From 5d9dc0a5324b94bfb2b90f4c9df6a1ba8f7a16dc Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 26 Oct 2026 15:34:27 -0400 Subject: [PATCH] chore(release): 1.8.0 --- .github/DISCUSSION_TEMPLATE/ideas.yml | 28 + .github/DISCUSSION_TEMPLATE/q-a.yml | 30 + .github/DISCUSSION_TEMPLATE/show-and-tell.yml | 33 ++ .github/ISSUE_TEMPLATE/bug_report.yml | 105 ++++ .github/ISSUE_TEMPLATE/config.yml | 11 + .github/ISSUE_TEMPLATE/feature_request.yml | 62 ++ .github/workflows/rc.yml | 47 ++ .github/workflows/release.yml | 4 +- .gitignore | 2 + CHANGELOG.md | 40 ++ CITATION.cff | 4 +- CONTRIBUTING.md | 21 + README.md | 36 +- codemeta.json | 6 +- composer.json | 4 +- docs/GUARANTEES.md | 21 +- docs/PUBLIC-API.md | 56 +- scripts/build_php74.py | 451 +++++++++++++++ scripts/fs_cleanup.py | 30 + src-legacy/Algorithm/BeamPacker.php | 30 +- src-legacy/Algorithm/CandidateFinder.php | 64 ++- src-legacy/Algorithm/Deadline.php | 52 +- src-legacy/Algorithm/ExactSmallSolver.php | 2 +- src-legacy/Algorithm/GridSolver.php | 5 +- .../Algorithm/HomogeneousBlockSolver.php | 86 ++- src-legacy/Algorithm/MaximalSpaceSolver.php | 21 +- src-legacy/Algorithm/ObjectiveBoundScope.php | 33 ++ src-legacy/Algorithm/ObjectiveBounds.php | 311 ++++++---- src-legacy/Algorithm/SearchStats.php | 15 - src-legacy/Algorithm/SolverOrchestrator.php | 194 +++---- src-legacy/Artifacts/OperationalArtifact.php | 2 +- src-legacy/Constraint/AxleLoad.php | 52 +- src-legacy/Constraint/AxleLoadConstraint.php | 39 +- src-legacy/Constraint/Internal/AxleTotals.php | 128 +++++ .../Constraint/Internal/LoadAnalysis.php | 60 +- src-legacy/Constraint/LoadCalculator.php | 16 +- src-legacy/Constraint/SupportConstraint.php | 4 + src-legacy/Constraint/VolumeReserve.php | 3 + src-legacy/Packer.php | 73 ++- .../Sequence/LoadingDependencyGraph.php | 11 +- .../Sequence/UnloadingDependencyGraph.php | 14 +- src-legacy/Serialization/ArrayCodec.php | 38 +- src-legacy/Support/CanonicalJson.php | 4 +- src-legacy/Support/FixedPlacementShape.php | 5 +- src-legacy/Support/JsonValue.php | 10 +- src-legacy/Support/RequestRules.php | 19 + src-legacy/Support/ResultValidation/Bound.php | 531 ++++++++++++++++++ src-legacy/Support/ResultValidation/Json.php | 18 + .../Support/ResultValidation/Lattice.php | 11 +- .../ResultValidation/ObjectiveCheck.php | 10 +- src-legacy/Support/ResultValidation/Spec.php | 12 +- src-legacy/Support/ResultValidation/Units.php | 39 ++ .../Support/ResultValidation/Validator.php | 8 +- .../IndependentSolutionValidator.php | 7 +- src/Algorithm/BeamPacker.php | 20 +- src/Algorithm/CandidateFinder.php | 60 +- src/Algorithm/Deadline.php | 51 +- src/Algorithm/ExactSmallSolver.php | 2 +- src/Algorithm/GridSolver.php | 5 +- src/Algorithm/HomogeneousBlockSolver.php | 86 ++- src/Algorithm/MaximalSpaceSolver.php | 21 +- src/Algorithm/ObjectiveBoundScope.php | 33 ++ src/Algorithm/ObjectiveBounds.php | 303 ++++++---- src/Algorithm/SearchStats.php | 15 - src/Algorithm/SolverOrchestrator.php | 174 +++--- src/Artifacts/OperationalArtifact.php | 2 +- src/Constraint/AxleLoad.php | 52 +- src/Constraint/AxleLoadConstraint.php | 36 +- src/Constraint/Internal/AxleTotals.php | 128 +++++ src/Constraint/Internal/LoadAnalysis.php | 58 +- src/Constraint/LoadCalculator.php | 16 +- src/Constraint/SupportConstraint.php | 4 + src/Constraint/VolumeReserve.php | 3 + src/Packer.php | 72 ++- src/Sequence/LoadingDependencyGraph.php | 11 +- src/Sequence/UnloadingDependencyGraph.php | 14 +- src/Serialization/ArrayCodec.php | 32 +- src/Support/CanonicalJson.php | 4 +- src/Support/FixedPlacementShape.php | 5 +- src/Support/JsonValue.php | 10 +- src/Support/RequestRules.php | 19 + src/Support/ResultValidation/Bound.php | 531 ++++++++++++++++++ src/Support/ResultValidation/Json.php | 18 + src/Support/ResultValidation/Lattice.php | 11 +- .../ResultValidation/ObjectiveCheck.php | 10 +- src/Support/ResultValidation/Spec.php | 12 +- src/Support/ResultValidation/Units.php | 39 ++ src/Support/ResultValidation/Validator.php | 8 +- .../IndependentSolutionValidator.php | 7 +- tests/BoundLoadingTest.php | 58 ++ tests/EffortBoundaryTest.php | 273 +++++++++ tests/ObjectiveBoundsTest.php | 169 +++++- tests/PackingTest.php | 22 +- tests/RequestObjectsCorpusTest.php | 33 +- tests/ResultValidationBoundTest.php | 186 ++++++ tests/SolverTest.php | 61 +- tools/php-compat/composer.json | 13 + tools/php-compat/composer.lock | 143 +++++ tools/php-compat/rector.php | 34 ++ 99 files changed, 4933 insertions(+), 849 deletions(-) create mode 100644 .github/DISCUSSION_TEMPLATE/ideas.yml create mode 100644 .github/DISCUSSION_TEMPLATE/q-a.yml create mode 100644 .github/DISCUSSION_TEMPLATE/show-and-tell.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/rc.yml create mode 100644 scripts/build_php74.py create mode 100644 scripts/fs_cleanup.py create mode 100644 src-legacy/Algorithm/ObjectiveBoundScope.php create mode 100644 src-legacy/Constraint/Internal/AxleTotals.php create mode 100644 src-legacy/Support/ResultValidation/Bound.php create mode 100644 src/Algorithm/ObjectiveBoundScope.php create mode 100644 src/Constraint/Internal/AxleTotals.php create mode 100644 src/Support/ResultValidation/Bound.php create mode 100644 tests/BoundLoadingTest.php create mode 100644 tests/EffortBoundaryTest.php create mode 100644 tests/ResultValidationBoundTest.php create mode 100644 tools/php-compat/composer.json create mode 100644 tools/php-compat/composer.lock create mode 100644 tools/php-compat/rector.php diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml new file mode 100644 index 0000000..500e98d --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -0,0 +1,28 @@ +title: "[Idea] " +labels: ["idea"] +body: + - type: markdown + attributes: + value: | + Ideas for new packing rules, outputs or integrations. An idea the maintainers take on + is moved to an issue and linked back here, so you can follow it. + - type: textarea + id: problem + attributes: + label: The problem behind the idea + description: What real shipment or load does today's library not handle? + validations: + required: true + - type: textarea + id: idea + attributes: + label: The idea + validations: + required: true + - type: input + id: package + attributes: + label: Package(s) you use + placeholder: "packvium/packvium" + validations: + required: false diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yml b/.github/DISCUSSION_TEMPLATE/q-a.yml new file mode 100644 index 0000000..ebec750 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-a.yml @@ -0,0 +1,30 @@ +title: "[Q&A] " +labels: ["question"] +body: + - type: markdown + attributes: + value: | + Ask anything about using the library. If you have found a wrong result or an error, + a bug report is the better place. + - type: input + id: package-version + attributes: + label: Package and version + placeholder: "packvium/packvium 1.8.0 (PHP)" + validations: + required: true + - type: textarea + id: question + attributes: + label: Your question + description: What you are trying to pack, and what you tried. + validations: + required: true + - type: textarea + id: request + attributes: + label: Request (optional) + description: The JSON request or code, if it helps. + render: json + validations: + required: false diff --git a/.github/DISCUSSION_TEMPLATE/show-and-tell.yml b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml new file mode 100644 index 0000000..ba6ac16 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml @@ -0,0 +1,33 @@ +title: "[Show and tell] " +labels: ["show-and-tell"] +body: + - type: markdown + attributes: + value: | + Using the library in a shop, a warehouse tool or a load planner? Tell us how. Leave out + anything confidential about your business. + - type: textarea + id: what + attributes: + label: What you built + validations: + required: true + - type: input + id: package-version + attributes: + label: Package and version + placeholder: "packvium/packvium 1.8.0 (PHP)" + validations: + required: false + - type: textarea + id: lessons + attributes: + label: What worked, and what got in the way + validations: + required: false + - type: input + id: link + attributes: + label: Link (optional) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4991b7c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,105 @@ +name: Bug report +description: A result that is wrong, an error you did not expect, or a crash. +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. A report we can run is a report we can fix, so the most useful + thing below is the smallest request that shows the problem. + + **Security problems do not belong here.** Use **Report a vulnerability** under the + Security tab instead; it opens a private advisory. + - type: dropdown + id: package + attributes: + label: Package + description: Which package did you call? + options: + - "packvium (PyPI) — Packvium for Python" + - "packvium-native (PyPI) — Packvium native backend selector for Python" + - "packvium/packvium (Packagist) — Packvium for PHP" + - "packvium/native-bridge (Packagist) — Packvium native bridge for PHP" + - "@packvium/engine (npm) — Packvium for Node.js" + - "@packvium/browser (npm) — Packvium for browsers" + - "packvium (crates.io) — Packvium for Rust" + - "packvium-native-rust (PyPI) — Packvium native engines: Python wheels, Node addons and the C library" + - Not sure + default: 2 + validations: + required: true + - type: input + id: version + attributes: + label: Package version + description: The exact version installed, for example 1.8.0. + placeholder: "1.8.0" + validations: + required: true + - type: input + id: runtime + attributes: + label: Runtime and operating system + description: Language runtime version and OS, for example "Python 3.12, Ubuntu 24.04" or "PHP 8.3, macOS 15". + validations: + required: true + - type: dropdown + id: profile + attributes: + label: Search profile + options: + - fast + - balanced + - quality + - exact_small + - Not set / default + - Not sure + validations: + required: false + - type: textarea + id: request + attributes: + label: Request that reproduces it + description: | + The JSON request, or the code that builds it, cut down as far as it still shows the + problem. Remove anything confidential; renamed item ids are fine. + render: json + validations: + required: true + - type: textarea + id: actual + attributes: + label: What happened + description: The result (or its relevant part), or the full error message and stack trace. + render: text + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + description: What the result should have been, and why. If another of the packages gives a different answer for the same request, say which. + validations: + required: true + - type: dropdown + id: stopped-by + attributes: + label: How was the search limited? + description: A search stopped by a time limit can differ between runs by design; one stopped by an effort budget cannot. + options: + - effort_budget + - time_limit_ms + - Both + - Neither / not sure + validations: + required: false + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I searched the existing issues and did not find this one. + required: true + - label: I read [docs/GUARANTEES.md](https://github.com/toxakara/packvium-php/blob/HEAD/docs/GUARANTEES.md) and this is not something it says the library does not guarantee. + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8cda1f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Question or help with a request + url: https://github.com/toxakara/packvium-php/discussions/categories/q-a + about: Ask in Discussions → Q&A. Answers there help the next person too. + - name: Report a security vulnerability + url: https://github.com/toxakara/packvium-php/security/advisories/new + about: Report privately. Do not open a public issue for a security problem. + - name: Documentation + url: https://packvium.com/docs + about: Guides, the request contract and the constraint reference. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..1e0ae8c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,62 @@ +name: Feature request +description: A packing rule, constraint, output or integration the library does not support yet. +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Open-ended ideas are welcome in **Discussions → Ideas** first. Use this form when you can + describe a concrete problem the library does not solve today. + - type: dropdown + id: package + attributes: + label: Package you use + options: + - "packvium (PyPI) — Packvium for Python" + - "packvium-native (PyPI) — Packvium native backend selector for Python" + - "packvium/packvium (Packagist) — Packvium for PHP" + - "packvium/native-bridge (Packagist) — Packvium native bridge for PHP" + - "@packvium/engine (npm) — Packvium for Node.js" + - "@packvium/browser (npm) — Packvium for browsers" + - "packvium (crates.io) — Packvium for Rust" + - "packvium-native-rust (PyPI) — Packvium native engines: Python wheels, Node addons and the C library" + - More than one + default: 2 + validations: + required: true + - type: input + id: version + attributes: + label: Package version + placeholder: "1.8.0" + validations: + required: true + - type: textarea + id: problem + attributes: + label: The problem + description: What are you packing, into what, and what rule or output is missing? A real (anonymised) example helps more than a general description. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What you would like + description: The behaviour or field you have in mind, if you have one. + validations: + required: false + - type: textarea + id: workaround + attributes: + label: What you do today + description: Any workaround you use, and what it costs you. + validations: + required: false + - type: markdown + attributes: + value: | + Every package implements one shared contract, so a new rule or result field lands in all + of them together, and a change to the request or result format waits for a release that + is allowed to change it. A request that is accepted may therefore take more than one + release to ship. diff --git a/.github/workflows/rc.yml b/.github/workflows/rc.yml new file mode 100644 index 0000000..6f36b1a --- /dev/null +++ b/.github/workflows/rc.yml @@ -0,0 +1,47 @@ +name: rc + +# Packagist does not take an upload: it reads this repository's tags, and imports a +# pre-release tag (`v1.9.0-rc.1`) as an RC-stability version. Composer skips it under the +# default `minimum-stability: stable`, so a consumer opts in explicitly: +# composer require packvium/packvium:@RC +# This workflow runs the same checks `release.yml` runs. A bad RC tag gets a red check, +# but that does not prevent Packagist from importing the tag. Check before pushing it. +# There is no GitHub Release: an RC is not a release. +# Nothing is needed from the owner: the repository is already linked on Packagist. + +on: + push: + tags: ["v*-*"] + +permissions: + contents: read + +jobs: + verify-tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: "8.3" + coverage: none + + # composer.json deliberately carries no `version` field -- Composer derives it from + # the tag, and a hand-maintained copy only ever drifts. The changelog is the file + # that does state a version, so that is what the tag is checked against: a tag with + # no matching changelog entry is a release nobody wrote down. + - name: The tag must have a changelog entry + run: | + tagged="${GITHUB_REF_NAME#v}" + if ! grep -q "^## \[${tagged}\]" CHANGELOG.md; then + echo "CHANGELOG.md has no '## [${tagged}]' section for tag $GITHUB_REF_NAME" >&2 + exit 1 + fi + + - run: composer validate --strict --no-check-lock + - name: Lint every shipped file + run: | + for file in $(find src tests examples -name '*.php') bin/packvium; do + php -l "$file" > /dev/null + done + - run: php tests/run.php diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 435eeef..18ca855 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,9 +9,11 @@ name: release # https://packagist.org/packages/submit and enable the GitHub service hook it offers, so # later tags are picked up without anyone pressing "Update". +# A pre-release tag (`v1.9.0-rc.1`) never reaches the production registries: the negated +# pattern must follow the positive one, and `rc.yml` takes those tags instead. on: push: - tags: ["v*"] + tags: ["v*", "!v*-*"] permissions: contents: read diff --git a/.gitignore b/.gitignore index 12fb64f..e29d169 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /.phpunit.cache/ /.php-cs-fixer.cache /composer.lock +/build/ +/tools/php-compat/vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e53419..30af470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ What changed in `packvium/packvium` on Packagist, release by release. The format [Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] + +Results now say how far they can be from the best possible answer, `optimal` means a proof, +and a mistyped flag is refused instead of guessed. + +### Added + +- **`optimality.lower_bound` and the gap** under the `default` and `lowest_cost` objectives; + `Packvium\Validation\validate` checks them. +- **The `src-legacy/` generator** (`scripts/build_php74.py`, `tools/php-compat/`), which + rebuilds the shipped tree byte for byte from a tag. + +### Changed + +- **Attaining the bound, not search exhaustion, sets `optimal`** (`proven_optimal`). +- **An effort budget names a stop:** a run that ends on exactly its budget is `complete`, and + `time_limit_ms` is no longer split across starts. Check `termination.any_start_truncated`. +- **`ArrayCodec::pack` refuses a non-boolean `keep_upright`, `stackable`, `must_be_on_floor` + or `require_placement_coordinates`** as `wrong_type`; `"no"` packed as stackable. The + constructors are unchanged: without `strict_types`, PHP still converts the value. + `Packvium\Validation\validate` throws `MalformedResult` (`contract`) for such a request, and + reads `null` as the default. +- **Solves 2-15% faster**, the loading-order check about twice as fast, identical answers. +- **The JSON path avoids a redundant object check:** `ArrayCodec::pack` retains request + admission and model checks, and compiles fewer files. Direct measurements on the macOS + host removed about 0.5 ms of compilation/checking work on the unpackable request; the + additional end-to-end gain was within the A/A noise. This is wall time, not CPU time. +- **Placement under axle limits is about 2.5-3x faster**, unloading orders about 5% faster; + answers are identical. + +### Fixed + +- **`homogeneous_blocks` named alone packs under `fast` and `balanced`**, and a node-limit + stop is no longer reported as `time_limit`. +- **A bound past the declared ceiling no longer throws `BoundOverflowException`.** +- **`validate` reads `units: null` and `units.length: null` as millimetres**, as the engines do, + and refuses a `units` that is not an object, or an unknown `units.length`, as a `contract` + `MalformedResult` naming the field. `units.weight` is not a field and is ignored: put the unit + on each weight. + ## [1.7.0] The reference validator ships in the package, `quality` looks ahead in its block solver, and a diff --git a/CITATION.cff b/CITATION.cff index b6be0a5..922d5ab 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - name: "Packvium contributors" website: "https://packvium.com" -version: "1.7.0" +version: "1.8.0" license: MIT url: "https://packvium.com" repository-code: "https://github.com/toxakara/packvium-php" @@ -23,3 +23,5 @@ keywords: - packing-algorithm - logistics - deterministic + - box-packing + - load-planning diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ffd917..264b049 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,3 +75,24 @@ exact input. For anything with security implications, follow [SECURITY.md](SECURITY.md) rather than opening a public issue. + +## How your feedback is handled + +Every issue and discussion is read by a maintainer. This is what happens next: + +1. **New issues are labelled `needs-triage`.** A maintainer answers with one of: a question + back to you, a confirmation, or a reason it will not be taken on. +2. **A confirmed bug** gets the `bug` label and is reproduced from your request. A wrong result + in one package is checked against the others, because all of them implement the same + contract and must agree. +3. **An accepted feature request or idea** is added to the project's backlog, and the issue is + labelled `accepted` with a comment saying so. An idea from Discussions is first turned into + an issue and linked back to the discussion. +4. **When the change ships**, the issue is closed with the release it shipped in. A change to + the request or result format can only ship in a release that is allowed to change it, so + it may take more than one release. +5. **Declined** requests are closed with the reason, so you are never left waiting on a + silent issue. + +Questions go to **Discussions → Q&A**; security problems go to **Report a vulnerability** +under the Security tab, never to a public issue. diff --git a/README.md b/README.md index 21d9da0..44826ec 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ coordinates and rotations for each item, with a reason for anything that did not Full documentation, the constraint reference and benchmarks live at [packvium.com](https://packvium.com). -> **Version 1.7.0 — the public API is frozen.** Field names, status codes and the +> **Version 1.8.0 — the public API is frozen.** Field names, status codes and the > objective vector do not change without a major version, so any `1.x` is a safe upgrade > from any earlier `1.x`. > Read [docs/GUARANTEES.md](https://github.com/toxakara/packvium-php/blob/main/docs/GUARANTEES.md) @@ -96,7 +96,8 @@ Lengths accept `mm`, `cm`, `m`, `in`, `ft` and ticks; weights `mg`, `g`, `kg`, ` Pass numbers as strings or integers — the API has no float parameters for a measure. Round item exteriors outward and container interiors inward before building the request. Volumes that exceed `PHP_INT_MAX` are handled in exact decimal-string arithmetic rather -than losing precision. See +than losing precision. A JSON request's `units` sets only the length unit; a weight takes its +unit from the measure or is grams, and `units.weight` is ignored today. See [docs/UNITS-AND-NUMERICS.md](https://github.com/toxakara/packvium-php/blob/main/docs/UNITS-AND-NUMERICS.md) and [`units.php`](https://github.com/toxakara/packvium-php/blob/main/examples/units.php). @@ -128,6 +129,11 @@ with `Packvium\Config\EffortBudget`, part of the frozen API since 1.6.0. [`reproducibility.php`](https://github.com/toxakara/packvium-php/blob/main/examples/reproducibility.php) shows all three outcomes. +`effort_limit` means the budget stopped work the search still had: a search that finishes on +exactly its budget reads `complete`, and under a budget `time_limit_ms` is one fuse for the +whole solve, not a slice per start. `termination.any_start_truncated` says whether the budget +cut a start that might have won. + ## Errors A request that no engine may answer throws `Packvium\Serialization\InvalidRequestException`, @@ -163,6 +169,9 @@ try { - An unknown `objective` or `access_directions` value in a request raises `InvalidRequestException` with reason `not_allowed` and a `field()` pointing to the bad value. Direct domain constructors may raise their own argument errors. +- Since 1.8.0, `keep_upright`, `stackable`, `must_be_on_floor` and + `require_placement_coordinates` must be JSON booleans: `"no"`, `0` or `1` raises + `InvalidRequestException` with reason `wrong_type`. A request that is valid but does not fit completely is not an error: the result lists what was left out, and why, in `unpacked_items`. @@ -249,6 +258,10 @@ where their answers agree. maximal-space and bounded exact search, selected by problem shape and profile. - **Answers you can check.** Every solution is re-validated by logic independent of the search. Unplaced items come back with a reason code, not silently missing. +- **A proof, or the distance to one.** Under the `default` and `lowest_cost` objectives every + result states `optimality.lower_bound`. A complete result that attains it reports `optimal`, + which proves no packing obeying the request's rules scores better; any other carries the gap + to the bound (`gap_key`, `absolute_gap`, `relative_gap`). - **Deterministic.** The same input, seed and effort budget produce the same result, and the result says when a clock, not the budget, stopped the search. - **Multi-container and nested.** Split across containers, or pack containers into @@ -324,6 +337,21 @@ The array API (`ArrayCodec::pack`) takes and returns plain arrays of strings and so code written against it is the same on every version. The shipped examples are 8.2 code and are not meant to run below it. +### Rebuilding `src-legacy/` + +The generator ships in this repository: `scripts/build_php74.py` (Python 3), the Rector +configuration in `tools/php-compat/rector.php`, and the Rector version pinned in +`tools/php-compat/composer.lock`. From a checkout of a release tag, with PHP 8.2+ and +Composer installed: + +```bash +composer install --working-dir=tools/php-compat +python3 scripts/build_php74.py # writes build/php-legacy/ +python3 scripts/build_php74.py --verify # checks the tree against its receipt +``` + +`build/php-legacy/src/` is then the tag's `src-legacy/`, byte for byte. + ## The Packvium family One request and result contract, implemented independently in four engines (Rust, @@ -339,7 +367,7 @@ Documentation, the constraint reference and the benchmarks are at | --- | --- | --- | | Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) | | PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) | -| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.7"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | +| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.8"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | | Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) | | Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) | | PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) | @@ -363,7 +391,7 @@ CodeMeta form. @software{packvium_php, author = {{Packvium contributors}}, title = {Packvium for PHP}, - version = {1.7.0}, + version = {1.8.0}, license = {MIT}, url = {https://packvium.com} } diff --git a/codemeta.json b/codemeta.json index 6c6174b..ca3ec69 100644 --- a/codemeta.json +++ b/codemeta.json @@ -3,7 +3,7 @@ "@type": "SoftwareSourceCode", "name": "Packvium for PHP", "description": "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.", - "version": "1.7.0", + "version": "1.8.0", "license": "https://spdx.org/licenses/MIT", "author": [ { @@ -26,6 +26,8 @@ "truck-loading", "packing-algorithm", "logistics", - "deterministic" + "deterministic", + "box-packing", + "load-planning" ] } diff --git a/composer.json b/composer.json index 64a8285..7b3c315 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,9 @@ "truck-loading", "packing-algorithm", "logistics", - "deterministic" + "deterministic", + "box-packing", + "load-planning" ], "homepage": "https://packvium.com", "support": { diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index e0949d9..8cdcba6 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -14,8 +14,11 @@ After a result reports `feasible` or `optimal` and passes independent validation - **exact arithmetic** — all geometry is integer, in units of 1/16000 mm for length and 1/8 µg for weight. No coordinate is ever a binary floating-point number, so no placement decision depends on rounding; -- **determinism** — identical input and identical seed produce an identical result, on - any platform, when the search completes or stops on `configuration.effort_budget`. A +- **determinism** — for a fixed backend and version, identical input and seed produce + identical packing and deterministic diagnostics on supported platforms when the search + completes or stops on `configuration.effort_budget`. Wall-clock timing diagnostics, + such as `duration_ms` and `elapsed_ms`, are excluded. This does not promise identical + solutions between backends; their documented native quality differences remain. A search stopped by `time_limit_ms` stops where the host's speed let it, so it may differ between runs; the result says which limit stopped it; - **complete accounting** — every requested item instance appears exactly once, either @@ -24,9 +27,15 @@ After a result reports `feasible` or `optimal` and passes independent validation ## It does not guarantee - **A globally optimal packing.** The `fast`, `balanced` and `quality` profiles are - heuristic. Only `exact_small`, within its item limit, searches exhaustively — and even - then optimality is not certified in the result. A returned solution is a good solution, - not a proven best one. + heuristic, and `exact_small` searches exhaustively only within its item limit. A returned + solution is a good solution, not a proven best one, unless the result proves it: under the + `default` and `lowest_cost` objectives a result states `optimality.lower_bound`, and a + complete result whose score attains that bound reports `optimal` / `proven_optimal`. That + proves no packing obeying the request's rules scores better on that objective; it says + nothing about a rule the request cannot express. Any other stated result carries the gap + to the bound, an upper limit on what a longer search could still gain. The statement is + withheld if its gap exceeds the portable integer ceiling of `2^53 - 1`, even when the + request's bound fits. Where geometry makes a request hard, expect a gap. - **Physical safety in transit.** Nothing here models vibration, shock, acceleration, tipping, strapping or load shifting. A geometrically valid packing can still be an unsafe shipment. @@ -52,7 +61,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.7.0` freezes the public API. Field names, status codes, the objective +Version `1.8.0` freezes the public API. Field names, status codes, the objective vector, the numeric policy and the validation rules do not change without a major version, so any `1.x` is a safe upgrade from any earlier `1.x`. A caret or tilde constraint on `1.0` is enough; an exact pin is no longer required. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index a784f42..d737e88 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -155,10 +155,16 @@ request field. - `termination.code`: `complete`, `time_limit`, `effort_limit`, or `error`; - `optimality.code`: `proven_optimal`, `proven_infeasible`, `best_found`, or `not_proven`. The 1.1.0 freeze reserved and typed `gap_key`, `absolute_gap` and `relative_gap` - alongside it. The pre-1.1.0 extension point remains open: closing the object would - make a previously valid producer fail a 1.1.0 validator, which is a breaking change - and therefore not legal in this minor release. No engine emits the reserved fields - yet, so `code` is still the only key any Packvium result carries; + alongside it, and the 1.8.0 freeze added `lower_bound`: the lower bound on + `score`, in the result's own score order, through the first position the result misses + -- or all of it when none is missed, which proves the result optimal for its objective + under the rules this library models. `gap_key` requires `lower_bound`, and absence of + `lower_bound` makes no statement at all; it is never "attained". The pre-1.1.0 + extension point remains open: closing the object would make a previously valid + producer fail a validator, which is a breaking change. Every engine emits these fields + since, on the primary result and on each alternative, for the `default` and + `lowest_cost` objectives; a complete result that attains its bound reports `status: + optimal` and `code: proven_optimal`, and nothing else does; - legacy `status`: `optimal`, `feasible`, `best_found`, `time_limit`, `infeasible`, or `invalid_result`; - packed containers with exact coordinates, dimensions, rotations, and a `centre_of_mass_offset_ppm` — the weighted centre of mass's exact-integer Chebyshev @@ -238,7 +244,10 @@ objective vector. `algorithm.time_limit_reached` and `algorithm.effort_limit_reached` identify distinct, mutually exclusive causes. The former is only a wall-clock deadline. The latter means -one of the request's counted-work boundaries was reached. An unpacked `time_limit` or +the selected start was stopped by one of the request's counted-work boundaries: the search +had more work to do and the budget refused it. A start that finished having used exactly its +budget was not stopped, and reports neither flag. No counter in `algorithm.metrics` ends +above its limit: each counted step is checked before it is taken. An unpacked `time_limit` or `effort_limit` reason must match the corresponding flag. ## Loading and unloading sequences @@ -401,8 +410,9 @@ deadline: Each fact is an open object with a required string `code`. Consumers must preserve an unknown code and any additional fields, allowing future effort/node/cancellation limits without turning a minor release into a parsing break. `status` remains for one -compatibility cycle and is checked against the three facts. The alpha release emits no -`proven_optimal` claim without a future proof/certificate. +compatibility cycle and is checked against the three facts. `proven_optimal` is emitted only +on a complete result whose score attains its stated `optimality.lower_bound`: a proof that no +packing obeying the request's rules scores better on the result's own objective. `status: infeasible`, with `feasibility: infeasible` and `optimality: proven_infeasible`, is emitted only when the portfolio ranks no result at all. A request whose every item is proven @@ -452,6 +462,23 @@ The fields answer separate questions: | `winning_start_truncated` | The selected packing itself came from an interrupted start. | | `global_deadline_reached` | The portfolio-wide deadline expired, independently of individual start slices. | +**Under an `effort_budget` the clock is the request's fuse, and fires only as a whole.** +Without one, Python and PHP give each start an even share of the time left, so one slow start +cannot starve the rest. With one, every start may use all the time left: a request that +bounds its work by counting must get the same answer on any machine, and a start cut by its +own share of the clock would lose quietly, with no flag saying the answer depended on the +machine's speed. When the clock does fire, the portfolio-wide deadline is reached and +`time_limit_reached` is true. Rust and the JavaScript fallback always run their starts against +the one portfolio deadline. Each start keeps its own counters against the whole budget in +Python, PHP and Rust; the JavaScript fallback hands each later start what the earlier ones +left of one budget, so the same number buys less search there. + +**Read `any_start_truncated` when the answer must not depend on the budget.** The selected +start can finish while another start, one that might have won, was stopped by the budget; the +top-level flags then read `complete`, because they describe the selected start. +`any_start_truncated` is the only field that says a larger budget could change the answer. A +checkout that caches a quote refuses one on it (ECOMMERCE-CHECKOUT-INTEGRATIONS.md D3). + The aggregate fields are derived only from `starts`. Exactly one record is selected. `completed` and `truncated` are mutually exclusive and both require `started`. `termination.code` is `time_limit` when the selected start or portfolio hit the wall @@ -526,9 +553,10 @@ valid. A result without the documented shape is refused as `MalformedResult`, wh same as one JSON document for a binding. - PyO3 (`packvium_native_rust`): `validate_json(request, result)` over JSON text, a list of `{"code", "detail"}` dicts, `MalformedResult` (a `ValueError`) with `kind`. -- `@packvium/engine`: `validate(request, result)`, `MalformedResult`, and - `ValidatorUnavailableError` (`code: 'validator_unavailable'`) when the compiled addon is - absent, until the JavaScript fallback's port. +- `@packvium/engine`: `validate(request, result)` and `MalformedResult`; the compiled addon + answers when it is loaded and the package's JavaScript port otherwise (1.8.0). + `ValidatorUnavailableError` (`code: 'validator_unavailable'`) is still exported but + deprecated: nothing throws it since 1.8.0. - `@packvium/browser`: `validate(request, result)`, a promise, `MalformedResult`. - PHP: `Packvium\Validation\validate(array $request, array $result)`, a `list` @@ -727,3 +755,11 @@ depend on the exact shape. Python, PHP, Rust and the JavaScript fallback accept the same top-level keys: `units`, `configuration`, `items`, `containers`, `catalog_versions_used`, `policy`, `output` and `fixed_placements`. + +`units` holds one key, `length`: the unit of every length written without one, millimetres when +`units`, or `units.length`, is absent or `null`. There is no request-wide weight unit. A weight +takes its unit from the measure itself (`"2.5 kg"`, `{"value": "2.5", "unit": "kg"}`), and a +bare number is grams. `units.weight` is not a field: today every engine ignores it without an +error, so `{"length": "cm", "weight": "kg"}` still reads a bare weight `2` as 2 g. Write the unit +on each weight instead. A future release that changes the request schema will refuse unknown +keys in `units`, as it already refuses them in `configuration`. diff --git a/scripts/build_php74.py b/scripts/build_php74.py new file mode 100644 index 0000000..9a4ec2b --- /dev/null +++ b/scripts/build_php74.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""Build the generated, non-canonical distribution tree for PHP below 8.2. + +The floor is whatever `tools/php-compat/rector.php` targets and the generated +`composer.json` declares -- 7.3 as of 0.1.3. The `php74` in this file's own name is +historical and means nothing; the tree is never shipped under it. + +It runs in two layouts. In the development workspace the canonical source is the +`packvium-php/` directory beside `scripts/`. In a checkout of the published packvium-php +repository, which carries this script, `tools/php-compat/` and `scripts/fs_cleanup.py`, the +canonical source is the checkout itself. Either way the tree is written to `--output`, +relative to the directory holding `scripts/`: + + composer install --working-dir=tools/php-compat + python3 scripts/build_php74.py # writes build/php-legacy/ + python3 scripts/build_php74.py --verify # checks it against its receipt +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fs_cleanup import remove_tree + +ROOT = Path(__file__).resolve().parents[1] +WORKSPACE_SOURCE = ROOT / "packvium-php" +SOURCE = WORKSPACE_SOURCE if WORKSPACE_SOURCE.is_dir() else ROOT +DEFAULT_OUTPUT = Path("build") / "php-legacy" +OUTPUT = ROOT / DEFAULT_OUTPUT +RECTOR = ROOT / "tools" / "php-compat" / "vendor" / "bin" / "rector" +RECTOR_CONFIG = ROOT / "tools" / "php-compat" / "rector.php" +RECEIPT = OUTPUT / "BUILD-RECEIPT.json" +FORMAT = "packvium-php74-build/v1" +SOURCE_INPUTS = ( + "LICENSE", + "README.md", + "autoload.php", + "bin", + "composer.json", + "examples", + "src", +) +TOOLCHAIN_INPUTS = ( + Path("scripts/build_php74.py"), + Path("tools/php-compat/composer.json"), + Path("tools/php-compat/composer.lock"), + Path("tools/php-compat/rector.php"), +) + + +def _use_output(output: Path) -> None: + """Point the build and its receipt at ``output``, relative to the directory holding scripts/.""" + global OUTPUT, RECEIPT + OUTPUT = ROOT / output + RECEIPT = OUTPUT / "BUILD-RECEIPT.json" + + +def _copy_source() -> None: + """Copy exactly the inputs the receipt binds, so both layouts generate the same tree. + + A checkout of the published repository also holds this toolchain, the generated + ``src-legacy/``, its docs and its CI; none of them is source, and copying the whole + directory would put them in the output. + """ + if OUTPUT.exists(): + remove_tree(OUTPUT) + OUTPUT.mkdir(parents=True) + ignore = shutil.ignore_patterns(".DS_Store", ".gitignore", ".phpunit.cache", "vendor") + for relative in SOURCE_INPUTS: + origin = SOURCE / relative + if origin.is_dir(): + shutil.copytree(origin, OUTPUT / relative, ignore=ignore) + else: + shutil.copy2(origin, OUTPUT / relative) + + +def _rewrite_metadata() -> None: + composer_path = OUTPUT / "composer.json" + composer = json.loads(composer_path.read_text()) + composer["require"]["php"] = ">=7.3" + composer.pop("autoload-dev", None) + composer.get("scripts", {}).pop("test", None) + composer["extra"] = { + **composer.get("extra", {}), + "packvium": { + "canonical_source": "PHP 8.2+", + "compatibility_artifact": "PHP 7.3", + }, + } + composer_path.write_text(json.dumps(composer, indent=2) + "\n") + (OUTPUT / "LEGACY-COMPATIBILITY.md").write_text( + "# Legacy PHP compatibility artifact\n\n" + "This tree is generated from the canonical Packvium PHP source with the " + "pinned AST downgrade toolchain. It exposes the same namespaces and public " + "features, but every PHP version it exists for is end-of-life and receives no upstream " + "security updates. Prefer a currently supported PHP whenever possible.\n" + ) + + +def _downgrade() -> None: + if not RECTOR.is_file(): + raise SystemExit( + "missing tools/php-compat/vendor/bin/rector; " + "run `composer install --working-dir=tools/php-compat`" + ) + env = os.environ.copy() + env["PACKVIUM_PHP74_ROOT"] = str(OUTPUT) + command = [ + str(RECTOR), + "process", + "--config", + str(RECTOR_CONFIG), + "--no-progress-bar", + "--no-diffs", + ] + first = subprocess.run(command, cwd=ROOT, env=env, check=False) + if first.returncode == 0: + return + + # Rector writes every successfully transformed file before reporting a + # file-specific reflection failure. A second pass over that simpler intermediate + # tree is deterministic and is required for the 0.1.2 examples; never accept the + # partial first pass unless the bounded retry itself completes successfully. + print("legacy PHP downgrade: retrying once after Rector's partial-write failure") + second = subprocess.run(command, cwd=ROOT, env=env, check=False) + if second.returncode != 0: + raise subprocess.CalledProcessError(second.returncode, command) + + +def _patch_downgraded_enums() -> None: + """Complete Rector's scalar-backed-enum lowering for typed public properties. + + Rector correctly replaces enum cases with string constants, but intentionally + leaves a few value-oriented usages for project-specific handling. Packvium's + enums are closed scalar vocabularies, so the downgraded representation is a final + constants class with deterministic ``from``/``cases`` helpers. + """ + + enum_types = ("SolverProfile", "PackingStatus", "Rounding", "Rotation", "ShapeType") + type_pattern = re.compile(r"\b(?:" + "|".join(enum_types) + r")\s+\$") + # `->value` must strip only where it accesses an actual (now string-backed) + # enum member -- never a same-named but unrelated property such as the + # `maximum_value` objective's `Item::$value`, which a blind `str.replace("->value", "")` would corrupt into + # `$this=$value` by deleting the `->value` out of `$this->value=$value`. + # Enum-holding names are collected from two shapes, across every file first + # (a name's *type* is declared in whichever file defines that property/param, + # not necessarily the file that later chains through it): + # - a typed property or parameter: `SolverProfile $profile`, `Rotation $rotation` + # - the `Type::all()`/`Type::cases()` iteration idiom: `fn($x) => $x->value`, + # whose untyped closure parameter is never caught by the first shape + name_pattern = re.compile(r"\b(?:" + "|".join(enum_types) + r")\s+\$(\w+)") + arrow_pattern = re.compile(r"fn\s*\(\s*\$(\w+)\s*\)\s*=>\s*\$\1->value\b") + sources = {path: path.read_text() for path in (OUTPUT / "src").rglob("*.php")} + enum_names: set[str] = set() + for source in sources.values(): + enum_names.update(name_pattern.findall(source)) + enum_names.update(arrow_pattern.findall(source)) + access_pattern = re.compile( + r"(\$(?:" + "|".join(re.escape(name) for name in enum_names) + r")" + r"|->(?:" + "|".join(re.escape(name) for name in enum_names) + r"))->value\b" + ) if enum_names else None + # A third shape, and the one that shipped broken: the case is fetched from the class + # rather than held in a name -- `ShapeType::RIGID_CUBOID->value`. The two patterns above + # only see `$name->value` and `->prop->value`, so this survived the lowering as a + # property fetch on a string constant: a PHP 8.1 parse error, on the one runtime range + # this tree exists to serve. It is scoped to the enum class names above so a genuine + # `Something::CONST->value` on a real object is left alone. + case_access_pattern = re.compile( + r"\b((?:" + "|".join(enum_types) + r")::[A-Za-z_]\w*)->value\b" + ) + for path, source in sources.items(): + source = type_pattern.sub("string $", source) + if access_pattern is not None: + source = access_pattern.sub(r"\1", source) + source = case_access_pattern.sub(r"\1", source) + path.write_text(source) + + templates = { + "Config/SolverProfile.php": ( + "value` +#: from a case, and calling `from`/`tryFrom`/`cases` on the class. `ShapeType` was added later, +#: never joined `enum_types`, and shipped inside `packvium/packvium@1.0.0` as +#: `src-legacy/Serialization/ArrayCodec.php` -- a parse error on every runtime the package +#: advertises. A hand-maintained list is what failed, so this asserts the *outcome*. +ENUM_LEAK_PATTERNS = ( + ("a `->value` fetch on a lowered case", r"::[A-Za-z_]\w*->value\b"), + ("a call to a method the lowering removes", r"::(?:from|tryFrom|cases)\("), +) + + +def _assert_enum_lowering_complete(enum_types: tuple[str, ...]) -> None: + """Fail the build if any lowered enum is still used as an enum. + + Scoped to the classes that are `enum` in the canonical source, so an unrelated class + with a `from()` factory is not implicated. A class listed in ``enum_types`` gets its + helpers written back by a template above and is exempt from the second pattern only -- + never from the first, which is a parse error regardless. + """ + declared = { + path.stem for path in (SOURCE / "src").rglob("*.php") + if re.search(r"(?m)^enum\s+\w", path.read_text()) + } + if not declared: + raise SystemExit("build_php74: no enums found in the canonical source to lower") + offenders: list[str] = [] + for path in sorted((OUTPUT / "src").rglob("*.php")): + source = path.read_text() + for label, suffix in ENUM_LEAK_PATTERNS: + exempt = set(enum_types) if "method" in label else set() + for name in sorted(declared - exempt): + if re.search(r"\b" + re.escape(name) + suffix, source): + offenders.append(f"{path.relative_to(OUTPUT)}: {name} -- {label}") + if offenders: + shown = "\n ".join(sorted(set(offenders))[:10]) + raise SystemExit( + f"build_php74: the enum lowering is incomplete in {len(set(offenders))} " + f"place(s); add the class to `enum_types` and give it a template:\n {shown}" + ) + + +def _digest(paths: list[Path], base: Path) -> tuple[str, int]: + digest = hashlib.sha256() + files = sorted(path for path in paths if path.is_file()) + for path in files: + digest.update(path.relative_to(base).as_posix().encode()) + digest.update(b"\0") + digest.update(hashlib.sha256(path.read_bytes()).digest()) + digest.update(b"\0") + return digest.hexdigest(), len(files) + + +def _source_digest() -> tuple[str, int]: + paths: list[Path] = [] + for relative in SOURCE_INPUTS: + candidate = SOURCE / relative + paths.extend(candidate.rglob("*") if candidate.is_dir() else [candidate]) + return _digest(paths, SOURCE) + + +def _toolchain_digest() -> tuple[str, int]: + return _digest([ROOT / path for path in TOOLCHAIN_INPUTS], ROOT) + + +def _artifact_digest() -> tuple[str, int]: + return _digest( + [ + path + for path in OUTPUT.rglob("*") + # Finder writes .DS_Store into the fresh tree between the build and its verify. + if path.name not in {"BUILD-RECEIPT.json", ".DS_Store"} + ], + OUTPUT, + ) + + +def _lint_on_host_php() -> list[str]: + """Parse every generated file with the host interpreter, not only on the floor. + + The tree serves 7.3 through 8.1 -- `autoload.php` hands it every runtime below 8.2 -- + so "valid on the floor" is not the requirement; "valid on the whole range" is. Exactly + one construct separates the two, and Rector emits it: an unparenthesized nested ternary, + deprecated on the old runtimes and a fatal parse error from 8.0. `DowngradeArraySpreadRector` + produces one for every `[...$x]` whose operand it cannot prove is an array, so three + solvers shipped a tree that the 7.4 gate passed and PHP 8.0 could not even parse. + Write `array_merge($a, $b)` instead of `[...$a, ...$b]` in downgraded source. + """ + files = sorted( + path for relative in ("src", "bin", "examples") + for path in (OUTPUT / relative).rglob("*") + if path.is_file() and path.suffix in {".php", ""} + ) + [OUTPUT / "autoload.php"] + offenders = [] + for path in files: + result = subprocess.run( + ["php", "-l", str(path)], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + detail = (result.stdout + result.stderr).strip().splitlines() + offenders.append(f"{path.relative_to(OUTPUT)}: {detail[0] if detail else 'parse error'}") + return offenders + + +def build() -> int: + _copy_source() + _rewrite_metadata() + # Lower the enums first, while the canonical type syntax is still there to read. The + # post-pass recognises an enum-typed name from a typed property or parameter and from + # the `fn($x) => $x->value` idiom -- both of which the downgrade rewrites away below + # 7.4, so running it afterwards silently missed three names and left `->value` on what + # was by then a plain string. PHP 7 evaluates that to null instead of raising. + _patch_downgraded_enums() + _downgrade() + source_digest, source_file_count = _source_digest() + toolchain_digest, toolchain_file_count = _toolchain_digest() + artifact_digest, artifact_file_count = _artifact_digest() + receipt = { + "format": FORMAT, + "canonical_source_digest": source_digest, + "canonical_source_file_count": source_file_count, + "toolchain_digest": toolchain_digest, + "toolchain_file_count": toolchain_file_count, + "artifact_digest": artifact_digest, + "artifact_file_count": artifact_file_count, + "minimum_php": "7.3", + "output": str(OUTPUT.relative_to(ROOT)), + } + RECEIPT.write_text(json.dumps(receipt, indent=2) + "\n") + print(f"generated {OUTPUT.relative_to(ROOT)} from canonical PHP source") + return 0 + + +def verify() -> int: + try: + receipt = json.loads(RECEIPT.read_text()) + composer = json.loads((OUTPUT / "composer.json").read_text()) + except (OSError, json.JSONDecodeError) as error: + print(f"legacy PHP artifact: FAIL — unreadable build output: {error}") + return 1 + + source_digest, source_file_count = _source_digest() + toolchain_digest, toolchain_file_count = _toolchain_digest() + artifact_digest, artifact_file_count = _artifact_digest() + expected = { + "format": FORMAT, + "canonical_source_digest": source_digest, + "canonical_source_file_count": source_file_count, + "toolchain_digest": toolchain_digest, + "toolchain_file_count": toolchain_file_count, + "artifact_digest": artifact_digest, + "artifact_file_count": artifact_file_count, + "minimum_php": "7.3", + "output": str(OUTPUT.relative_to(ROOT)), + } + problems = [ + f"{field} is stale ({receipt.get(field)!r} != {value!r})" + for field, value in expected.items() + if receipt.get(field) != value + ] + if composer.get("require", {}).get("php") != ">=7.3": + problems.append("generated Composer metadata does not require php >=7.3") + forbidden = [ + relative + for relative in ("build", "composer.lock", "tests", "vendor") + if (OUTPUT / relative).exists() + ] + if forbidden: + problems.append(f"transient paths leaked into artifact: {', '.join(forbidden)}") + problems.extend(_lint_on_host_php()) + if problems: + for problem in problems: + print(f"legacy PHP artifact: FAIL — {problem}") + return 1 + host = subprocess.run( + ["php", "-r", "echo PHP_VERSION;"], capture_output=True, text=True, check=True + ).stdout.strip() + print( + "legacy PHP artifact: PASS " + f"({artifact_file_count} files, sha256 {artifact_digest}; parses on PHP {host})" + ) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--verify", + action="store_true", + help="verify the generated tree and its source/toolchain-bound receipt", + ) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"where the tree is written, relative to the directory holding scripts/ (default {DEFAULT_OUTPUT})", + ) + arguments = parser.parse_args() + _use_output(arguments.output) + return verify() if arguments.verify else build() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fs_cleanup.py b/scripts/fs_cleanup.py new file mode 100644 index 0000000..c406e83 --- /dev/null +++ b/scripts/fs_cleanup.py @@ -0,0 +1,30 @@ +"""Remove a generated directory on a host that keeps writing metadata into it. + +On macOS, Finder and Spotlight re-create ``.DS_Store`` inside a directory *while* +``shutil.rmtree`` is emptying it, so the closing ``rmdir`` fails with "Directory not +empty". Linux CI never reproduces it. Retrying removes the stray file if the +writer only wins the race once per attempt. + +Failures other than that race are re-raised: a caller that wanted the tree gone must not +be told it is when it is not. The retry only asks ``shutil.rmtree`` to walk the +given tree again; an extra metadata sweep could follow a symlink at its root. +""" + +from __future__ import annotations + +import shutil +import time +from pathlib import Path + + +def remove_tree(path: Path, attempts: int = 5, pause_seconds: float = 0.05) -> None: + for attempt in range(1, attempts + 1): + try: + shutil.rmtree(path) + return + except FileNotFoundError: + return + except OSError: + if attempt == attempts: + raise + time.sleep(pause_seconds) diff --git a/src-legacy/Algorithm/BeamPacker.php b/src-legacy/Algorithm/BeamPacker.php index bb7db24..e16d4a5 100644 --- a/src-legacy/Algorithm/BeamPacker.php +++ b/src-legacy/Algorithm/BeamPacker.php @@ -42,6 +42,9 @@ public static function pack(Container $container,int $sequence,array $items,Pack $expansions=[];$exhausted=false; foreach($beam as [$state,$unplaced]){ if($nodeLimit!==null&&$nodes>=$nodeLimit){$exhausted=true;break;} + // A spent budget would stop this node before its first counted step, so it + // is not counted either: a counter never passes its limit. + if($deadline->effortStops()){$exhausted=true;$expansions[]=[$state,array_merge($unplaced,$batch)];continue;} $nodes++; $stats->searchNodesExpanded++; try{$children=self::placeBatch($state,$batch,$config,$constraints,$stats,$deadline,$scorer,$branchWidth);} @@ -62,18 +65,22 @@ public static function pack(Container $container,int $sequence,array $items,Pack if($candidateKey<$incumbentKey){$incumbent=[$state,array_merge($unplaced,$future)];$incumbentKey=$candidateKey;} } if($expansions===[])break; - $futureInfo=self::precomputeFuture($future,($nullsafeVariable1 = $container->maxPayload) ? $nullsafeVariable1->ticks : null); - $beam=array_slice(StableSorter::sortBy($expansions,static function (array $node) use ($futureInfo, $byVolume): array { - return self::nodeKeyPrecomputed($node,$futureInfo,$byVolume); - }),0,$width); + if(count($expansions)>1){ + $futureInfo=self::precomputeFuture($future,($nullsafeVariable1 = $container->maxPayload) ? $nullsafeVariable1->ticks : null); + $beam=array_slice(StableSorter::sortBy($expansions,static function (array $node) use ($futureInfo, $byVolume): array { + return self::nodeKeyPrecomputed($node,$futureInfo,$byVolume); + }),0,$width); + }else{ + $beam=$expansions; + } if($exhausted){ [$state,$unplaced]=$incumbent; - return new SingleContainerSolution($state,$unplaced,false,$deadline->expired()); + return new SingleContainerSolution($state,$unplaced,false,$deadline->stopped()); } } - $ordered=StableSorter::sortBy($beam,static function (array $node) use ($byVolume): array { + $ordered=count($beam)>1?StableSorter::sortBy($beam,static function (array $node) use ($byVolume): array { return self::nodeKey($node,$byVolume); - }); + }):$beam; $completed=$ordered[0]??null; [$state,$unplaced]=$completed!==null&&self::nodeKey($completed,$byVolume)<$incumbentKey?$completed:$incumbent; return new SingleContainerSolution($state,$unplaced); @@ -94,15 +101,18 @@ private static function packLegacy(Container $container,int $sequence,array $ite foreach($batches as $position=>$batch){ $expansions=[];$exhausted=false; foreach($beam as [$state,$unplaced]){ + if($deadline->effortStops()){$exhausted=true;$expansions[]=[$state,array_merge($unplaced,$batch)];continue;} $stats->searchNodesExpanded++; try{$children=self::placeBatch($state,$batch,$config,$constraints,$stats,$deadline,$scorer,$width);} catch(TimeLimitReached $exception){$exhausted=true;$children=[];} if($children!==[]){foreach($children as $child)$expansions[]=[$child,$unplaced];} else $expansions[]=[$state,array_merge($unplaced,$batch)]; } - $beam=array_slice(StableSorter::sortBy($expansions,static function (array $node) use ($byVolume): array { - return self::nodeKey($node,$byVolume); - }),0,$width); + $beam=count($expansions)>1 + ?array_slice(StableSorter::sortBy($expansions,static function (array $node) use ($byVolume): array { + return self::nodeKey($node,$byVolume); + }),0,$width) + :$expansions; if($exhausted){ [$state,$unplaced]=$beam[0]; $pending=GroupBatcher::flatten(array_slice($batches,$position+1)); diff --git a/src-legacy/Algorithm/CandidateFinder.php b/src-legacy/Algorithm/CandidateFinder.php index 89aef49..9d85977 100644 --- a/src-legacy/Algorithm/CandidateFinder.php +++ b/src-legacy/Algorithm/CandidateFinder.php @@ -67,10 +67,15 @@ public static function find(ContainerState $state,ItemInstance $item,PackingConf // support rule stays in regardless: `supportChecks` counts every time the chain // reaches it. $active=[]; + $inertChain=true; foreach($constraints as $constraint){ if(ConstraintActivity::isInert($constraint,$container,$item,$stackSensitive,$routeSensitive))continue; - $active[]=[$constraint,$constraint instanceof SupportConstraint,ConstraintActivity::isPositionIndependent($constraint)]; + $isSupport=$constraint instanceof SupportConstraint; + if(!$isSupport||$constraint->canReject($item))$inertChain=false; + $active[]=[$constraint,$isSupport,ConstraintActivity::isPositionIndependent($constraint)]; } + $inertSupportChecks=count($active); + $defaultScorer=get_class($scorer)===\Packvium\Extension\DefaultCandidateScorer::class; // A rule that reads no position answers the same at every (point, rotation) of this // search, so it is asked once, by the first position that reaches it. // Keyed by chain position; the stored value is the verdict's `allowed`. @@ -88,17 +93,25 @@ public static function find(ContainerState $state,ItemInstance $item,PackingConf $scan=array_slice($scan,0,$config->maxCandidatePoints); } if($container->axles!==null)$scan=self::appendUnseen($scan,self::axleBalancedPoints($state,$item,$forms,$limitX)); - }else{$scan=$points;usort($scan,static function (Point $a, Point $b): int { - return [$a->z,$a->y,$a->x]<=>[$b->z,$b->y,$b->x]; - });} + }else{ + $scan=$points; + if(count($scan)>1)usort($scan,static function (Point $a, Point $b): int { + return [$a->z,$a->y,$a->x]<=>[$b->z,$b->y,$b->x]; + }); + } $out=[];$best=null; $retained=$max!==null&&$max>1?new \SplPriorityQueue():null; $placementOffset=count($bounds)-count($placed); + // The point's check alone let a point's rotations run a counter past its limit, so + // each counted step is compared with its ceiling first; reaching one stops the scan + // through the same check, which records the stop. + [$attemptCeiling,$candidateCeiling]=$deadline->placementCeilings($stats); foreach($scan as $point){ $deadline->check(); $stats->candidatePointsConsidered++; $x1=$point->x;$y1=$point->y;$z1=$point->z; foreach($forms as [$rotation,$physical,$envelope,$dx,$dy,$dz,$shape]){ + if($stats->placementsAttempted>=$attemptCeiling)$deadline->check(); $stats->placementsAttempted++; $x2=$x1+$dx;$y2=$y1+$dy;$z2=$z1+$dz; if($x2>$limitX||$y2>$limitY||$z2>$limitZ)continue; @@ -131,19 +144,35 @@ public static function find(ContainerState $state,ItemInstance $item,PackingConf } } if($blocked)continue; - $ctx=new ConstraintContext($container,$placed,$item,$point,$rotation,$physical,$envelope,$stackSensitive,$routeSensitive); - foreach($active as $link=>[$constraint,$countsSupport,$positionIndependent]){ - if($countsSupport)$stats->supportChecks++; - $allowed=$positionIndependent - ?($fixedVerdicts[$link] = $fixedVerdicts[$link] ?? $constraint->evaluate($ctx)->allowed) - :$constraint->evaluate($ctx)->allowed; - if(!$allowed){$blocked=true;break;} + if($inertChain){ + $stats->supportChecks+=$inertSupportChecks; + }else{ + $ctx=new ConstraintContext($container,$placed,$item,$point,$rotation,$physical,$envelope,$stackSensitive,$routeSensitive); + foreach($active as $link=>[$constraint,$countsSupport,$positionIndependent]){ + if($countsSupport)$stats->supportChecks++; + $allowed=$positionIndependent + ?($fixedVerdicts[$link] = $fixedVerdicts[$link] ?? $constraint->evaluate($ctx)->allowed) + :$constraint->evaluate($ctx)->allowed; + if(!$allowed){$blocked=true;break;} + } + if($blocked)continue; } - if($blocked)continue; - $score=$scorer->score($state,$point,$envelope); - $position=($nullsafeVariable1 = $tentative) ? $nullsafeVariable1->position : null; - if($reserveCheck){ - $position = $position ?? new Point($x1+$clearance,$y1+$clearance,$z1+$clearance); + if(!$reserveCheck){ + if($stats->candidatesEvaluated>=$candidateCeiling)$deadline->check(); + $stats->candidatesEvaluated++; + if($defaultScorer){ + if($max===1&&$best!==null&&$z1>$best->score[0])continue; + if($retained!==null&&$retained->count()===$max&&$z1>$retained->top()->score[0])continue; + $score=[$z1,$z2>$bagZ?$z2:$bagZ,($limitX-$x2)*($limitY-$y2)+($limitZ-$z2),$y1,$x1]; + }else{ + $score=$scorer->score($state,$point,$envelope); + } + $position=($nullsafeVariable1 = $tentative) ? $nullsafeVariable1->position : null; + }else{ + $score=$defaultScorer + ?[$z1,$z2>$bagZ?$z2:$bagZ,($limitX-$x2)*($limitY-$y2)+($limitZ-$z2),$y1,$x1] + :$scorer->score($state,$point,$envelope); + $position=(($nullsafeVariable2 = $tentative) ? $nullsafeVariable2->position : null)??new Point($x1+$clearance,$y1+$clearance,$z1+$clearance); $reservePlacement=$tentative??new Placement( $item,$position,$rotation,$physical,$point,$envelope, ); @@ -160,8 +189,9 @@ public static function find(ContainerState $state,ItemInstance $item,PackingConf $projected=BigInt::add($state->usedVolume,Nesting::usedVolumeDelta($placed,$reservePlacement)); } if(BigInt::compare($projected,$usable)>0)continue; + if($stats->candidatesEvaluated>=$candidateCeiling)$deadline->check(); + $stats->candidatesEvaluated++; } - $stats->candidatesEvaluated++; // Selection follows every predicate and scorer call so extensions and // effort counters observe exactly the same enumeration as a full sort. if($max===1&&$best!==null&&$score>=$best->score)continue; diff --git a/src-legacy/Algorithm/Deadline.php b/src-legacy/Algorithm/Deadline.php index b65d494..a5e3629 100644 --- a/src-legacy/Algorithm/Deadline.php +++ b/src-legacy/Algorithm/Deadline.php @@ -9,6 +9,12 @@ * existing call site already polls `expired()`/`check()`, so attaching an effort * budget (via `withEffort`) needs no changes anywhere else; leaving it unattached * (the default) reproduces the exact prior time-only behaviour. + * + * A spent budget answers `true` to a poll, and that poll is a search refusing further work: + * the deadline records it (`effortStopped()`), so the effort flag says the budget stopped the + * search, not that a counter equals its limit afterwards. A search that used exactly + * its budget and finished was not stopped. Code asking afterwards whether a search was cut + * reads `stopped()`, which records nothing. */ final class Deadline { @@ -44,6 +50,9 @@ final class Deadline * @var bool */ private $usesRealClock; + /** Per-solve state: set once a poll found the effort budget spent and the search stopped there. + * @var bool */ + private $effortStopped=false; public function __construct( int $limitNs, @@ -97,7 +106,22 @@ public static function until(int $absoluteNs):self public function elapsedMs():int{return intdiv(($this->clock)()-$this->started,1000000);} public function remainingNs():int{return $this->limitNs-(($this->clock)()-$this->started);} + /** Whether the budget is spent; a query that records nothing. */ public function effortExceeded():bool{return $this->effortBudget!==null && $this->stats!==null && $this->effortBudget->exceeded($this->stats);} + /** + * Whether the effort budget stops the search at this poll, recording that it did. Only a + * caller about to do more work asks: asking is refusing it. + */ + public function effortStops():bool + { + if(!$this->effortExceeded())return false; + $this->effortStopped=true; + return true; + } + /** Whether a poll found the effort budget spent and the search stopped there. */ + public function effortStopped():bool{return $this->effortStopped;} + /** Whether the search was cut, by the effort budget at a poll or by the clock; records nothing. */ + public function stopped():bool{return $this->effortStopped || $this->remainingNs()<=0;} /** * How many more search nodes counted into `$counted` may expand before `effortExceeded()` * trips, while nothing else that budget counts moves: zero once it has tripped, and @@ -110,7 +134,33 @@ public function searchNodesLeft(SearchStats $counted):int if($this->effortBudget===null||$this->effortBudget->maxSearchNodes===null||$this->stats!==$counted)return PHP_INT_MAX; return $this->effortBudget->maxSearchNodes-$counted->searchNodesExpanded; } - public function expired():bool{return $this->effortExceeded() || $this->remainingNs()<=0;} + /** + * The `placementsAttempted` and `candidatesEvaluated` values of `$counted` at which the budget + * stops a search, `PHP_INT_MAX` for a counter no limit reads. A scan compares a counter with + * its ceiling before each step, so the counter never passes its limit. O(1). + * + * @return array{int,int} + */ + public function placementCeilings(SearchStats $counted):array + { + $budget=$this->effortBudget; + if($budget===null||$this->stats!==$counted)return [PHP_INT_MAX,PHP_INT_MAX]; + return [$budget->maxPlacementAttempts??PHP_INT_MAX,$budget->maxCandidatesEvaluated??PHP_INT_MAX]; + } + /** + * Whether the budget leaves room for `$candidates`, `$placements` and `$nodes` more counted + * steps on `$counted`, so a solver that counts a run of steps at once never passes a limit; + * a query that records nothing. O(1). + */ + public function effortRoom(SearchStats $counted,int $candidates,int $placements,int $nodes):bool + { + $budget=$this->effortBudget; + if($budget===null||$this->stats!==$counted)return true; + return ($budget->maxCandidatesEvaluated===null||$counted->candidatesEvaluated+$candidates<=$budget->maxCandidatesEvaluated) + && ($budget->maxPlacementAttempts===null||$counted->placementsAttempted+$placements<=$budget->maxPlacementAttempts) + && ($budget->maxSearchNodes===null||$counted->searchNodesExpanded+$nodes<=$budget->maxSearchNodes); + } + public function expired():bool{return $this->effortStops() || $this->remainingNs()<=0;} public function slice(int $parts):self { diff --git a/src-legacy/Algorithm/ExactSmallSolver.php b/src-legacy/Algorithm/ExactSmallSolver.php index 9b62337..62703a0 100644 --- a/src-legacy/Algorithm/ExactSmallSolver.php +++ b/src-legacy/Algorithm/ExactSmallSolver.php @@ -156,6 +156,6 @@ public function packOne(Container $container,int $sequence,array $items,PackingC // optimality. return new SingleContainerSolution($best,array_values(array_filter($items,static function ($i) use ($packed) { return !isset($packed[$i->id()]); - })),false,$deadline->expired()); + })),false,$deadline->stopped()); } } diff --git a/src-legacy/Algorithm/GridSolver.php b/src-legacy/Algorithm/GridSolver.php index ffd25e1..16b2b5b 100644 --- a/src-legacy/Algorithm/GridSolver.php +++ b/src-legacy/Algorithm/GridSolver.php @@ -152,7 +152,10 @@ public function packOne(Container $container,int $sequence,array $items,PackingC $sameDeclaredType=count(array_unique(array_map(static function ($i) { return $i->item->id; },array_slice($items,0,$total))))===1; - if($total>0&&!$config->requirePlacementCoordinates&&$nestingTicks===0&&$sameDeclaredType){ + // The fast path counts the whole lattice in one step, so it is taken only when the + // budget covers it; otherwise the per-item loop below places the same lattice one + // counted item at a time and stops on the limit, never past it. + if($total>0&&!$config->requirePlacementCoordinates&&$nestingTicks===0&&$sameDeclaredType&&$deadline->effortRoom($stats,$total,$total,1)){ // Quantity-compression fast path: the lattice parameters above // already fully determine every coordinate in O(r); skip the O(n) loop // that would otherwise build one Placement per instance purely to fill diff --git a/src-legacy/Algorithm/HomogeneousBlockSolver.php b/src-legacy/Algorithm/HomogeneousBlockSolver.php index 46b1280..8c881e3 100644 --- a/src-legacy/Algorithm/HomogeneousBlockSolver.php +++ b/src-legacy/Algorithm/HomogeneousBlockSolver.php @@ -47,10 +47,16 @@ final class HomogeneousBlockSolver implements SingleContainerSolver public const LOOKAHEAD_WIDTH=4; /** The lookahead's lattice-node budget per mode, as a multiple of the node limit. */ public const LOOKAHEAD_NODE_FACTOR=64; + /** + * Lattice nodes one mode may enumerate under every profile but `quality` -- the bound + * `quality` gives the same solver through `containerPlanNodeLimit`. + */ + public const START_NODE_LIMIT=100000; /** - * `fast` and `balanced` leave `containerPlanNodeLimit` at one, which would stop the - * lattice enumeration before its first block; their start carries its own bound. + * Every profile but `quality` leaves `containerPlanNodeLimit` at one, which would stop + * the lattice enumeration before its first block, so under them the solver carries its + * own bound (`nodeLimit()`); the start `fast` and `balanced` add passes it. * * @param list $constraints * @param list $modes @@ -69,19 +75,27 @@ public function packOne(Container $container,int $sequence,array $items,PackingC { if(!$this->supports($container,$items,$config)) return (new ExtremePointSolver($this->constraints))->packOne($container,$sequence,$items,$config,$stats,$deadline); - $nodeLimit=$this->nodeLimit??$config->containerPlanNodeLimit; - $best=null;$reached=false; + $nodeLimit=$this->nodeLimit($config); + $best=null; foreach($this->modes as $mode){ - if($deadline->expired()){$reached=true;break;} + if($deadline->expired())break; $solution=$this->packMode($container,$sequence,$items,$config,$stats,$deadline,$mode,$nodeLimit); if($config->profile===SolverProfile::Quality&&!$deadline->expired()) $solution=$this->lookahead($container,$sequence,$items,$config,$stats,$deadline,$mode,$nodeLimit*self::LOOKAHEAD_NODE_FACTOR,$solution); - $reached=$reached||$solution->timeLimitReached; $byVolume=$config->objective==='maximum_volume'; if($best===null||$this->solutionKey($solution,$byVolume)<$this->solutionKey($best,$byVolume))$best=$solution; } - if($best===null)return new SingleContainerSolution(new ContainerState($container,$sequence),$items,false,$reached); - return new SingleContainerSolution($best->state,$best->unpacked,false,$reached); + // Only the deadline or the effort budget marks the answer as stopped by a limit: the + // node limit is a search bound, and the answer it leaves is the search's own. + if($best===null)return new SingleContainerSolution(new ContainerState($container,$sequence),$items,false,$deadline->stopped()); + return new SingleContainerSolution($best->state,$best->unpacked,false,$deadline->stopped()); + } + + /** Lattice nodes one mode may enumerate: the start's own bound, `quality`'s node limit, or START_NODE_LIMIT. */ + private function nodeLimit(PackingConfig $config):int + { + if($this->nodeLimit!==null)return $this->nodeLimit; + return $config->profile===SolverProfile::Quality?$config->containerPlanNodeLimit:self::START_NODE_LIMIT; } public function supports(Container $container,array $items,PackingConfig $config):bool @@ -289,7 +303,7 @@ private static function leadingBlocks(array $spaces,array $capacities,array $rot if($nodes+$groupNodes>$stopAt||(($nodes+$groupNodes-1)>>self::CLOCK_SHIFT)!==(($nodes-1)>>self::CLOCK_SHIFT)){ $expanded=self::expandable($nodes,$groupNodes,$stopAt,$deadline); $nodes+=$expanded;$stats->searchNodesExpanded+=$expanded; - if($expanded<$groupNodes){$reached=true;return [];} + if($expanded<$groupNodes){$deadline->effortStops();$reached=true;return [];} }else{$nodes+=$groupNodes;$stats->searchNodesExpanded+=$groupNodes;} if($envelopeHeight>$spaceHeight)continue; $maximumZ=intdiv($spaceHeight,$envelopeHeight); @@ -341,7 +355,7 @@ private function packMode(Container $container,int $sequence,array $items,Packin $remaining=self::byType($items); $nodes=0;$reached=false; $solution=$this->complete($container,new ContainerState($container,$sequence),[new Space(new Point(0,0,0),$container->innerDimensions)],$remaining,self::rotationsOf($remaining,$config),$config,$stats,$deadline,$mode,$nodeLimit,$nodes,$reached); - return new SingleContainerSolution($solution->state,$solution->unpacked,false,$reached||$deadline->expired()); + return new SingleContainerSolution($solution->state,$solution->unpacked,false,$reached||$deadline->stopped()); } /** @@ -392,7 +406,8 @@ private function complete(Container $container,ContainerState $state,array $spac while($spaces!==[]&&array_sum(array_map('count',$remaining))>0){ $leading=self::round($container,$state,$spaces,$remaining,$rotations,$mode,1,$nodeLimit,$nodes,$stats,$deadline,$reached); if($leading===[]||$reached)break; - $spaces=self::commit($state,$spaces,$remaining,$leading[0],$config,$stats); + $spaces=self::commit($state,$spaces,$remaining,$leading[0],$config,$stats,$deadline); + if($spaces===null){$reached=true;break;} } $unpacked=[];foreach($remaining as $available)foreach($available as $instance)$unpacked[]=$instance; return new SingleContainerSolution($state,$unpacked,false,$reached); @@ -427,7 +442,8 @@ private function lookahead(Container $container,int $sequence,array $items,Packi $chosen=$known===null?null:[$this->solutionKey($known,$byVolume),$known,$leading[0]]; foreach($known===null?$leading:array_slice($leading,1) as $block){ $trialState=$state->copy();$trialRemaining=$remaining; - $trialSpaces=self::commit($trialState,$spaces,$trialRemaining,$block,$config,$stats); + $trialSpaces=self::commit($trialState,$spaces,$trialRemaining,$block,$config,$stats,$deadline); + if($trialSpaces===null){$reached=true;break;} $trial=$this->complete($container,$trialState,$trialSpaces,$trialRemaining,$rotations,$config,$stats,$deadline,$mode,$nodeLimit,$nodes,$reached); if($reached)break; $trialKey=$this->solutionKey($trial,$byVolume); @@ -436,34 +452,58 @@ private function lookahead(Container $container,int $sequence,array $items,Packi if($reached||$chosen===null)break; [$chosenKey,$known,$block]=$chosen; if($chosenKey<$bestKey){$best=$known;$bestKey=$chosenKey;} - $spaces=self::commit($state,$spaces,$remaining,$block,$config,$stats); + $spaces=self::commit($state,$spaces,$remaining,$block,$config,$stats,$deadline); + if($spaces===null){$reached=true;break;} } - return new SingleContainerSolution($best->state,$best->unpacked,false,$best->timeLimitReached||$deadline->expired()); + return new SingleContainerSolution($best->state,$best->unpacked,false,$best->timeLimitReached||$deadline->stopped()); } /** * Place every member of the block, bottom layer first, and return the spaces left. * + * Each member is one counted candidate and placement. When the effort budget has room for + * fewer than the whole block, the members it has room for are placed, the budget records the + * stop, and `null` says the search ends here: a counter never passes its limit. + * * @param list $spaces * @param array> $remaining * @param array $best - * @return list + * @return list|null */ - private static function commit(ContainerState $state,array $spaces,array &$remaining,array $best,PackingConfig $config,SearchStats $stats):array + private static function commit(ContainerState $state,array $spaces,array &$remaining,array $best,PackingConfig $config,SearchStats $stats,Deadline $deadline):?array { - $chosen=array_slice($remaining[$best['itemId']],0,$best['count']); - $remaining[$best['itemId']]=array_slice($remaining[$best['itemId']],$best['count']); - $clearance=$config->clearance->ticks;$index=0; - for($z=0;$z<$best['nz'];$z++)for($y=0;$y<$best['ny'];$y++)for($x=0;$x<$best['nx'];$x++){ + [$attemptCeiling,$candidateCeiling]=$deadline->placementCeilings($stats); + $members=max(0,min($best['count'],$attemptCeiling-$stats->placementsAttempted,$candidateCeiling-$stats->candidatesEvaluated)); + self::placeMembers($state,$remaining,$best,$members,$config,$stats); + if($members<$best['count']){ + $deadline->effortStops(); + return null; + } + $blockDimensions=new Dimensions(new Length($best['nx']*$best['envelope']->length->ticks),new Length($best['ny']*$best['envelope']->width->ticks),new Length($best['nz']*$best['envelope']->height->ticks)); + return MaximalSpaceSolver::subtractAll($spaces,[new AxisAlignedBox($best['space']->origin,$blockDimensions)],$stats); + } + + /** + * The block's first `$members` members, x fastest, then y, then z: a layer is filled before + * the one above it, so a member above the bottom layer rests on the member beneath it. + * + * @param array> $remaining + * @param array $best + */ + private static function placeMembers(ContainerState $state,array &$remaining,array $best,int $members,PackingConfig $config,SearchStats $stats):void + { + $chosen=array_slice($remaining[$best['itemId']],0,$members); + $remaining[$best['itemId']]=array_slice($remaining[$best['itemId']],$members); + $clearance=$config->clearance->ticks;$perLayer=$best['nx']*$best['ny']; + for($index=0;$index<$members;$index++){ + $x=$index%$best['nx'];$y=intdiv($index,$best['nx'])%$best['ny'];$z=intdiv($index,$perLayer); $point=new 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=new Point($point->x+$clearance,$point->y+$clearance,$point->z+$clearance); // Above its bottom layer a member rests on an identical footprint; the // bottom layer rests on whatever is there. $support=$z===0?self::memberSupport($state,$chosen[$index],$point,$best['envelope']):1.0; - $state->addDirect(new Placement($chosen[$index++],$position,$best['rotation'],$best['physical'],$point,$best['envelope'],$support)); + $state->addDirect(new Placement($chosen[$index],$position,$best['rotation'],$best['physical'],$point,$best['envelope'],$support)); $stats->candidatesEvaluated++;$stats->placementsAttempted++; } - $blockDimensions=new Dimensions(new Length($best['nx']*$best['envelope']->length->ticks),new Length($best['ny']*$best['envelope']->width->ticks),new Length($best['nz']*$best['envelope']->height->ticks)); - return MaximalSpaceSolver::subtractAll($spaces,[new AxisAlignedBox($best['space']->origin,$blockDimensions)],$stats); } } diff --git a/src-legacy/Algorithm/MaximalSpaceSolver.php b/src-legacy/Algorithm/MaximalSpaceSolver.php index de572f5..4b72660 100644 --- a/src-legacy/Algorithm/MaximalSpaceSolver.php +++ b/src-legacy/Algorithm/MaximalSpaceSolver.php @@ -58,9 +58,12 @@ public function packOne(Container $container,int $sequence,array $items,PackingC $unplaced=[]; $batches=GroupBatcher::batches($items); foreach($batches as $position=>$batch){ - $snapshot=$state->copy();$saved=$spaces;$placed=true; + $snapshot=count($batch)>1?$state->copy():$state;$saved=$spaces;$placed=true; try{ foreach($batch as $item){ + // An item past a full container reaches no check of its own, so a spent + // budget is checked before its node is counted. + if($deadline->effortStops())throw new TimeLimitReached('effort budget spent'); $stats->searchNodesExpanded++; $chosen=self::choose($state,$spaces,$item,$config,$constraints,$stats,$deadline,$scorer); if($chosen===null){$placed=false;break;} @@ -205,19 +208,21 @@ static function (Space $space): array { } /** - * Whether another extent contains `$extents[$index]`, for extents ordered by origin z. + * Whether another extent contains `$extents[$index]`, for extents ordered by + * `(z1, y1, x1, -volume)`. * - * A container starts at or below what it contains, so the scan stops at the first - * extent that starts higher: O(prefix) instead of O(s), with no index to build. + * A containing extent starts at or before `$extents[$index]` on every axis and has strictly + * larger volume when origins match, so it always appears at a position `< $index` with + * `$extent[2] <= $z1` already guaranteed. * * @param list $extents */ private static function isContained(array $extents,int $index):bool { - [$x1,$y1,$z1,$x2,$y2,$z2]=$extents[$index]; - foreach($extents as $other=>$extent){ - if($extent[2]>$z1)return false; - if($other!==$index&&$extent[0]<=$x1&&$extent[1]<=$y1&&$x2<=$extent[3]&&$y2<=$extent[4]&&$z2<=$extent[5])return true; + [$x1,$y1,,$x2,$y2,$z2]=$extents[$index]; + for($other=0;$other<$index;++$other){ + $extent=$extents[$other]; + if($extent[3]>=$x2&&$extent[4]>=$y2&&$extent[5]>=$z2&&$extent[0]<=$x1&&$extent[1]<=$y1)return true; } return false; } diff --git a/src-legacy/Algorithm/ObjectiveBoundScope.php b/src-legacy/Algorithm/ObjectiveBoundScope.php new file mode 100644 index 0000000..4e5faa1 --- /dev/null +++ b/src-legacy/Algorithm/ObjectiveBoundScope.php @@ -0,0 +1,33 @@ + [0, 1, 2, 3, 4], + 'lowest_cost' => [0, 2, 1, 3, 4], + ]; + + /** + * O(containers) time, O(1) space; no instances or arithmetic are needed to refuse. + * @param list $containers + * @return list|null + */ + public static function order(array $containers, string $objective): ?array + { + $order = self::OBJECTIVE_ORDERS[$objective] ?? null; + if ($order === null) { + return null; + } + foreach ($containers as $container) { + if ($container->madeToMeasure !== null) { + return null; + } + } + return $order; + } +} diff --git a/src-legacy/Algorithm/ObjectiveBounds.php b/src-legacy/Algorithm/ObjectiveBounds.php index 8d2ed85..2f7a863 100644 --- a/src-legacy/Algorithm/ObjectiveBounds.php +++ b/src-legacy/Algorithm/ObjectiveBounds.php @@ -27,17 +27,22 @@ * to a float. * * Complexity. `O(n log n + c log c)` for `n` instances and `c` container types, the same as - * the Python port: one sort of the volumes, one of the weights, one of the per-unit costs. - * The volume sort compares decimal strings through `BigInt::compare` rather than PHP's - * default string order, where "9" would sort after "10". + * the Python port: one sort of the volumes, one of the weights, one of the container types + * by cost. The volume sort compares decimal strings through `BigInt::compare` rather than + * PHP's default string order, where "9" would sort after "10". + * + * Which instances, and when. The bound is a property of the request: `Packer` computes it + * once per pack through `forRequest`, over every instance the request declares -- fixed + * placements included -- before the first solver runs. Refusal is the + * one predicate of docs/OPTIMALITY-CERTIFICATES.md, the same in every engine. * * Namespace. `Packvium\Algorithm` is one of the three namespaces `spec/api/php.json` * records as internal, and that is deliberate rather than incidental. A lower bound is - * solver machinery: `SolverOrchestrator` -- its only production caller, and already in this - * namespace -- records it into `SearchStats`, and nothing serializes it. Filed first under - * the public `Packvium\Objective`, it added two classes to a frozen public API for no - * caller outside this namespace, which is a contract this project would then owe forever. - * Python and JavaScript reached the same place by construction: neither exports its bound. + * solver machinery, and nothing serializes it until the 1.8.0 result fields are emitted + *. Filed first under the public `Packvium\Objective`, it added two classes to a + * frozen public API for no caller outside the engine, which is a contract this project would + * then owe forever. Python and JavaScript reached the same place by construction: neither + * exports its bound. */ final class ObjectiveBounds { @@ -62,6 +67,85 @@ final class ObjectiveBounds /** Largest result integer every binding can carry without changing its value. */ public const MAX_BOUND_VALUE = '9007199254740991'; + /** + * The objectives a bound is stated for, and where each reads the five bounds: the + * position in `compute`'s default-order vector of each of the objective's score keys. + * `lowest_cost` scores cost ahead of the container count, and the document shows the + * permuted vector is still a bound. Every other objective gets none. + */ + public const OBJECTIVE_ORDERS = ObjectiveBoundScope::OBJECTIVE_ORDERS; + + /** + * The request's bound in `$objective`'s own score order, or null where none is stated. + * + * Null for an objective with no bound, for a request with a `made_to_measure` container + * (D13: the unused-volume key would read the machine maximum while the score + * reads the fabricated box), and for a request past a declared ceiling. The last + * withholds the bound and nothing else -- the pack is answered as it was before any + * bound existed, rather than failing with `BoundOverflowException` (finding F5). + * + * @param list $instances every instance of the request, fixed ones included + * @param list $containers + * @return list|null + */ + public static function forRequest(array $instances, array $containers, string $objective): ?array + { + $order = ObjectiveBoundScope::order($containers, $objective); + if ($order === null) { + return null; + } + try { + $vector = self::compute($instances, $containers); + } catch (BoundOverflowException $refused) { + return null; + } + return array_map(static function (int $position) use ($vector): int { + return $vector[$position]; + }, $order); + } + + /** + * The `optimality` fields one result states against its request's bound + * (OPTIMALITY-GAP.md § 1.1), or null where § 1.5 withholds them. + * + * `$bound` is in the result's own score order. The prefix runs through the first position + * the score misses, or is the whole bound when nothing is missed -- the attained case, + * which carries no gap at all rather than a zero one. A score below its bound means the + * bound is wrong, and an engine must not publish a claim it has just refuted, so nothing + * is stated. At most five comparisons, `O(1)`. + * + * @param list $score + * @param list $bound + * @return array|null + */ + public static function statement(array $score, array $bound): ?array + { + foreach ($bound as $index => $least) { + $achieved = $score[$index]; + if ($achieved < $least) { + return null; + } + if ($achieved > $least) { + $difference = BigInt::subtract((string)$achieved, (string)$least); + if (BigInt::compare($difference, self::MAX_BOUND_VALUE) > 0) { + return null; + } + $absolute = (int)$difference; + $fields = [ + 'lower_bound' => array_slice($bound, 0, $index + 1), + 'gap_key' => $index, + 'absolute_gap' => $absolute, + ]; + if ($least > 0) { + // Unreduced, so the pair reads as (score - bound, bound) by eye. + $fields['relative_gap'] = ['numerator' => $absolute, 'denominator' => $least]; + } + return $fields; + } + } + return ['lower_bound' => $bound]; + } + /** * Refuse a sum past the declared ceiling instead of carrying it further. * @@ -81,6 +165,12 @@ private static function guard(string $total, string $quantity): string return $total; } + /** A capacity total is guarded only when it is a number: an unbounded one is not. */ + private static function guardFinite(?string $total, string $quantity): ?string + { + return $total === null ? null : self::guard($total, $quantity); + } + /** Refuse a result before PHP or JavaScript can round it on a binding boundary. */ private static function guardOutput(string $value, string $quantity): int { @@ -96,7 +186,7 @@ private static function guardOutput(string $value, string $quantity): int /** * Every bound for one request, in the default objective's key order. * - * @param list $instances quantity-expanded, exactly as the solver sees them + * @param list $instances quantity-expanded, free and fixed alike * @param list $containers the types the request permits * @return array{int,int,int,int,int} */ @@ -149,43 +239,10 @@ public static function fromNormalised(array $instances, array $containers): arra $weights[] = $instance['weight']; $shrinks = $shrinks || $instance['shrinks']; } - // Native comparison while both sides fit one, `BigInt` only when they do not. The - // sort is the dominant cost at scale -- ten thousand instances is about 133,000 - // comparisons -- and PHP's own string order is not an option here, where "9" would - // sort after "10". - usort($volumes, static function (string $a, string $b): int { - if (strlen($a) <= 18 && strlen($b) <= 18) { - return (int)$a <=> (int)$b; - } - return BigInt::compare($a, $b); - }); + self::sortVolumes($volumes); sort($weights); $count = count($instances); - // The a-priori check, once, on the way in. Every later product is bounded by these - // totals times `PPM`, so guarding them here is what makes the rest safe by derivation - // rather than by hoping each step stays small. - self::guard(self::sumBig($volumes), 'instance volume'); - self::guard(self::sumBig(array_map(\Closure::fromCallable('strval'), $weights)), 'instance weight'); - self::guard( - self::sumBig(array_map( - static function (array $c): string { - return BigInt::multiply($c['usable'], (string)($c['quantity'] ?? 1)); - }, - $containers, - )), - 'container capacity', - ); - self::guard( - self::sumBig(array_map( - static function (array $c): string { - return BigInt::multiply((string)$c['cost_minor'], (string)($c['quantity'] ?? 1)); - }, - $containers, - )), - 'opening cost', - ); - $usable = array_map(static function (array $c): string { return $c['usable']; }, $containers); @@ -211,8 +268,21 @@ static function (array $c): string { return $c['cost_minor']; }, $containers); - $unpacked = self::unpackedBound($volumes, $weights, $shrinks, $count, $usable, - $payloads, $slots, $quantities); + // The one refusal predicate, on the way in: the two instance sums and every + // *finite* capacity total. Every later product is bounded by these times `PPM`, so + // guarding them here makes the rest safe by derivation. Nothing the formulas never + // form is guarded -- no single type's capacity or cost, no total an unlimited type + // makes unbounded -- and the shapes are never read, so the same request is refused + // in every engine. The five results are held to the portable ceiling on the way out. + self::guard(self::sumBig($volumes), 'instance volume'); + self::guard(self::sumBig(array_map(\Closure::fromCallable('strval'), $weights)), 'instance weight'); + $capacities = [ + 'volume' => self::guardFinite(self::volumeCapacity($usable, $quantities), 'volume capacity'), + 'payload' => self::guardFinite(self::nativeCapacity($payloads, $quantities), 'payload capacity'), + 'slots' => self::guardFinite(self::nativeCapacity($slots, $quantities), 'slot capacity'), + ]; + + $unpacked = self::unpackedBound($volumes, $weights, $shrinks, $count, $capacities); $placed = $count - $unpacked; $opened = self::containerBound($volumes, $weights, $shrinks, $placed, $containers, $usable, $payloads, $slots); @@ -254,22 +324,17 @@ private static function occupiesLessThanItsBox(Item $item): bool * * @param list $volumes ascending * @param list $weights ascending - * @param list $usable - * @param list $payloads - * @param list $slots - * @param list $quantities + * @param array{volume:?string,payload:?string,slots:?string} $capacities null when unbounded */ private static function unpackedBound(array $volumes, array $weights, bool $shrinks, - int $count, array $usable, array $payloads, array $slots, array $quantities): int + int $count, array $capacities): int { $placeable = $count; if (!$shrinks) { - $volumeCapacity = self::volumeCapacity($usable, $quantities); - $placeable = min($placeable, self::fitBig($volumes, $volumeCapacity)); + $placeable = min($placeable, self::fitBig($volumes, $capacities['volume'])); } - $payloadCapacity = self::nativeCapacity($payloads, $quantities); - $placeable = min($placeable, self::fitNative($weights, $payloadCapacity)); - $slotCapacity = self::nativeCapacity($slots, $quantities); + $placeable = min($placeable, self::fitNative($weights, $capacities['payload'])); + $slotCapacity = $capacities['slots']; if ($slotCapacity !== null) { $placeable = min($placeable, BigInt::compare($slotCapacity, (string)$count) >= 0 ? $count : (int)$slotCapacity); @@ -326,6 +391,12 @@ private static function containerBound(array $volumes, array $weights, bool $shr * `L2`: at least `L1` containers open, each costing at least the cheapest the inventory * still holds. * + * The types are sorted by cost once and taken greedily, `min(quantity, still needed)` of + * each: the sum of the `L1` cheapest per-unit entries without building that multiset, + * `O(c log c)` rather than the `O(c * L1 log(c * L1))` of materialising and sorting up to + * `L1` copies of every type (finding F6). Ties in cost cannot change the sum, so the + * order among them does not matter. + * * @param list $costs * @param list $quantities */ @@ -334,17 +405,21 @@ private static function costBound(array $costs, array $quantities, int $opened): if ($opened <= 0) { return 0; } - $available = []; - foreach ($costs as $index => $cost) { - $quantity = $quantities[$index]; - $repeat = $quantity === null ? $opened : min($quantity, $opened); - for ($taken = 0; $taken < $repeat; $taken++) { - $available[] = $cost; + $cheapestFirst = array_keys($costs); + usort($cheapestFirst, static function (int $a, int $b) use ($costs): int { + return $costs[$a] <=> $costs[$b]; + }); + $needed = $opened; + $selected = '0'; + foreach ($cheapestFirst as $index) { + if ($needed <= 0) { + break; } + $quantity = $quantities[$index]; + $taken = $quantity === null ? $needed : min($quantity, $needed); + $selected = BigInt::add($selected, BigInt::multiply((string)$costs[$index], (string)$taken)); + $needed -= $taken; } - sort($available); - $selected = self::sumBig(array_map(\Closure::fromCallable('strval'), array_slice($available, 0, $opened))); - self::guard($selected, 'opening cost'); return self::guardOutput($selected, 'opening cost'); } @@ -479,14 +554,47 @@ private static function nativeCapacity(array $values, array $quantities): ?strin continue; } $total = BigInt::add($total, BigInt::multiply((string)$value, (string)$quantity)); - self::guard($total, 'container capacity'); } return $total; } + /** + * Ascending decimal strings, in numeric order. + * + * PHP's own string order is not an option, where "9" would sort after "10". When every + * volume fits a native integer -- a 10 mm cube is 4.1e15 cubic ticks -- they are sorted + * as integers in C and written back as the same decimal strings. Measured on 51,200 + * volumes: 17 ms, against 78 ms for the comparator callback and 182 ms for + * `SORT_NUMERIC`, which re-parses both strings on every comparison. Only a volume wider + * than a native integer needs the `BigInt` comparator. + * + * @param list $volumes + */ + private static function sortVolumes(array &$volumes): void + { + $widest = $volumes === [] ? 0 : max(array_map(\Closure::fromCallable('strlen'), $volumes)); + if ($widest <= 18) { + $native = array_map(\Closure::fromCallable('intval'), $volumes); + sort($native); + $volumes = array_map(\Closure::fromCallable('strval'), $native); + return; + } + usort($volumes, static function (string $a, string $b): int { + if (strlen($a) <= 18 && strlen($b) <= 18) { + return (int)$a <=> (int)$b; + } + return BigInt::compare($a, $b); + }); + } + /** * The largest `n` such that the `n` smallest values sum to at most the capacity. * + * The running total is `$flushed + $native`: native additions until one more would + * overflow, then one `BigInt` addition that folds the chunk in. The capacity test stays + * native too, against the room the capacity leaves above `$flushed`, so a long run of + * small values costs native work rather than a string addition and comparison each. + * * @param list $ascending */ private static function fitBig(array $ascending, ?string $capacity): int @@ -494,30 +602,42 @@ private static function fitBig(array $ascending, ?string $capacity): int if ($capacity === null) { return count($ascending); } - $used = '0'; + $flushed = '0'; + $native = 0; + $room = self::nativeRoom($capacity, $flushed); foreach ($ascending as $taken => $value) { - $used = BigInt::add($used, $value); - if (BigInt::compare($used, $capacity) > 0) { + if (strlen($value) > 18 || $native > PHP_INT_MAX - (int)$value) { + $flushed = BigInt::add(BigInt::add($flushed, (string)$native), $value); + $native = 0; + if (BigInt::compare($flushed, $capacity) > 0) { + return $taken; + } + $room = self::nativeRoom($capacity, $flushed); + continue; + } + $native += (int)$value; + if ($room !== null && $native > $room) { return $taken; } } return count($ascending); } + /** + * `capacity - flushed` as a native integer, or null when it exceeds every native total. + * + * The caller guarantees `flushed <= capacity`. + */ + private static function nativeRoom(string $capacity, string $flushed): ?int + { + $room = BigInt::subtract($capacity, $flushed); + return BigInt::compare($room, (string)PHP_INT_MAX) <= 0 ? (int)$room : null; + } + /** @param list $ascending */ private static function fitNative(array $ascending, ?string $capacity): int { - if ($capacity === null) { - return count($ascending); - } - $used = '0'; - foreach ($ascending as $taken => $value) { - $used = BigInt::add($used, (string)$value); - if (BigInt::compare($used, $capacity) > 0) { - return $taken; - } - } - return count($ascending); + return self::fitBig(array_map(\Closure::fromCallable('strval'), $ascending), $capacity); } /** @@ -546,39 +666,34 @@ private static function finiteMax(array $values): ?int * The same trade `HullShape::axisFitsNatively` makes, and for the same reason: string * arithmetic is exact and slow, so it should be reached for when it is needed rather * than always. A single volume fits a 64-bit integer comfortably -- a 10 mm cube is - * 4.1e15 cubic ticks -- and only the running total can leave that range, at around two - * thousand such items. So the loop adds natively until one more addend would overflow, - * and only then switches to `BigInt` for the remainder. + * 4.1e15 cubic ticks -- and only the running total leaves that range, at around two + * thousand such items. So values are added natively in chunks, and a chunk is folded + * into the `BigInt` total only when one more addend would overflow it: one string + * addition per ~2^63 of total rather than one per value past the first overflow, which + * is what the bound computed on every pack pays at scale. * - * Measured on ten thousand instances: 459 ms before, and the exactness is unchanged -- - * the switch happens on the value that would have wrapped, not after it. + * The exactness is unchanged: a chunk is folded before the value that would wrap it is + * added, and a value too wide for `(int)` goes straight to `BigInt`. * * @param list $values */ private static function sumBig(array $values): string { + $total = '0'; $native = 0; - $index = 0; - $count = count($values); - for (; $index < $count; $index++) { - $value = $values[$index]; - // A value too wide for a native integer, or a total that would pass one, ends the - // fast path. `is_numeric` guards nothing here -- these are engine-produced decimal - // strings -- but the width check is what keeps `(int)` from silently truncating. + foreach ($values as $value) { if (strlen($value) > 18) { - break; + $total = BigInt::add($total, $value); + continue; } $addend = (int)$value; if ($native > PHP_INT_MAX - $addend) { - break; + $total = BigInt::add($total, (string)$native); + $native = 0; } $native += $addend; } - $total = (string)$native; - for (; $index < $count; $index++) { - $total = BigInt::add($total, $values[$index]); - } - return $total; + return BigInt::add($total, (string)$native); } /** @param list $values */ diff --git a/src-legacy/Algorithm/SearchStats.php b/src-legacy/Algorithm/SearchStats.php index b1c99ef..b954a10 100644 --- a/src-legacy/Algorithm/SearchStats.php +++ b/src-legacy/Algorithm/SearchStats.php @@ -32,21 +32,6 @@ final class SearchStats * @var int */ public $searchNodesExpanded=0; - /** - * The request-level lower bound on the objective vector, computed once per start at the - * root of an `exact_small` or global-beam solve. - * - * Deliberately absent from `metrics()`: `algorithm.metrics` is serialised into every - * result, so a key there changes the bytes of every committed golden and has to land in - * all four engines at once. Reporting a gap to a caller is also a new public result - * field, which this project reserves and rejects before a contract freeze rather than - * adding mid-line. `null` when no bound was computed -- a non-default objective keys its - * score vector differently, so a bound compared against it would compare different - * quantities. - * - * @var array{int,int,int,int,int}|null - */ - public $objectiveLowerBound; public function metrics():SolverMetrics { diff --git a/src-legacy/Algorithm/SolverOrchestrator.php b/src-legacy/Algorithm/SolverOrchestrator.php index 2502872..97fe7c9 100644 --- a/src-legacy/Algorithm/SolverOrchestrator.php +++ b/src-legacy/Algorithm/SolverOrchestrator.php @@ -137,22 +137,11 @@ static function (array $start): StartRecord { foreach($starts as $position=>[$solver,$orderName,$order]){ if($deadline->expired())break; $stats=new SearchStats(); - $budget=$deadline->slice(count($starts)-$position); - if($effortBudget!==null)$budget=$budget->withEffort($effortBudget,$stats); + $budget=self::startBudget($deadline,$effortBudget,$stats,count($starts)-$position); $startConfig=substr_compare($orderName, ':greedy', -strlen(':greedy')) === 0?self::greedyConfig($config):$config; [$packed,$unpacked,$exhaustive,$reached,$dominantLattice]=$this->acrossContainers($solver,$order,$containers,$startConfig,$stats,$budget,$preloaded); - $effortReached=$budget->effortExceeded(); - $wallReached=$reached&&$budget->remainingNs()<=0; - if($effortReached){ - $unpacked=array_map( - static function (UnpackedItem $item): UnpackedItem { - return $item->reason==='time_limit' - ? new UnpackedItem($item->instance,'effort_limit',$item->details) - : $item; - }, - $unpacked, - ); - } + [$wallReached,$effortReached]=self::limitsReached($reached,$budget->effortStopped(),$budget->remainingNs()); + $unpacked=self::effortLimited($unpacked,$effortReached); $id=$solver->name().':'.$orderName; $results[]=new RawSolution($id,$packed,$unpacked,$stats,$wallReached,$effortReached,$exhaustive); $records[$position]=new StartRecord($id,true,!$reached,$reached); @@ -228,21 +217,10 @@ static function (array $start): StartRecord { [$solver0,$orderName0,$order0]=$starts[0]; $stats0=new SearchStats(); - $budget0=$deadline->slice(count($starts)); - if($effortBudget!==null)$budget0=$budget0->withEffort($effortBudget,$stats0); + $budget0=self::startBudget($deadline,$effortBudget,$stats0,count($starts)); [$packed0,$unpacked0,$exhaustive0,$reached0,$dominantLattice0]=$this->acrossContainers($solver0,$order0,$containers,$config,$stats0,$budget0,$preloaded); - $effortReached0=$budget0->effortExceeded(); - $wallReached0=$reached0&&$budget0->remainingNs()<=0; - if($effortReached0){ - $unpacked0=array_map( - static function (UnpackedItem $item): UnpackedItem { - return $item->reason==='time_limit' - ? new UnpackedItem($item->instance,'effort_limit',$item->details) - : $item; - }, - $unpacked0, - ); - } + [$wallReached0,$effortReached0]=self::limitsReached($reached0,$budget0->effortStopped(),$budget0->remainingNs()); + $unpacked0=self::effortLimited($unpacked0,$effortReached0); $id0=$solver0->name().':'.$orderName0; $results[0]=new RawSolution($id0,$packed0,$unpacked0,$stats0,$wallReached0,$effortReached0,$exhaustive0); $records[0]=new StartRecord($id0,true,!$reached0,$reached0); @@ -279,7 +257,7 @@ static function (UnpackedItem $item): UnpackedItem { // process's own fork running the exact same code, never from // external/attacker-controlled input, so the default (allow // every class) `unserialize()` on the parent's side is safe. - fwrite($pair[1],serialize([$packed,$unpacked,$exhaustive,$reached,$stats])); + fwrite($pair[1],serialize([$packed,$unpacked,$exhaustive,$reached,$stats,$budget->effortStopped()])); fclose($pair[1]); exit(0); } @@ -292,19 +270,9 @@ static function (UnpackedItem $item): UnpackedItem { $data=stream_get_contents($socket); fclose($socket); pcntl_waitpid($pids[$position],$status); - [$packed,$unpacked,$exhaustive,$reached,$stats]=unserialize($data); - $effortReached=$effortBudget!==null&&$effortBudget->exceeded($stats); - $wallReached=$reached&&($absoluteDeadlineNs-hrtime(true))<=0; - if($effortReached){ - $unpacked=array_map( - static function (UnpackedItem $item): UnpackedItem { - return $item->reason==='time_limit' - ? new UnpackedItem($item->instance,'effort_limit',$item->details) - : $item; - }, - $unpacked, - ); - } + [$packed,$unpacked,$exhaustive,$reached,$stats,$effortStopped]=unserialize($data); + [$wallReached,$effortReached]=self::limitsReached($reached,$effortStopped,$absoluteDeadlineNs-hrtime(true)); + $unpacked=self::effortLimited($unpacked,$effortReached); $id=$solver->name().':'.$orderName; $results[$position]=new RawSolution($id,$packed,$unpacked,$stats,$wallReached,$effortReached,$exhaustive); $records[$position]=new StartRecord($id,true,!$reached,$reached); @@ -325,18 +293,8 @@ private function runOneStartInProcess(SingleContainerSolver $solver,string $orde $budget=Deadline::until($absoluteDeadlineNs); if($effortBudget!==null)$budget=$budget->withEffort($effortBudget,$stats); [$packed,$unpacked,$exhaustive,$reached,]=$this->acrossContainers($solver,$order,$containers,$config,$stats,$budget,$preloaded); - $effortReached=$budget->effortExceeded(); - $wallReached=$reached&&$budget->remainingNs()<=0; - if($effortReached){ - $unpacked=array_map( - static function (UnpackedItem $item): UnpackedItem { - return $item->reason==='time_limit' - ? new UnpackedItem($item->instance,'effort_limit',$item->details) - : $item; - }, - $unpacked, - ); - } + [$wallReached,$effortReached]=self::limitsReached($reached,$budget->effortStopped(),$budget->remainingNs()); + $unpacked=self::effortLimited($unpacked,$effortReached); $id=$solver->name().':'.$orderName; return [ new RawSolution($id,$packed,$unpacked,$stats,$wallReached,$effortReached,$exhaustive), @@ -344,6 +302,51 @@ static function (UnpackedItem $item): UnpackedItem { ]; } + /** + * The deadline one start runs under. + * + * Without an effort budget each start gets an even share of what is left, so a slow start + * cannot starve the rest. Under one the clock is only the request's fuse: every start gets + * all that is left, and the clock stops the search only as a whole, so the portfolio-wide + * deadline and `time_limit_reached` say it did. A slice would let a start the clock cut + * lose quietly, making a budgeted answer depend on the machine's speed. + */ + private static function startBudget(Deadline $deadline,?EffortBudget $effortBudget,SearchStats $stats,int $startsLeft):Deadline + { + if($effortBudget===null)return $deadline->slice($startsLeft); + return $deadline->withEffort($effortBudget,$stats); + } + + /** + * Which limit cut a start, as `[wall clock, effort budget]`: at most one, and only for a + * start that was cut. The effort budget is named only when a poll found it spent and the + * search stopped there, not when a finished start used exactly its budget. + * + * @return array{bool,bool} + */ + private static function limitsReached(bool $reached,bool $effortStopped,int $remainingNs):array + { + $effortReached=$reached&&$effortStopped; + return [$reached&&!$effortReached&&$remainingNs<=0,$effortReached]; + } + + /** + * @param list $unpacked + * @return list + */ + private static function effortLimited(array $unpacked,bool $effortReached):array + { + if(!$effortReached)return $unpacked; + return array_map( + static function (UnpackedItem $item): UnpackedItem { + return $item->reason==='time_limit' + ? new UnpackedItem($item->instance,'effort_limit',$item->details) + : $item; + }, + $unpacked, + ); + } + private function finishPortfolio(array $items,array $containers,Deadline $deadline,array $results,array $records,array $preloaded=[]):PortfolioRun { $globalDeadline=$deadline->expired(); @@ -524,12 +527,6 @@ private function supportIsTheBlocker(ItemInstance $item,array $packed,PackingCon return false; } - /** - * Lattice nodes the block start added to `fast` and `balanced` may enumerate per mode -- - * the bound `quality` gives the same solver through `containerPlanNodeLimit`. - */ - private const BLOCK_START_NODE_LIMIT=100000; - /** * The block start `fast` and `balanced` add to their own, or `null`. * @@ -555,7 +552,7 @@ private function blockStart(array $items,PackingConfig $config,array $containers foreach($items as $instance)if(ItemOrdering::lead($instance,$config)!=$lead)return null; // One enumeration, the one the objective ranks by: `fast` is latency-bound. $modes=!$fast?HomogeneousBlockSolver::MODES:($config->objective==='maximum_volume'?['volume']:['count']); - $solver=new HomogeneousBlockSolver([],null,$modes,self::BLOCK_START_NODE_LIMIT); + $solver=new HomogeneousBlockSolver([],null,$modes,HomogeneousBlockSolver::START_NODE_LIMIT); if($containers===[])return null; foreach($containers as $container)if(!$solver->supports($container,$items,$config))return null; return $solver; @@ -710,32 +707,6 @@ private function orders(array $items,PackingConfig $config):array } /** @return array{0:list,1:list,2:bool,3:bool,4:bool} */ - /** - * Compute the request-level lower bound once, at the root. - * - * The plan beam already computes the same relaxation per node to prune with; the - * request-level bound is that formula with the state empty. `exact_small` gets it for - * the opposite reason: it exhausts its candidate set, and a bound is what turns "I - * stopped looking" into a statement about the request. - * - * Only for the default objective -- `lowest_cost` and `maximum_value` order their score - * keys differently, so a bound vector compared against them would line cost up against - * container count. Nothing downstream reads this yet, and that is deliberate. - * - * @param list $items - * @param list $containers - */ - private static function recordRootBound(SingleContainerSolver $solver,bool $beam,array $items,array $containers,PackingConfig $config,SearchStats $stats):void - { - if($stats->objectiveLowerBound!==null)return; - if(!$beam&&$solver->name()!=='exact_small')return; - if($config->objective!=='default')return; - // Its unused-volume and height keys are ratios against a declared size, and a - // made-to-measure box has none until it is cut: the bound would not be admissible. - foreach($containers as $container)if($container->madeToMeasure!==null)return; - $stats->objectiveLowerBound=ObjectiveBounds::compute($items,$containers); - } - /** * The greedy round's key for one trial, read from the container the trial built: a * made-to-measure trial carries its fabricated box, so the key prices the box that ships, @@ -848,7 +819,6 @@ private static function fabricated(Container $declared,SingleContainerSolution $ private function acrossContainers(SingleContainerSolver $solver,array $items,array $containers,PackingConfig $config,SearchStats $stats,Deadline $deadline,array $preloaded=[]):array { $beam=$config->containerPlanBeamWidth>1&&($this->containerSelector===null||$this->containerSelector instanceof DefaultContainerSelector); - self::recordRootBound($solver,$beam,$items,$containers,$config,$stats); [$opened,$reached]=$this->openPreloaded($solver,$preloaded,$items,$containers,$config,$stats,$deadline); if($beam){ return $this->acrossContainerPlans($solver,$items,$containers,$config,$stats,$deadline,$opened,$reached); @@ -864,7 +834,7 @@ private function acrossContainers(SingleContainerSolver $solver,array $items,arr $selector=$this->containerSelector??new DefaultContainerSelector(); while($remaining!==[]&&count($packed)<$maximum&&!$reached){ if($deadline->expired()){$reached=true;break;} - $candidates=[]; + $best=null;$bestScore=null; foreach($ordered as $container){ if($inventory[$container->id]!==null&&$inventory[$container->id]<=0)continue; try{[$one]=$this->trial($solver,$container,$seq[$container->id]+1,$remaining,$config,$stats,$deadline);} @@ -872,14 +842,11 @@ private function acrossContainers(SingleContainerSolver $solver,array $items,arr if($one->timeLimitReached)$reached=true; if($one->state->placementCount()===0){if($reached)break;continue;} $candidateScore=self::roundScore($one,$remaining,$config,$selector); - $candidates[]=[$candidateScore,$one]; + if($bestScore===null||$candidateScore<$bestScore){$bestScore=$candidateScore;$best=$one;} if($reached)break; } - if($candidates===[])break; - $candidates=StableSorter::sortBy($candidates,static function (array $candidate): array { - return $candidate[0]; - }); - $best=$candidates[0][1];$c=$best->state->container;$seq[$c->id]++; + if($best===null)break; + $c=$best->state->container;$seq[$c->id]++; $exhaustive=$exhaustive&&$best->exhaustive; $dominantLattice=$dominantLattice&&$best->dominantLattice; if($inventory[$c->id]!==null)$inventory[$c->id]--; @@ -897,8 +864,8 @@ private function acrossContainers(SingleContainerSolver $solver,array $items,arr if($reason==='no_feasible_placement')$reason=self::limitReason($i,$containers,$opened,$config)??$reason; $unpacked[]=new UnpackedItem($i,$reason,$details); } - // Optimality may only be claimed when an exhaustive search filled a single - // container — the cheapest one, since containers are tried in cost order. + // `RawSolution::$exhaustive` is a hint for extension scorers, not a proof: the result's + // `optimal` status comes only from attaining the lower bound (`Packer::statedOptimality`). $proven=$exhaustive&&!$reached&&$unpacked===[]&&count($packed)===1; return [$packed,$unpacked,$proven,$reached,$dominantLattice]; } @@ -1013,6 +980,7 @@ private function acrossContainerPlans(SingleContainerSolver $solver,array $items // Numbered per container type, as the greedy path and every other engine number // them: a fixed placement names `#`, so the count must be the type's own. $beam=$reached?[]:[$initial];$incumbent=$initial;$nodes=0; + $incumbentScore=self::planScore($incumbent,$incumbent['remaining'],$config); $maximum=$config->maxContainers??array_sum(array_map(static function (Container $container) use ($items): int { return $container->quantity??count($items); },$containers)); @@ -1022,7 +990,11 @@ private function acrossContainerPlans(SingleContainerSolver $solver,array $items while($beam!==[]&&$nodes<$config->containerPlanNodeLimit){ $expansions=[]; foreach($beam as $plan){ - if($plan['remaining']===[]||count($plan['packed'])>=$maximum){if(self::planScore($plan,$plan['remaining'],$config)=$maximum){ + $planScore=self::planScore($plan,$plan['remaining'],$config); + if($planScore<$incumbentScore){$incumbent=$plan;$incumbentScore=$planScore;} + continue; + } foreach($ordered as $container){ if($nodes>=$config->containerPlanNodeLimit)break; if($deadline->expired()){$reached=true;break;} @@ -1037,25 +1009,33 @@ private function acrossContainerPlans(SingleContainerSolver $solver,array $items }));$child['sequences'][$container->id]++; if($child['inventory'][$container->id]!==null)$child['inventory'][$container->id]--; $child['exhaustive']=$plan['exhaustive']&&$one->exhaustive;$child['dominant']=$plan['dominant']&&$one->dominantLattice;$expansions[]=$child; - if(self::planScore($child,$child['remaining'],$config)id(); - },$plan['remaining'])).'::'.json_encode($plan['inventory'],JSON_THROW_ON_ERROR);$previous=$dominant[$signature]??null;if($previous===null||self::planScore($plan,[],$config)id(); + },$plan['remaining'])).'::'.json_encode($plan['inventory'],JSON_THROW_ON_ERROR); + $partialScore=self::planScore($plan,[],$config); + $previous=$dominant[$signature]??null; + if($previous===null||$partialScore<$previous[0])$dominant[$signature]=[$partialScore,$plan]; + } // planBound re-derives the additive volume/payload relaxation with BigInt // string math; one evaluation per plan instead of one per stable-usort // comparison leaves the ordering untouched. - $beam=array_slice( - StableSorter::sortBy(array_values($dominant),static function (array $plan) use ($containers, $config): array { + $survivors=array_map(static function (array $pair): array { + return $pair[1]; + },array_values($dominant)); + $beam=count($survivors)>1 + ?array_slice(StableSorter::sortBy($survivors,static function (array $plan) use ($containers, $config): array { return self::planBound($plan,$containers,$config); - }), - 0,$config->containerPlanBeamWidth, - ); + }),0,$config->containerPlanBeamWidth) + :$survivors; } $unpacked=[];$opened=self::openedByType($incumbent['packed']);foreach($incumbent['remaining'] as $item){[$reason,$details]=$this->unpackedReason($item,$containers,$reached);if($reason==='no_feasible_placement'&&$this->supportIsTheBlocker($item,$incumbent['packed'],$config))$reason='insufficient_support';if($reason==='no_feasible_placement')$reason=self::limitReason($item,$containers,$opened,$config)??$reason;$unpacked[]=new UnpackedItem($item,$reason,$details);} $proven=$incumbent['exhaustive']&&!$reached&&$unpacked===[]&&count($incumbent['packed'])===1; diff --git a/src-legacy/Artifacts/OperationalArtifact.php b/src-legacy/Artifacts/OperationalArtifact.php index 4eeef69..03def28 100644 --- a/src-legacy/Artifacts/OperationalArtifact.php +++ b/src-legacy/Artifacts/OperationalArtifact.php @@ -36,7 +36,7 @@ final class OperationalArtifact * The engine's own name is deliberately not recorded: four correct builders naming * themselves would emit four different documents. */ - public const SUITE_VERSION = '1.7.0'; + public const SUITE_VERSION = '1.8.0'; /** The deterministic part of `result.algorithm`; `duration_ms` is wall-clock time. */ private const SOLVER_FIELDS = ['profile', 'solver', 'seed', 'time_limit_reached', 'effort_limit_reached']; diff --git a/src-legacy/Constraint/AxleLoad.php b/src-legacy/Constraint/AxleLoad.php index e8832c3..2002677 100644 --- a/src-legacy/Constraint/AxleLoad.php +++ b/src-legacy/Constraint/AxleLoad.php @@ -1,6 +1,7 @@ position->ticks; - $rearX = (string)$rear->position->ticks; - $span = BigInt::subtract($rearX, $frontX); // positive: enforced by Container::__construct - - $totalWeight = (string)$tareWeightTicks; - foreach ($units as $unit) { - $totalWeight = BigInt::add($totalWeight, (string)$unit->weightTicks); - } - - $doubledWeightedX = BigInt::multiply((string)$tareWeightTicks, (string)$tareDoubledCenterX); - foreach ($units as $unit) { - $doubledCenterX = (string)(2 * $unit->box->origin->x + $unit->box->dimensions->length->ticks); - $doubledWeightedX = BigInt::add($doubledWeightedX, BigInt::multiply((string)$unit->weightTicks, $doubledCenterX)); - } - - $denominator = BigInt::multiply('2', $span); - $twoTotalRearX = BigInt::multiply(BigInt::multiply('2', $totalWeight), $rearX); - $twoTotalFrontX = BigInt::multiply(BigInt::multiply('2', $totalWeight), $frontX); - return [ - 'denominator' => $denominator, - 'front_numerator' => self::signedSubtract($twoTotalRearX, $doubledWeightedX), - 'rear_numerator' => self::signedSubtract($doubledWeightedX, $twoTotalFrontX), - ]; + return AxleTotals::reactions($axles, AxleTotals::ofUnits($units, $tareWeightTicks, $tareDoubledCenterX)); } public static function exceeded(array $axles, array $units, int $tareWeightTicks=0, int $tareDoubledCenterX=0): ?array { - [$front, $rear] = $axles; - $reaction = self::reactions($axles, $units, $tareWeightTicks, $tareDoubledCenterX); - if ($front->maxLoad !== null && self::overLimit($reaction['front_numerator'], $front->maxLoad->ticks, $reaction['denominator'])) { - return ['axle_overloaded', 'front']; - } - if ($rear->maxLoad !== null && self::overLimit($reaction['rear_numerator'], $rear->maxLoad->ticks, $reaction['denominator'])) { - return ['axle_overloaded', 'rear']; - } - return null; - } - - private static function signedSubtract(string $left, string $right): string - { - $comparison = BigInt::compare($left, $right); - if ($comparison === 0) { return '0'; } - return $comparison > 0 - ? BigInt::subtract($left, $right) - : '-'.BigInt::subtract($right, $left); - } - - private static function overLimit(string $numerator, int $maxLoadTicks, string $denominator): bool - { - if (strncmp($numerator, '-', strlen('-')) === 0) { return false; } - $threshold = BigInt::multiply((string)$maxLoadTicks, $denominator); - return BigInt::compare($numerator, $threshold) > 0; + return AxleTotals::exceeded($axles, AxleTotals::ofUnits($units, $tareWeightTicks, $tareDoubledCenterX)); } /** diff --git a/src-legacy/Constraint/AxleLoadConstraint.php b/src-legacy/Constraint/AxleLoadConstraint.php index 4c8e56d..32522ac 100644 --- a/src-legacy/Constraint/AxleLoadConstraint.php +++ b/src-legacy/Constraint/AxleLoadConstraint.php @@ -1,6 +1,8 @@ |null */ + private $placements; + /** + * @var \Packvium\Domain\Container|null + */ + private $container; + /** @var array{0:string,1:string} */ + private $base = ['0', '0']; + public function evaluate(ConstraintContext $c): ConstraintResult { if ($c->container->axles === null) { return ConstraintResult::allow(); } - $candidate = $c->envelopeBox(); - $units = LoadCalculator::units( - $c->placements, - new LoadUnit( - $candidate, $c->item->weight()->ticks, null, null, $c->item->id(), - $c->item->item->nestingHeight===null?null:$c->item->item->id, - ($nullsafeVariable1 = $c->item->item->nestingHeight) ? $nullsafeVariable1->ticks : null, - ), - ); - $failure = AxleLoad::exceeded( + if ($this->placements !== $c->placements || $this->container !== $c->container) { + $this->base = AxleTotals::ofPlacements( + $c->placements, + $c->container->tareWeight->ticks, + $c->container->innerDimensions->length->ticks, + ); + $this->placements = $c->placements; + $this->container = $c->container; + } + $failure = AxleTotals::exceeded( $c->container->axles, - $units, - $c->container->tareWeight->ticks, - $c->container->innerDimensions->length->ticks, + AxleTotals::plus($this->base, $c->envelopeBox(), $c->item->weight()->ticks), ); return $failure === null ? ConstraintResult::allow() : ConstraintResult::reject($failure[0], $failure[1]); } diff --git a/src-legacy/Constraint/Internal/AxleTotals.php b/src-legacy/Constraint/Internal/AxleTotals.php new file mode 100644 index 0000000..ec8eef3 --- /dev/null +++ b/src-legacy/Constraint/Internal/AxleTotals.php @@ -0,0 +1,128 @@ + $placements + * @return array{0:string,1:string} gross weight, doubled weighted x + */ + public static function ofPlacements(array $placements, int $tareWeightTicks, int $tareDoubledCenterX): array + { + $totalWeight = (string)$tareWeightTicks; + $doubledWeightedX = BigInt::multiply((string)$tareWeightTicks, (string)$tareDoubledCenterX); + foreach ($placements as $placement) { + $weight = (string)$placement->instance->weight()->ticks; + $totalWeight = BigInt::add($totalWeight, $weight); + $doubledCenterX = (string)(2 * $placement->envelopeOrigin->x + $placement->envelopeDimensions->length->ticks); + $doubledWeightedX = BigInt::add($doubledWeightedX, BigInt::multiply($weight, $doubledCenterX)); + } + return [$totalWeight, $doubledWeightedX]; + } + + /** + * Tare plus every unit, each at its own box's centre. O(m). + * + * @param list $units + * @return array{0:string,1:string} gross weight, doubled weighted x + */ + public static function ofUnits(array $units, int $tareWeightTicks, int $tareDoubledCenterX): array + { + $totalWeight = (string)$tareWeightTicks; + foreach ($units as $unit) { + $totalWeight = BigInt::add($totalWeight, (string)$unit->weightTicks); + } + $doubledWeightedX = BigInt::multiply((string)$tareWeightTicks, (string)$tareDoubledCenterX); + foreach ($units as $unit) { + $doubledCenterX = (string)(2 * $unit->box->origin->x + $unit->box->dimensions->length->ticks); + $doubledWeightedX = BigInt::add($doubledWeightedX, BigInt::multiply((string)$unit->weightTicks, $doubledCenterX)); + } + return [$totalWeight, $doubledWeightedX]; + } + + /** + * `$totals` with one more box of `$weightTicks` at its centre. O(1). + * + * @param array{0:string,1:string} $totals + * @return array{0:string,1:string} + */ + public static function plus(array $totals, AxisAlignedBox $box, int $weightTicks): array + { + $weight = (string)$weightTicks; + $doubledCenterX = (string)(2 * $box->origin->x + $box->dimensions->length->ticks); + return [BigInt::add($totals[0], $weight), BigInt::add($totals[1], BigInt::multiply($weight, $doubledCenterX))]; + } + + /** + * What each axle carries, as numerators over one shared denominator. + * + * @param array{0:Axle,1:Axle} $axles + * @param array{0:string,1:string} $totals + * @return array{denominator:string,front_numerator:string,rear_numerator:string} + */ + public static function reactions(array $axles, array $totals): array + { + [$front, $rear] = $axles; + $frontX = (string)$front->position->ticks; + $rearX = (string)$rear->position->ticks; + $span = BigInt::subtract($rearX, $frontX); // positive: enforced by Container::__construct + [$totalWeight, $doubledWeightedX] = $totals; + $twoTotal = BigInt::multiply('2', $totalWeight); + return [ + 'denominator' => BigInt::multiply('2', $span), + 'front_numerator' => self::signedSubtract(BigInt::multiply($twoTotal, $rearX), $doubledWeightedX), + 'rear_numerator' => self::signedSubtract($doubledWeightedX, BigInt::multiply($twoTotal, $frontX)), + ]; + } + + /** + * The first axle over its own limit, as [code, detail], front before rear. + * + * @param array{0:Axle,1:Axle} $axles + * @param array{0:string,1:string} $totals + * @return array{0:string,1:string}|null + */ + public static function exceeded(array $axles, array $totals): ?array + { + [$front, $rear] = $axles; + $reaction = self::reactions($axles, $totals); + if ($front->maxLoad !== null && self::overLimit($reaction['front_numerator'], $front->maxLoad->ticks, $reaction['denominator'])) { + return ['axle_overloaded', 'front']; + } + if ($rear->maxLoad !== null && self::overLimit($reaction['rear_numerator'], $rear->maxLoad->ticks, $reaction['denominator'])) { + return ['axle_overloaded', 'rear']; + } + return null; + } + + private static function signedSubtract(string $left, string $right): string + { + $comparison = BigInt::compare($left, $right); + if ($comparison === 0) { return '0'; } + return $comparison > 0 + ? BigInt::subtract($left, $right) + : '-'.BigInt::subtract($right, $left); + } + + private static function overLimit(string $numerator, int $maxLoadTicks, string $denominator): bool + { + if (strncmp($numerator, '-', strlen('-')) === 0) { return false; } + $threshold = BigInt::multiply((string)$maxLoadTicks, $denominator); + return BigInt::compare($numerator, $threshold) > 0; + } +} diff --git a/src-legacy/Constraint/Internal/LoadAnalysis.php b/src-legacy/Constraint/Internal/LoadAnalysis.php index 35f8707..4fb6078 100644 --- a/src-legacy/Constraint/Internal/LoadAnalysis.php +++ b/src-legacy/Constraint/Internal/LoadAnalysis.php @@ -21,7 +21,7 @@ final class LoadAnalysis /** @var list */ private $units; /** - * @var \Packvium\Constraint\Internal\LoadSupportGraph + * @var \Packvium\Constraint\Internal\LoadSupportGraph|null */ private $graph; /** @var list|null */ @@ -30,25 +30,30 @@ final class LoadAnalysis private $restingAbove; /** @param list $units */ - public function __construct(array $units,LoadSupportGraph $graph) + public function __construct(array $units,?LoadSupportGraph $graph=null) { $this->units=$units; $this->graph=$graph; } + public function graph():LoadSupportGraph + { + return $this->graph = $this->graph ?? new LoadSupportGraph($this->units); + } + /** @return list */ public function topLoads():array { if($this->topLoads!==null)return $this->topLoads; $count=count($this->units); if($count===0)return $this->topLoads=[]; + $graph=$this->graph(); $loads=array_fill(0,$count,0); - $order=$this->graph->descendingIndices(); + $order=$graph->descendingIndices(); foreach($order as $upperIndex){ - $supports=$this->graph->supporters($upperIndex); - $totalArea=array_sum(array_map(static function (ContactEdge $edge): int { - return $edge->area; - },$supports)); + $supports=$graph->supporters($upperIndex); + $totalArea=0; + foreach($supports as $edge)$totalArea+=$edge->area; if($totalArea===0)continue; $downward=$this->units[$upperIndex]->weightTicks+$loads[$upperIndex]; $assigned=0;$last=count($supports)-1; @@ -110,10 +115,12 @@ public function restingAbove():array { if($this->restingAbove!==null)return $this->restingAbove; $count=count($this->units); - $sets=$count===0?[]:array_fill(0,$count,[]); - foreach($this->graph->descendingIndices() as $index){ + if($count===0)return $this->restingAbove=[]; + $graph=$this->graph(); + $sets=array_fill(0,$count,[]); + foreach($graph->descendingIndices() as $index){ $result=[]; - foreach($this->graph->children($index) as $child)$result+=[$child=>true]+$sets[$child]; + foreach($graph->children($index) as $child)$result+=[$child=>true]+$sets[$child]; $sets[$index]=$result; } return $this->restingAbove=$sets; @@ -139,13 +146,34 @@ public static function routed(array $stops):bool */ public function routeOrderViolated(array $stops):?array { - foreach($this->restingAbove() as $index=>$above){ + $count=count($this->units); + if($count===0)return null; + $graph=$this->graph(); + $maxStopAbove=array_fill(0,$count,-INF); + foreach($graph->descendingIndices() as $index){ + $best=-INF; + foreach($graph->children($index) as $child){ + $childStop=$stops[$child]; + if($childStop>$best)$best=$childStop; + $childAbove=$maxStopAbove[$child]; + if($childAbove>$best)$best=$childAbove; + } + $maxStopAbove[$index]=$best; + } + foreach($stops as $index=>$stop){ + if($maxStopAbove[$index]<=$stop)continue; // Ascending, as Python iterates: the detail names the first violator (S16). - $uppers=array_keys($above); - sort($uppers); - foreach($uppers as $upper) - if($stops[$upper]>$stops[$index]) - return ['unloading_order_violation',$this->units[$index]->label.' blocked by '.$this->units[$upper]->label]; + $seen=[]; + $pending=$graph->children($index); + $upperMin=null; + while($pending!==[]){ + $upper=array_pop($pending); + if(isset($seen[$upper]))continue; + $seen[$upper]=true; + if($stops[$upper]>$stop&&($upperMin===null||$upper<$upperMin))$upperMin=$upper; + foreach($graph->children($upper) as $child)$pending[]=$child; + } + return ['unloading_order_violation',$this->units[$index]->label.' blocked by '.$this->units[$upperMin]->label]; } return null; } diff --git a/src-legacy/Constraint/LoadCalculator.php b/src-legacy/Constraint/LoadCalculator.php index 1e0e47a..530198c 100644 --- a/src-legacy/Constraint/LoadCalculator.php +++ b/src-legacy/Constraint/LoadCalculator.php @@ -40,13 +40,13 @@ public static function units(array $placements,?LoadUnit $extra=null):array */ public static function topLoads(array $units):array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->topLoads(); + return (new LoadAnalysis($units))->topLoads(); } /** First unit whose bearing limit is exceeded, as [code, detail]. @param list $units @return array{0:string,1:string}|null */ public static function overloaded(array $units):?array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->overloaded(); + return (new LoadAnalysis($units))->overloaded(); } /** @@ -61,7 +61,7 @@ public static function overloaded(array $units):?array */ public static function crushed(array $units):?array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->crushed(); + return (new LoadAnalysis($units))->crushed(); } /** @@ -75,13 +75,13 @@ public static function crushed(array $units):?array */ public static function restingAbove(array $units):array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->restingAbove(); + return (new LoadAnalysis($units))->restingAbove(); } /** Items resting anywhere above each unit. A count, not a weight. @param list $units @return list */ public static function stackedCounts(array $units):array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->stackedCounts(); + return (new LoadAnalysis($units))->stackedCounts(); } /** @@ -101,13 +101,13 @@ public static function stackedCounts(array $units):array public static function routeOrderViolated(array $units,array $stops):?array { if(!LoadAnalysis::routed($stops))return null; - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->routeOrderViolated($stops); + return (new LoadAnalysis($units))->routeOrderViolated($stops); } /** First unit whose stacked-item limit is exceeded, as [code, detail]. @param list $units @return array{0:string,1:string}|null */ public static function stackLimitExceeded(array $units):?array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->stackLimitExceeded(); + return (new LoadAnalysis($units))->stackLimitExceeded(); } /** @@ -126,6 +126,6 @@ public static function stackLimitExceeded(array $units):?array */ public static function stackDensityExceeded(array $units,?int $maxDensityTicks):?array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->stackDensityExceeded($maxDensityTicks); + return (new LoadAnalysis($units))->stackDensityExceeded($maxDensityTicks); } } diff --git a/src-legacy/Constraint/SupportConstraint.php b/src-legacy/Constraint/SupportConstraint.php index b8bd567..8e48f7d 100644 --- a/src-legacy/Constraint/SupportConstraint.php +++ b/src-legacy/Constraint/SupportConstraint.php @@ -62,6 +62,10 @@ public function evaluate(ConstraintContext $c):ConstraintResult $baseArea=$c->envelopeDimensions->baseAreaTicks(); if($area50% of the base already + // encloses the centroid and needs no hull check. + if($area*2>$baseArea)return ConstraintResult::allow(); // Area alone is not stability: a candidate can clear the ratio // while overhanging its own centre of gravity, which the ratio check above // cannot see. Checked only once a minimum ratio is already being enforced, diff --git a/src-legacy/Constraint/VolumeReserve.php b/src-legacy/Constraint/VolumeReserve.php index 37ef6de..0a1c186 100644 --- a/src-legacy/Constraint/VolumeReserve.php +++ b/src-legacy/Constraint/VolumeReserve.php @@ -14,6 +14,9 @@ final class VolumeReserve { public static function reserved(Container $container):string { + // Nothing set aside is the common case, and answering it here keeps the request-level + // bound from loading SupportConstraint for its ratio scale alone. + if($container->voidFillReserveRatio===0.0)return '0'; $scaled=(string)SupportConstraint::scaled($container->voidFillReserveRatio); return BigInt::divide(BigInt::multiply($container->innerDimensions->volumeString(),$scaled),'1000000'); } diff --git a/src-legacy/Packer.php b/src-legacy/Packer.php index 64df02a..f1776d5 100644 --- a/src-legacy/Packer.php +++ b/src-legacy/Packer.php @@ -3,7 +3,7 @@ namespace Packvium; -use Packvium\Algorithm\{Deadline, FixedPlacementAdmission, SolverOrchestrator}; +use Packvium\Algorithm\{Deadline, FixedPlacementAdmission, ObjectiveBoundScope, ObjectiveBounds, SolverOrchestrator}; use Packvium\Support\{RequestObjects, StableSorter, UnsupportedPlacements}; use Packvium\Config\PackingConfig; use Packvium\Domain\{Container, FixedPlacement, Item, PackingRequest, UnratedWeightException}; @@ -13,6 +13,7 @@ AlgorithmReport, PackingResult, PackingStatus, + ResultFact, TerminationSummary }; use Packvium\Validation\IndependentSolutionValidator; @@ -49,12 +50,71 @@ public function __construct(?PackingConfig $config = null, ?ExtensionRegistry $e $this->clock = $clock; } + /** + * The request's lower bound in the objective's score order, or null. + * + * Over every instance, fixed placements included. A custom solution scorer + * ranks by positions the bound knows nothing about, so it gets none. + * + * @return list|null + */ + private function objectiveBound(PackingRequest $request): ?array + { + if ($this->solutionScorer !== null + || ObjectiveBoundScope::order($request->containers, $this->config->objective) === null + ) { + return null; + } + return ObjectiveBounds::forRequest($request->instances(), $request->containers, + $this->config->objective); + } + + /** + * The status and `optimality` fact one ranked result states (OPTIMALITY-GAP.md § 1.3). + * + * Attainment is the only route to `optimal`: a complete result whose score meets the bound + * on every position is proven, and nothing else is -- an exhausted candidate set covers + * only the points it generated. A null fact keeps the one derived from the status alone, + * which is every result the bound does not speak for (§ 1.5). + * + * @param list $score + * @param list|null $objectiveBound + * @return array{0:PackingStatus,1:?ResultFact} + */ + private static function statedOptimality(string $status, array $score, ?array $objectiveBound, bool $complete): array + { + if ($objectiveBound === null || $status === PackingStatus::InvalidResult) { + return [$status, null]; + } + $fields = ObjectiveBounds::statement($score, $objectiveBound); + if ($fields === null) { + return [$status, null]; + } + if ($complete && !array_key_exists('gap_key', $fields)) { + $status = PackingStatus::Optimal; + } + $code = $status === PackingStatus::Optimal ? 'proven_optimal' : ($complete ? 'not_proven' : 'best_found'); + return [$status, new ResultFact($code, $fields)]; + } + /** @param list $items @param list $containers @param list $fixedPlacements */ public function pack(array $items, array $containers, array $fixedPlacements = []): 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. RequestObjects::check($items, $containers, $this->config, $fixedPlacements); + return $this->solve($items, $containers, $fixedPlacements); + } + + /** + * The pack once the request's rules have passed. Private, so the public entry always + * checks; `ArrayCodec` reaches it after `RequestRules`, which refuses everything + * `RequestObjects` would. + * + * @param list $items @param list $containers @param list $fixedPlacements + */ + private function solve(array $items, array $containers, array $fixedPlacements): PackingResult + { $request = new PackingRequest($items, $containers, $fixedPlacements); // Both cost objectives price the same billed weight, so both need the divisor. // There is no library-chosen default: a wrong guess would silently misprice @@ -89,6 +149,10 @@ public function pack(array $items, array $containers, array $fixedPlacements = [ $this->config->clearance, $this->config->maxContainers, ); + // Once per pack, from the request alone and before the first solver runs, so that + // computing the bound never depends on which solver won, how many starts ran or + // how much of the deadline was left (OPTIMALITY-GAP.md F3). + $objectiveBound = $this->objectiveBound($request); $deadline = Deadline::ofMilliseconds($this->config->timeLimitMs, $this->clock); $orchestrator = new SolverOrchestrator( $this->extensions->placementConstraints, @@ -112,9 +176,7 @@ public function pack(array $items, array $containers, array $fixedPlacements = [ $score = $scorer->score($raw)->components; $status = ($raw->unpacked !== [] && $raw->timeLimitReached) ? PackingStatus::TimeLimit - : ($raw->unpacked !== [] - ? PackingStatus::BestFound - : ($raw->exhaustive ? PackingStatus::Optimal : PackingStatus::Feasible)); + : ($raw->unpacked !== [] ? PackingStatus::BestFound : PackingStatus::Feasible); $warnings = []; if ($this->config->validateResult) { // Independent validation re-derives every guarantee from per-item @@ -165,6 +227,7 @@ static function ($c) { } $aggregateTimedOut = $termination->attributes['global_deadline_reached'] || $raw->timeLimitReached; + [$status, $optimality] = self::statedOptimality($status, $score, $objectiveBound, $raw->unpacked === []); $ranked[] = new PackingResult($status, $raw->containers, $raw->unpacked, new AlgorithmReport( $this->config->profile, $raw->solverName, @@ -175,7 +238,7 @@ static function ($c) { $raw->stats->candidatesEvaluated, $raw->stats->placementsAttempted, $raw->stats->metrics(), - ), $score, $warnings, [], null, $termination, null, $this->config->objective); + ), $score, $warnings, [], null, $termination, $optimality, $this->config->objective); } $ranked = StableSorter::sortBy($ranked, static function ($result): array { diff --git a/src-legacy/Sequence/LoadingDependencyGraph.php b/src-legacy/Sequence/LoadingDependencyGraph.php index 4cdba1a..4fa4824 100644 --- a/src-legacy/Sequence/LoadingDependencyGraph.php +++ b/src-legacy/Sequence/LoadingDependencyGraph.php @@ -230,11 +230,12 @@ public static function verifyLoadingPrefixBusinessRules(array $placements, array foreach ($order as $step => $index) { $present[] = $placements[$index]; $units = LoadCalculator::units($present); - $problem = LoadCalculator::overloaded($units) - ?? LoadCalculator::stackLimitExceeded($units) - ?? LoadCalculator::stackDensityExceeded($units, $maxDensityTicks); + $analysis = new \Packvium\Constraint\Internal\LoadAnalysis($units); + $problem = $analysis->overloaded() + ?? $analysis->stackLimitExceeded() + ?? $analysis->stackDensityExceeded($maxDensityTicks); if ($problem === null) { - $counts = LoadCalculator::stackedCounts($units); + $counts = $analysis->stackedCounts(); foreach ($units as $unitIndex => $unit) { if (!$present[$unitIndex]->instance->item->stackable && $counts[$unitIndex] > 0) { $problem = ['non_stackable_item_has_load', $unit->label]; @@ -244,7 +245,7 @@ public static function verifyLoadingPrefixBusinessRules(array $placements, array } if ($problem === null && $grouped) { $last = count($present) - 1; - $problem = (new LoadSupportGraph($units))->nonStackableFailure($present, $present[$last]->instance, $last); + $problem = $analysis->graph()->nonStackableFailure($present, $present[$last]->instance, $last); } if ($problem === null) { $candidate = $present[count($present) - 1]; diff --git a/src-legacy/Sequence/UnloadingDependencyGraph.php b/src-legacy/Sequence/UnloadingDependencyGraph.php index eafa37f..8ec0433 100644 --- a/src-legacy/Sequence/UnloadingDependencyGraph.php +++ b/src-legacy/Sequence/UnloadingDependencyGraph.php @@ -96,18 +96,13 @@ public static function safeRemovalOrder(array $boxes, Dimensions $container, arr $present = array_fill_keys(array_keys($boxes), true); $order = []; while ($present !== []) { - $unblocked = []; + $chosen = null; foreach (array_keys($present) as $index) { $blockedBySupport = false; foreach (array_keys($dependsOn[$index] ?? []) as $dependency) { if (isset($present[$dependency])) { $blockedBySupport = true; break; } } if ($blockedBySupport) { continue; } - $unblocked[] = $index; - } - sort($unblocked); - $chosen = null; - foreach ($unblocked as $index) { if (SequenceGeometry::clearDirection($index, $boxes[$index], $boxes, $present, $container, $directions) !== null) { $chosen = $index; break; @@ -170,18 +165,13 @@ public static function safeRouteRemovalOrder(array $boxes, array $stops, Dimensi if ($stops[$index] === $stop) { $due[$index] = true; } } while ($due !== []) { - $unblocked = []; + $chosen = null; foreach (array_keys($due) as $index) { $blockedBySupport = false; foreach (array_keys($dependsOn[$index] ?? []) as $dependency) { if (isset($remaining[$dependency])) { $blockedBySupport = true; break; } } if ($blockedBySupport) { continue; } - $unblocked[] = $index; - } - sort($unblocked); - $chosen = null; - foreach ($unblocked as $index) { if (SequenceGeometry::clearDirection($index, $boxes[$index], $boxes, $remaining, $container, $directions) !== null) { $chosen = $index; break; diff --git a/src-legacy/Serialization/ArrayCodec.php b/src-legacy/Serialization/ArrayCodec.php index d0c6735..26ec3f8 100644 --- a/src-legacy/Serialization/ArrayCodec.php +++ b/src-legacy/Serialization/ArrayCodec.php @@ -9,6 +9,7 @@ use Packvium\Extension\ExtensionRegistry; use Packvium\Packer; use Packvium\Policy\PolicyRuleSet; +use Packvium\Result\PackingResult; use Packvium\Support\{FixedPlacementShape,JsonValue,MeasureReading,ObjectRefusal,RequestRules}; use Packvium\Unit\{Length,Weight}; use Throwable; @@ -183,11 +184,27 @@ public static function pack($data):array catch(InvalidArgumentException|TypeError|ValueError|ArithmeticError $error){throw self::asRequestError($error);} $unit=(string)($data['units']['length']??'mm'); $out=$data['output']??[]; - $result=(new Packer($config,$extensions))->pack($items,$containers,$fixed)->toArray($out['length_unit']??self::outputLengthCode($unit),$out['weight_unit']??'g'); + $result=self::solve(new Packer($config,$extensions),$items,$containers,$fixed)->toArray($out['length_unit']??self::outputLengthCode($unit),$out['weight_unit']??'g'); $result['catalog_versions_used']=$references; return $result; } + /** + * `Packer::pack` without its object check. `RequestRules::check` has passed this request, + * and `RequestObjects` refuses only what those rules refuse, so the second walk could never + * fire here. The solve stays private to `Packer`, keeping its public `pack` the + * checked entry for object-built requests; the closure borrows its scope. + * + * @param list $items @param list $containers @param list $fixed + */ + private static function solve(Packer $packer,array $items,array $containers,array $fixed):PackingResult + { + $solve=\Closure::bind(static function (Packer $packer) use ($items, $containers, $fixed): PackingResult { + return $packer->solve($items,$containers,$fixed); + },null,Packer::class); + return $solve($packer); + } + /** * The request's model, in the reference's order: configuration, catalog references, * policy, items, containers, fixed placements. @@ -213,9 +230,7 @@ private static function model(array $data):array $containers=array_map(function ($r) use ($unit) { return self::container($r,$unit); },$data['containers']); - $fixed=array_map(function ($r) use ($unit) { - return self::fixedPlacement($r,$unit); - },FixedPlacementShape::requireEntries($data['fixed_placements']??null,$unit)); + $fixed=self::fixedPlacements($data['fixed_placements']??null,$unit); return [$config,$references,$extensions,$items,$containers,$fixed]; } @@ -301,6 +316,21 @@ private static function hullVertices(?array $raw,string $unit):?array } private static function point(array $r,string $unit):Point{return new Point(Length::parse($r['x']??0,$unit)->ticks,Length::parse($r['y']??0,$unit)->ticks,Length::parse($r['z']??0,$unit)->ticks);} private static function box(array $r,string $unit):AxisAlignedBox{return new AxisAlignedBox(self::point($r['origin']??[],$unit),Dimensions::fromArray($r['dimensions'],$unit));} + /** + * Absent or null is none. Answered here, so that a request without fixed placements never + * compiles their shape checker. O(f) for f entries. + * + * @param mixed $raw + * @return list + */ + private static function fixedPlacements($raw,string $unit):array + { + if($raw===null)return []; + return array_map(function ($r) use ($unit) { + return self::fixedPlacement($r,$unit); + },FixedPlacementShape::requireEntries($raw,$unit)); + } + /** * One entry `FixedPlacementShape` has already admitted, so every read here is exact: the * names are strings, the orientation a code, the instance an integer by value. diff --git a/src-legacy/Support/CanonicalJson.php b/src-legacy/Support/CanonicalJson.php index e7777b9..4c518db 100644 --- a/src-legacy/Support/CanonicalJson.php +++ b/src-legacy/Support/CanonicalJson.php @@ -29,8 +29,8 @@ */ final class CanonicalJson { - /** The largest magnitude every engine holds exactly. */ - public const MAX_EXACT_MAGNITUDE = 9007199254740991; + /** The largest magnitude every engine holds exactly; {@see JsonValue} owns it. */ + public const MAX_EXACT_MAGNITUDE = JsonValue::MAX_EXACT_MAGNITUDE; /** * @param mixed $value diff --git a/src-legacy/Support/FixedPlacementShape.php b/src-legacy/Support/FixedPlacementShape.php index a561abc..021caee 100644 --- a/src-legacy/Support/FixedPlacementShape.php +++ b/src-legacy/Support/FixedPlacementShape.php @@ -24,7 +24,7 @@ final class FixedPlacementShape private const AXES = ['x', 'y', 'z']; /** - * The entries as JSON gave them; absent or null is none. O(f) for f entries. + * The entries as JSON gave them. O(f) for f entries; an absent list never reaches here. * * Entries arrive from `json_decode(..., true)`, where `{}` is the empty array, so an empty * array stands for an empty object wherever an object is required. @@ -35,9 +35,6 @@ final class FixedPlacementShape */ public static function requireEntries($raw, string $unit = 'mm'): array { - if ($raw === null) { - return []; - } if (!JsonValue::isList($raw)) { throw self::malformed('fixed_placements is a list', '/fixed_placements'); } diff --git a/src-legacy/Support/JsonValue.php b/src-legacy/Support/JsonValue.php index ed6065e..0f27fbc 100644 --- a/src-legacy/Support/JsonValue.php +++ b/src-legacy/Support/JsonValue.php @@ -15,6 +15,12 @@ */ final class JsonValue { + /** + * The largest magnitude every engine holds exactly. Kept here, not in `CanonicalJson`, so + * that reading a request's integers never compiles the canonical writer. + */ + public const MAX_EXACT_MAGNITUDE = 9007199254740991; + /** @param mixed $value */ public static function isList($value): bool { @@ -44,10 +50,10 @@ public static function isList($value): bool public static function integer($value): ?int { if (\is_int($value)) { - return \abs($value) <= CanonicalJson::MAX_EXACT_MAGNITUDE ? $value : null; + return \abs($value) <= self::MAX_EXACT_MAGNITUDE ? $value : null; } if (!\is_float($value) || !\is_finite($value) || \floor($value) !== $value - || \abs($value) > CanonicalJson::MAX_EXACT_MAGNITUDE) { + || \abs($value) > self::MAX_EXACT_MAGNITUDE) { return null; } return (int) $value; diff --git a/src-legacy/Support/RequestRules.php b/src-legacy/Support/RequestRules.php index c46bff5..3edb325 100644 --- a/src-legacy/Support/RequestRules.php +++ b/src-legacy/Support/RequestRules.php @@ -49,6 +49,8 @@ final class RequestRules 'solver_profile', 'solvers', 'time_limit_ms', ]; private const SIDES = ['length', 'width', 'height']; + /** The item fields the request schema types `boolean`, in schema order. */ + private const ITEM_BOOLEANS = ['keep_upright', 'stackable', 'must_be_on_floor']; /** Container fields a flat bag refuses, in reporting order (docs/DEFORMABLE-CONTAINERS.md). */ public const BAG_EXCLUSIONS = ['obstacles', 'axles', 'access_directions', 'max_stack_density', 'outer_dimensions', 'void_fill_reserve_ratio', 'made_to_measure']; @@ -135,6 +137,7 @@ private static function configuration($raw, string $unit): void } self::optionalRatio($configuration, 'minimum_support_ratio', $where, 1); self::optionalMeasure($configuration, 'clearance', $where, Length::class, $unit); + self::optionalBoolean($configuration, 'require_placement_coordinates', $where); $effort = JsonValue::get($configuration, 'effort_budget'); if ($effort !== null) { $budget = self::objectAt($effort, "{$where}/effort_budget"); @@ -193,6 +196,9 @@ private static function item($raw, string $where, string $unit): void self::optionalEachOneOf($item, 'allowed_rotations', $where, self::ROTATIONS); self::optionalOneOf($item, 'ground_contact_rule', $where, self::GROUND_CONTACT_RULES); self::optionalOneOf($item, 'shape_type', $where, self::SHAPE_TYPES); + foreach (self::ITEM_BOOLEANS as $name) { + self::optionalBoolean($item, $name, $where); + } } /** @param mixed $value */ @@ -608,6 +614,19 @@ private static function optionalInteger($object, string $name, string $where, in } } + /** + * Only JSON `true`/`false`: a `(bool)` cast would pack `"no"` as yes. O(1). + * + * @param stdClass|array $object + */ + private static function optionalBoolean($object, string $name, string $where): void + { + $value = JsonValue::get($object, $name); + if ($value !== null && !\is_bool($value)) { + throw self::refuse('wrong_type', "{$where}/{$name}", 'must be a boolean'); + } + } + /** @param stdClass|array $object */ private static function optionalRatio($object, string $name, string $where, ?int $maximum): void { diff --git a/src-legacy/Support/ResultValidation/Bound.php b/src-legacy/Support/ResultValidation/Bound.php new file mode 100644 index 0000000..f5dc74f --- /dev/null +++ b/src-legacy/Support/ResultValidation/Bound.php @@ -0,0 +1,531 @@ + [0, 1, 2, 3, 4], 'lowest_cost' => [0, 2, 1, 3, 4]]; + private const MAX_BOUND_SUM = '1000000000000000000000000000000'; + private const MAX_BOUND_VALUE = 9007199254740991; + private const SCORE_SCALE = 1000000; + private const GAP_FIELDS = ['gap_key', 'absolute_gap', 'relative_gap']; + + /** + * The reference's `_check_optimality_bound`. Absence of the field is never an issue. + * + * @param mixed $request @param mixed $result @param Ordered $items + * @param Ordered $containers @param list $score @param list $issues + */ + public static function check($request, $result, Ordered $items, Ordered $containers, array $score, array &$issues): void + { + $fact = Json::at($result, 'optimality'); + [$bound, $withheld] = self::stated($request, Json::at($result, 'status'), $items, $containers); + $departure = $bound === null ? null : self::departure($score, $bound); + $below = $departure !== null && $departure[1] < 0; + if ($below) { + $key = $departure[0]; + $issues[] = new ResultIssue('score_below_bound', "score[{$key}] is {$score[$key]}, below its lower bound {$bound[$key]}"); + } + $emitted = Json::get($fact, 'lower_bound'); + if ($emitted !== null && $bound !== null && $departure !== null + && Exact::compare(Exact::sub($score[$departure[0]], $bound[$departure[0]]), self::MAX_BOUND_VALUE) > 0) { + $issues[] = new ResultIssue('optimality_gap_mismatch', 'the gap exceeds the portable result ceiling'); + } + $counts = $emitted === null ? null : self::wellFormed($emitted); + if ($emitted === null) { + if (self::reportsGap($fact)) { + $issues[] = new ResultIssue('optimality_gap_mismatch', 'a gap is reported without lower_bound'); + } + } elseif ($counts === null) { + $issues[] = new ResultIssue('optimality_bound_mismatch', 'lower_bound must be a list of 1 to 5 non-negative integers'); + } elseif ($bound === null) { + $issues[] = new ResultIssue('optimality_bound_mismatch', "lower_bound is reported where no bound is stated: {$withheld}"); + } elseif ($below) { + $issues[] = new ResultIssue('optimality_bound_mismatch', 'lower_bound is reported for a score below the recomputed bound'); + } else { + self::checkReported($counts, $score, $bound, $departure, $fact, $issues); + } + $attained = $bound !== null && $departure === null; + self::checkClaims($result, $fact, $attained, $counts !== null && self::same($counts, $score), $issues); + } + + /** + * `[L0, L1, L2, L3, L4]`, or null where the one refusal predicate withholds the bound. Public so + * the shared scene can hold it to the oracle. + * + * @param list $volumes value runs sorted ascending + * @param list $weights + * @param list> $containers + * @return list|null + */ + public static function lowerBound(array $volumes, array $weights, bool $shrinks, array $containers): ?array + { + $quantities = self::column($containers, 'quantity'); + $guarded = [ + self::sumRuns($volumes), + self::sumRuns($weights), + self::capacityTotal(self::column($containers, 'usable'), $quantities), + self::capacityTotal(self::column($containers, 'payload'), $quantities), + self::capacityTotal(self::column($containers, 'max_items'), $quantities), + ]; + foreach ($guarded as $total) { + if ($total !== null && Exact::compare($total, self::MAX_BOUND_SUM) > 0) { + return null; + } + } + $count = self::runCount($volumes); + $placeable = 0; + if ($containers !== []) { + $placeable = $count; + if (!$shrinks) { + $placeable = self::min($placeable, self::fit($volumes, $guarded[2])); + } + $placeable = self::min($placeable, self::fit($weights, $guarded[3])); + if ($guarded[4] !== null) { + $placeable = self::min($placeable, $guarded[4]); + } + } + $opened = self::containerBound($volumes, $weights, $shrinks, $placeable, $containers); + $bound = [ + Exact::sub($count, $placeable), + $opened, + self::costBound($containers, $opened), + self::unusedBound($volumes, $shrinks, $placeable, $containers, $opened), + self::heightBound($volumes, $shrinks, $placeable, $containers, $opened), + ]; + foreach ($bound as $value) { + if (Exact::compare($value, self::MAX_BOUND_VALUE) > 0) { + return null; + } + } + return $bound; + } + + /** + * `[value, count]` pairs sorted by value: one per item type, however large its quantity. + * + * @param list $pairs @return list + */ + public static function runs(array $pairs): array + { + \usort($pairs, static function (array $left, array $right): int { return Exact::compare($left[0], $right[0]); }); + return $pairs; + } + + /** + * The bound in the objective's score order, or null with the reason none is stated (§ 1.5). + * + * @param mixed $request @param mixed $status @param Ordered $items @param Ordered $containers + * @return array{0:list|null,1:string} + */ + private static function stated($request, $status, Ordered $items, Ordered $containers): array + { + $name = Json::get(Json::get($request, 'configuration', []), 'objective', 'default'); + if (!\is_string($name) || !\array_key_exists($name, self::ORDERS)) { + return [null, 'objective ' . PyFormat::repr($name) . ' has no bound']; + } + foreach ($containers->values() as $spec) { + if ($spec->madeToMeasure !== null) { + return [null, 'a container is made to measure']; + } + } + if (Json::isText($status, 'infeasible')) { + return [null, "status is 'infeasible'"]; + } + if (Json::isText($status, 'invalid_result')) { + return [null, "status is 'invalid_result'"]; + } + [$volumes, $weights, $shrinks, $types] = self::inputs($items, $containers); + $bound = self::lowerBound($volumes, $weights, $shrinks, $types); + if ($bound === null) { + return [null, 'the request is past a declared ceiling']; + } + return [\array_map(static function (int $position) use ($bound) { return $bound[$position]; }, self::ORDERS[$name]), '']; + } + + /** + * The request as the bound reads it: every instance, fixed ones included, and every type. + * + * @param Ordered $items @param Ordered $containers @return array{0:list,1:list,2:bool,3:list>} + */ + private static function inputs(Ordered $items, Ordered $containers): array + { + $volumes = []; + $weights = []; + $shrinks = false; + foreach ($items->values() as $spec) { + $quantity = Json::int($spec->quantity, 'quantity'); + $volumes[] = [Exact::mul(Exact::mul($spec->dimensions[0], $spec->dimensions[1]), $spec->dimensions[2]), $quantity]; + $weights[] = [$spec->weight, $quantity]; + $shrinks = $shrinks || ($quantity > 0 && ($spec->nestingHeight !== null + || Json::isText($spec->shapeType, 'convex_hull') || Json::isText($spec->shapeType, 'compressible'))); + } + $types = []; + foreach ($containers->values() as $spec) { + $volume = $spec->volume(); + $scaled = ContainerCheck::ratioPpm($spec->voidFillReserveRatio); + $reserved = Exact::add( + Exact::mul(Exact::floorDiv($volume, self::SCORE_SCALE), $scaled), + Exact::floorDiv(Exact::mul(Exact::mod($volume, self::SCORE_SCALE), $scaled), self::SCORE_SCALE) + ); + $types[] = [ + 'inner_volume' => $volume, + 'base_area' => Exact::mul($spec->inner[0], $spec->inner[1]), + 'height' => $spec->inner[2], + 'usable' => Exact::sub($volume, $reserved), + 'cost_minor' => $spec->costMinor, + 'payload' => $spec->maxPayload, + 'max_items' => $spec->maxItems, + 'quantity' => $spec->quantity, + ]; + } + return [self::runs($volumes), self::runs($weights), $shrinks, $types]; + } + + /** @param list> $containers @return list */ + private static function column(array $containers, string $field): array + { + return \array_map(static function (array $container) use ($field) { return $container[$field]; }, $containers); + } + + /** @param list $runs @return int|string */ + private static function sumRuns(array $runs) + { + $total = 0; + foreach ($runs as [$value, $count]) { + $total = Exact::add($total, Exact::mul($value, $count)); + } + return $total; + } + + /** @param list $runs @return int|string */ + private static function runCount(array $runs) + { + $total = 0; + foreach ($runs as [, $count]) { + $total = Exact::add($total, $count); + } + return $total; + } + + /** @param int|string $left @param int|string $right @return int|string */ + private static function min($left, $right) + { + return Exact::compare($left, $right) <= 0 ? $left : $right; + } + + /** @param list $values @return int|string */ + private static function largest(array $values) + { + $largest = $values[0]; + foreach ($values as $value) { + $largest = Exact::max($largest, $value); + } + return $largest; + } + + /** + * How many of the smallest values sum to at most `capacity`; all of them when unlimited. + * + * @param list $runs @param int|string|null $capacity @return int|string + */ + private static function fit(array $runs, $capacity) + { + if ($capacity === null) { + return self::runCount($runs); + } + if (Exact::sign($capacity) < 0) { + return 0; + } + $taken = 0; + $used = 0; + foreach ($runs as [$value, $count]) { + $room = Exact::sign($value) === 0 ? $count : self::min($count, Exact::floorDiv(Exact::sub($capacity, $used), $value)); + $taken = Exact::add($taken, $room); + $used = Exact::add($used, Exact::mul($value, $room)); + if (Exact::compare($room, $count) < 0) { + break; + } + } + return $taken; + } + + /** The sum of the `count` smallest values. @param list $runs @param int|string $count @return int|string */ + private static function smallest(array $runs, $count) + { + $total = 0; + foreach ($runs as [$value, $available]) { + if (Exact::sign($count) <= 0) { + break; + } + $taken = self::min($available, $count); + $total = Exact::add($total, Exact::mul($value, $taken)); + $count = Exact::sub($count, $taken); + } + return $total; + } + + /** + * `Σ value × quantity`, or null when unbounded; a type that holds nothing adds 0. + * + * @param list $values @param list $quantities @return int|string|null + */ + private static function capacityTotal(array $values, array $quantities) + { + $total = 0; + foreach ($values as $index => $value) { + $quantity = $quantities[$index]; + if ($value === null) { + return null; + } + if ($quantity === null) { + if (Exact::sign($value) > 0) { + return null; + } + continue; + } + $total = Exact::add($total, Exact::mul($value, $quantity)); + } + return $total; + } + + /** + * `L1`, given `L0`. + * + * @param list $volumes @param list $weights + * @param int|string $placed @param list> $containers @return int|string + */ + private static function containerBound(array $volumes, array $weights, bool $shrinks, $placed, array $containers) + { + if (Exact::sign($placed) <= 0 || $containers === []) { + return 0; + } + $bound = 1; + $largestVolume = self::largest(self::column($containers, 'usable')); + if (!$shrinks && Exact::sign($largestVolume) > 0) { + $bound = Exact::max($bound, Exact::ceilDiv(self::smallest($volumes, $placed), $largestVolume)); + } + $payloads = self::column($containers, 'payload'); + if (!\in_array(null, $payloads, true)) { + $largestPayload = self::largest($payloads); + if (Exact::sign($largestPayload) > 0) { + $bound = Exact::max($bound, Exact::ceilDiv(self::smallest($weights, $placed), $largestPayload)); + } + } + $slots = self::column($containers, 'max_items'); + if (!\in_array(null, $slots, true)) { + $largestSlots = self::largest($slots); + if (Exact::sign($largestSlots) > 0) { + $bound = Exact::max($bound, Exact::ceilDiv($placed, $largestSlots)); + } + } + return $bound; + } + + /** + * `L2`: the `opened` cheapest containers the inventory holds, greedily by cost (stable). + * + * @param list> $containers @param int|string $opened @return int|string + */ + private static function costBound(array $containers, $opened) + { + $order = \array_keys($containers); + \usort($order, static function (int $left, int $right) use ($containers): int { + return Exact::compare($containers[$left]['cost_minor'], $containers[$right]['cost_minor']) ?: $left <=> $right; + }); + $total = 0; + foreach ($order as $index) { + if (Exact::sign($opened) <= 0) { + break; + } + $quantity = $containers[$index]['quantity']; + $taken = $quantity === null ? $opened : self::min($quantity, $opened); + $total = Exact::add($total, Exact::mul($containers[$index]['cost_minor'], $taken)); + $opened = Exact::sub($opened, $taken); + } + return $total; + } + + /** + * `L3`. + * + * @param list $volumes @param int|string $placed + * @param list> $containers @param int|string $opened @return int|string + */ + private static function unusedBound(array $volumes, bool $shrinks, $placed, array $containers, $opened) + { + $inner = self::positive(self::column($containers, 'inner_volume')); + if (Exact::sign($opened) <= 0 || $shrinks || $inner === []) { + return 0; + } + $smallestInner = $inner[0]; + foreach ($inner as $volume) { + $smallestInner = self::min($smallestInner, $volume); + } + $filled = Exact::ceilDiv(Exact::mul(self::smallest(\array_reverse($volumes), $placed), self::SCORE_SCALE), $smallestInner); + $unused = Exact::sub(Exact::sub(Exact::mul($opened, self::SCORE_SCALE), $filled), Exact::sub($opened, 1)); + return Exact::max(0, $unused); + } + + /** + * `L4`. + * + * @param list $volumes @param int|string $placed + * @param list> $containers @param int|string $opened @return int|string + */ + private static function heightBound(array $volumes, bool $shrinks, $placed, array $containers, $opened) + { + $floors = self::positive(self::column($containers, 'base_area')); + $heights = self::positive(self::column($containers, 'height')); + if (Exact::sign($opened) <= 0 || $shrinks || $floors === [] || $heights === []) { + return 0; + } + $required = Exact::ceilDiv(self::smallest($volumes, $placed), self::largest($floors)); + $aggregate = Exact::floorDiv(Exact::mul($required, self::SCORE_SCALE), self::largest($heights)); + return Exact::max(0, Exact::sub($aggregate, Exact::sub($opened, 1))); + } + + /** @param list $values @return list */ + private static function positive(array $values): array + { + $kept = []; + foreach ($values as $value) { + if ($value !== null && Exact::sign($value) > 0) { + $kept[] = $value; + } + } + return $kept; + } + + /** + * The first position where `score` leaves `bound`, and whether it is above (1) or below (-1). + * + * @param list $score @param list $bound @return array{0:int,1:int}|null + */ + private static function departure(array $score, array $bound): ?array + { + $shared = \min(\count($score), \count($bound)); + for ($index = 0; $index < $shared; ++$index) { + $order = Exact::compare($score[$index], $bound[$index]); + if ($order !== 0) { + return [$index, $order]; + } + } + return null; + } + + /** `lower_bound` as counts when it is a list of 1 to 5 non-negative integers. @param mixed $emitted @return list|null */ + private static function wellFormed($emitted): ?array + { + if (!Json::isList($emitted) || \count($emitted) < 1 || \count($emitted) > 5) { + return null; + } + foreach ($emitted as $value) { + if (!\is_int($value) || $value < 0) { + return null; + } + } + return $emitted; + } + + /** @param mixed $fact */ + private static function reportsGap($fact): bool + { + foreach (self::GAP_FIELDS as $name) { + if (Json::get($fact, $name) !== null) { + return true; + } + } + return false; + } + + /** @param list $left @param list $right */ + private static function same(array $left, array $right): bool + { + if (\count($left) !== \count($right)) { + return false; + } + foreach ($left as $index => $value) { + if (Exact::compare($value, $right[$index]) !== 0) { + return false; + } + } + return true; + } + + /** + * @param list $emitted @param list $score @param list $bound + * @param array{0:int,1:int}|null $departure @param mixed $fact @param list $issues + */ + private static function checkReported(array $emitted, array $score, array $bound, ?array $departure, $fact, array &$issues): void + { + $prefix = \array_slice($bound, 0, \count($emitted)); + if (!self::same($emitted, $prefix)) { + $issues[] = new ResultIssue('optimality_bound_mismatch', 'reported ' . PyFormat::intList($emitted) . ', recomputed ' . PyFormat::intList($prefix)); + } + $length = \count($bound); + $expected = ['gap_key' => null, 'absolute_gap' => null, 'relative_gap' => null]; + if ($departure !== null) { + $key = $departure[0]; + $absolute = Exact::sub($score[$key], $bound[$key]); + $length = $key + 1; + $expected = [ + 'gap_key' => $key, + 'absolute_gap' => $absolute, + 'relative_gap' => Exact::sign($bound[$key]) === 0 ? null : [$absolute, $bound[$key]], + ]; + } + if (\count($emitted) !== $length) { + $issues[] = new ResultIssue('optimality_gap_mismatch', 'lower_bound has ' . \count($emitted) . " entries, expected {$length}"); + } + foreach ($expected as $name => $value) { + if (!self::gapFieldMatches($name, Json::get($fact, $name), $value)) { + $text = $value === null ? 'absent' : ($name === 'relative_gap' ? "{$value[0]}/{$value[1]}" : (string)$value); + $issues[] = new ResultIssue('optimality_gap_mismatch', "{$name} should be {$text}"); + } + } + } + + /** @param mixed $actual @param int|string|array{0:int|string,1:int|string}|null $expected */ + private static function gapFieldMatches(string $name, $actual, $expected): bool + { + if ($expected === null) { + return $actual === null; + } + if ($name !== 'relative_gap') { + return \is_int($actual) && Exact::compare($actual, $expected) === 0; + } + return Json::isDict($actual) && \count($actual) === 2 + && \is_int(Json::get($actual, 'numerator')) && Exact::compare($actual['numerator'], $expected[0]) === 0 + && \is_int(Json::get($actual, 'denominator')) && Exact::compare($actual['denominator'], $expected[1]) === 0; + } + + /** @param mixed $result @param mixed $fact @param list $issues */ + private static function checkClaims($result, $fact, bool $attained, bool $claimsAttainment, array &$issues): void + { + $status = Json::at($result, 'status'); + $complete = Json::truthy(Json::at($result, 'complete')); + if (Json::isText($status, 'optimal') && !($complete && $attained)) { + $issues[] = new ResultIssue('result_fact_mismatch', "status 'optimal' requires a complete result that attains the recomputed lower bound"); + } + if (Json::isText($status, 'feasible') && $complete && $claimsAttainment) { + $issues[] = new ResultIssue('result_fact_mismatch', "status is 'feasible', but the result attains its lower_bound; expected 'optimal'"); + } + if (Json::isText(Json::get($fact, 'code'), 'proven_optimal') && !$claimsAttainment) { + $issues[] = new ResultIssue('unproven_optimality_claim', "optimality.code is 'proven_optimal' without an attained lower_bound"); + } + } +} diff --git a/src-legacy/Support/ResultValidation/Json.php b/src-legacy/Support/ResultValidation/Json.php index 1517c34..e1a2018 100644 --- a/src-legacy/Support/ResultValidation/Json.php +++ b/src-legacy/Support/ResultValidation/Json.php @@ -63,6 +63,24 @@ public static function at($value, string $key) return $value[$key]; } + /** + * A field the request schema types boolean: absent or null is its default, and any other + * non-boolean is refused, as every engine refuses it, rather than read by truthiness. + * + * @param mixed $value + */ + public static function flag($value, string $key, bool $default, string $where): bool + { + $flag = self::get($value, $key); + if ($flag === null) { + return $default; + } + if (!\is_bool($flag)) { + throw MalformedResult::contract("{$where}.{$key} must be a boolean"); + } + return $flag; + } + /** Something the reference iterates; only a list is admitted. @param mixed $value @return list */ public static function items($value, string $what): array { diff --git a/src-legacy/Support/ResultValidation/Lattice.php b/src-legacy/Support/ResultValidation/Lattice.php index 8e80ed0..eb8eb4b 100644 --- a/src-legacy/Support/ResultValidation/Lattice.php +++ b/src-legacy/Support/ResultValidation/Lattice.php @@ -15,12 +15,12 @@ final class Lattice private const SIDES = ['length', 'width', 'height']; /** - * @param mixed $request @param mixed $result + * @param mixed $result * @param Ordered $items @param Ordered $containers * @param int|string $clearance @param list $issues * @return mixed the result with every valid summary expanded into placements */ - public static function materialize($request, $result, Ordered $items, Ordered $containers, $clearance, array &$issues) + public static function materialize(bool $coordinatesRequired, $result, Ordered $items, Ordered $containers, $clearance, array &$issues) { $packedContainers = Json::items(Json::at($result, 'containers'), 'result.containers'); $summarized = false; @@ -30,9 +30,8 @@ public static function materialize($request, $result, Ordered $items, Ordered $c if (!$summarized) { return $result; } - $optional = Json::get(Json::get($request, 'configuration', []), 'require_placement_coordinates') === false; foreach ($packedContainers as $index => $packed) { - $placements = self::expand($packed, $optional, $items, $containers, $clearance, $issues); + $placements = self::expand($packed, $coordinatesRequired, $items, $containers, $clearance, $issues); if ($placements !== null) { $packedContainers[$index]['placements'] = $placements; } @@ -46,14 +45,14 @@ public static function materialize($request, $result, Ordered $items, Ordered $c * @param int|string $clearance @param list $issues * @return list|null the placements to write into the container, or null to leave it */ - private static function expand($packed, bool $optional, Ordered $items, Ordered $containers, $clearance, array &$issues): ?array + private static function expand($packed, bool $coordinatesRequired, Ordered $items, Ordered $containers, $clearance, array &$issues): ?array { $summary = Json::get($packed, 'lattice_summary'); if ($summary === null) { return null; } $containerId = PyFormat::str(Json::get($packed, 'id', '?')); - if (!$optional) { + if ($coordinatesRequired) { $issues[] = new ResultIssue('unexpected_lattice_summary', "{$containerId}: request requires explicit placement coordinates"); } if (Json::truthy(Json::get($packed, 'placements'))) { diff --git a/src-legacy/Support/ResultValidation/ObjectiveCheck.php b/src-legacy/Support/ResultValidation/ObjectiveCheck.php index 90fc817..c32cb75 100644 --- a/src-legacy/Support/ResultValidation/ObjectiveCheck.php +++ b/src-legacy/Support/ResultValidation/ObjectiveCheck.php @@ -11,8 +11,13 @@ */ final class ObjectiveCheck { - /** @param mixed $request @param mixed $result @param Ordered $fabricated @param list $issues */ - public static function check($request, $result, Context $context, Ordered $fabricated, array &$issues): void + /** + * Checks the reported score against the recomputed one, and returns the reported score. + * + * @param mixed $request @param mixed $result @param Ordered $fabricated @param list $issues + * @return list + */ + public static function check($request, $result, Context $context, Ordered $fabricated, array &$issues): array { $configuration = Json::get($request, 'configuration', []); $selected = Json::get($configuration, 'objective', 'default'); @@ -34,6 +39,7 @@ public static function check($request, $result, Context $context, Ordered $fabri if (!$same) { $issues[] = new ResultIssue('score_mismatch', 'reported ' . PyFormat::intList($reported) . ', recomputed ' . PyFormat::intList($recomputed)); } + return $reported; } /** diff --git a/src-legacy/Support/ResultValidation/Spec.php b/src-legacy/Support/ResultValidation/Spec.php index f255c6a..45a6742 100644 --- a/src-legacy/Support/ResultValidation/Spec.php +++ b/src-legacy/Support/ResultValidation/Spec.php @@ -16,10 +16,14 @@ final class Spec public static function items($request, string $unit): Ordered { $items = new Ordered(); - foreach (Json::items(Json::at($request, 'items'), 'request.items') as $raw) { + foreach (Json::items(Json::at($request, 'items'), 'request.items') as $index => $raw) { + $where = "items[{$index}]"; + $keepUpright = Json::flag($raw, 'keep_upright', false, $where); + $stackable = Json::flag($raw, 'stackable', true, $where); + $mustBeOnFloor = Json::flag($raw, 'must_be_on_floor', false, $where); $rotations = Json::get($raw, 'allowed_rotations'); if ($rotations === null) { - $rotations = Json::truthy(Json::get($raw, 'keep_upright')) ? self::UPRIGHT_ROTATIONS : \array_keys(Geometry::ROTATIONS); + $rotations = $keepUpright ? self::UPRIGHT_ROTATIONS : \array_keys(Geometry::ROTATIONS); } else { $rotations = Json::items($rotations, 'allowed_rotations'); } @@ -30,8 +34,8 @@ public static function items($request, string $unit): Ordered $spec->weight = Units::toTicks(Json::get($raw, 'weight', 0), 'g', Units::WEIGHT); $spec->quantity = Json::get($raw, 'quantity', 1); $spec->allowedRotations = \array_values($rotations); - $spec->stackable = Json::truthy(Json::get($raw, 'stackable', true)); - $spec->mustBeOnFloor = Json::truthy(Json::get($raw, 'must_be_on_floor')); + $spec->stackable = $stackable; + $spec->mustBeOnFloor = $mustBeOnFloor; $spec->maxTopLoad = self::measured($raw, 'max_top_load', 'g', Units::WEIGHT); $spec->minimumSupportRatio = Units::ratioOf(Json::get($raw, 'minimum_support_ratio', 0)); $spec->tags = self::tagSet(Json::get($raw, 'tags', []), 'tags'); diff --git a/src-legacy/Support/ResultValidation/Units.php b/src-legacy/Support/ResultValidation/Units.php index cdf6e54..051b39c 100644 --- a/src-legacy/Support/ResultValidation/Units.php +++ b/src-legacy/Support/ResultValidation/Units.php @@ -22,10 +22,49 @@ final class Units 'lb' => 3628738960, 'lbs' => 3628738960, 'tick' => 1, 'ticks' => 1, ]; + /** + * Every name the engines read in `units.length`, after stripping ASCII whitespace and + * lowering ASCII case, to the `LENGTH` key it means. + */ + private const REQUEST_LENGTH_UNITS = [ + 'mm' => 'mm', 'cm' => 'cm', 'm' => 'm', 'in' => 'in', 'inch' => 'inch', 'inches' => 'inches', + 'ft' => 'ft', 'tick' => 'tick', 'ticks' => 'ticks', 'millimeter' => 'mm', 'millimeters' => 'mm', + ]; + public const TICKS_PER_MM = 16000; public const TICKS_PER_GRAM = 8000000; public const TICKS_PER_KG = 8000000000; + /** + * `units.length` as every engine reads it: `units` or `units.length` absent or + * null is millimetres; `units` that is not an object, or a length unit the engines do not + * know, is refused naming the field. PHP decodes `[]` and `{}` alike, so `units: []` reads + * as an empty object, as the PHP engine reads it. + * + * @param mixed $request + */ + public static function lengthUnit($request): string + { + $units = Json::get($request, 'units'); + if ($units === null) { + return 'mm'; + } + if (!Json::isDict($units)) { + throw MalformedResult::contract('units must be an object'); + } + $length = Json::get($units, 'length'); + if ($length === null) { + return 'mm'; + } + $key = \is_string($length) + ? \strtr(\trim($length, " \t\n\r\v\f"), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') + : ''; + if (!\array_key_exists($key, self::REQUEST_LENGTH_UNITS)) { + throw MalformedResult::contract('units.length must be one of mm, cm, m, in, ft'); + } + return self::REQUEST_LENGTH_UNITS[$key]; + } + /** `table[unit]`, an exact key. @param array $table */ public static function perUnit(array $table, string $unit): int { diff --git a/src-legacy/Support/ResultValidation/Validator.php b/src-legacy/Support/ResultValidation/Validator.php index 8195a80..4c46c70 100644 --- a/src-legacy/Support/ResultValidation/Validator.php +++ b/src-legacy/Support/ResultValidation/Validator.php @@ -27,15 +27,16 @@ public static function validate($request, $result): array throw MalformedResult::contract('result is missing ' . PyFormat::stringRepr($key)); } } - $unit = Json::text(Json::get(Json::get($request, 'units', []), 'length', 'mm'), 'units.length'); + $unit = Units::lengthUnit($request); $configuration = Json::get($request, 'configuration', []); + $coordinatesRequired = Json::flag($configuration, 'require_placement_coordinates', true, 'configuration'); $clearance = Units::toTicks(Json::get($configuration, 'clearance', 0), $unit, Units::LENGTH); $globalSupport = Units::ratioOf(Json::get($configuration, 'minimum_support_ratio', 0)); $items = Spec::items($request, $unit); $containers = Spec::containers($request, $unit); $issues = []; - $validated = Lattice::materialize($request, $result, $items, $containers, $clearance, $issues); + $validated = Lattice::materialize($coordinatesRequired, $result, $items, $containers, $clearance, $issues); Accounting::checkStructure($validated, $issues); Facts::checkAlgorithmMetrics($validated, $issues); Facts::checkEffortBudget($request, $validated, $issues); @@ -50,7 +51,8 @@ public static function validate($request, $result): array ObjectiveCheck::checkFixedPlacements($request, $validated, $unit, $issues); ObjectiveCheck::checkGroups($request, $validated, $issues); ObjectiveCheck::checkSolverSelection($request, $validated, $issues); - ObjectiveCheck::check($request, $validated, $context, $fabricated, $issues); + $score = ObjectiveCheck::check($request, $validated, $context, $fabricated, $issues); + Bound::check($request, $validated, $items, $containers, $score, $issues); return $issues; } diff --git a/src-legacy/Validation/IndependentSolutionValidator.php b/src-legacy/Validation/IndependentSolutionValidator.php index 5d05742..ffd496b 100644 --- a/src-legacy/Validation/IndependentSolutionValidator.php +++ b/src-legacy/Validation/IndependentSolutionValidator.php @@ -33,7 +33,8 @@ public function validate(PackingRequest $request,array $containers,float $minimu // A stacking-group list refuses load on the same edges `stackable` does. if(!$item->stackable||$item->acceptsStackingGroups!==[])$stackSensitive=true; } - $stackGraph=$stackSensitive?new LoadSupportGraph(LoadCalculator::units($placements)):null; + $units=LoadCalculator::units($placements); + $stackGraph=$stackSensitive?new LoadSupportGraph($units):null; foreach(self::collisionPairs($placements) as [$left,$right]) $issues[]=new ValidationIssue('collision',$placements[$left]->instance->id().' with '.$placements[$right]->instance->id()); foreach($packed->placements as $index=>$p){ @@ -76,9 +77,9 @@ public function validate(PackingRequest $request,array $containers,float $minimu } // A second, whole-container bearing pass: the per-placement check above // reports the first offender it meets, this one is anchored on the container. - $units=LoadCalculator::units($packed->placements); $densityLimit=($nullsafeVariable2 = $packed->container->maxStackDensity) ? $nullsafeVariable2->ticks : null; - $failure=LoadCalculator::overloaded($units)??LoadCalculator::crushed($units)??LoadCalculator::stackLimitExceeded($units)??LoadCalculator::stackDensityExceeded($units,$densityLimit); + $analysis=new \Packvium\Constraint\Internal\LoadAnalysis($units,$stackGraph); + $failure=$analysis->overloaded()??$analysis->crushed()??$analysis->stackLimitExceeded()??$analysis->stackDensityExceeded($densityLimit); if($failure!==null)$issues[]=new ValidationIssue($failure[0],$packed->id().': '.$failure[1]); if($packed->container->axles!==null){ $axleFailure=AxleLoad::exceeded( diff --git a/src/Algorithm/BeamPacker.php b/src/Algorithm/BeamPacker.php index 7982a98..93d23ef 100644 --- a/src/Algorithm/BeamPacker.php +++ b/src/Algorithm/BeamPacker.php @@ -42,6 +42,9 @@ public static function pack(Container $container,int $sequence,array $items,Pack $expansions=[];$exhausted=false; foreach($beam as [$state,$unplaced]){ if($nodeLimit!==null&&$nodes>=$nodeLimit){$exhausted=true;break;} + // A spent budget would stop this node before its first counted step, so it + // is not counted either: a counter never passes its limit. + if($deadline->effortStops()){$exhausted=true;$expansions[]=[$state,array_merge($unplaced,$batch)];continue;} $nodes++; $stats->searchNodesExpanded++; try{$children=self::placeBatch($state,$batch,$config,$constraints,$stats,$deadline,$scorer,$branchWidth);} @@ -62,14 +65,18 @@ public static function pack(Container $container,int $sequence,array $items,Pack if($candidateKey<$incumbentKey){$incumbent=[$state,array_merge($unplaced,$future)];$incumbentKey=$candidateKey;} } if($expansions===[])break; - $futureInfo=self::precomputeFuture($future,$container->maxPayload?->ticks); - $beam=array_slice(StableSorter::sortBy($expansions,static fn(array $node):array=>self::nodeKeyPrecomputed($node,$futureInfo,$byVolume)),0,$width); + if(count($expansions)>1){ + $futureInfo=self::precomputeFuture($future,$container->maxPayload?->ticks); + $beam=array_slice(StableSorter::sortBy($expansions,static fn(array $node):array=>self::nodeKeyPrecomputed($node,$futureInfo,$byVolume)),0,$width); + }else{ + $beam=$expansions; + } if($exhausted){ [$state,$unplaced]=$incumbent; - return new SingleContainerSolution($state,$unplaced,false,$deadline->expired()); + return new SingleContainerSolution($state,$unplaced,false,$deadline->stopped()); } } - $ordered=StableSorter::sortBy($beam,static fn(array $node):array=>self::nodeKey($node,$byVolume)); + $ordered=count($beam)>1?StableSorter::sortBy($beam,static fn(array $node):array=>self::nodeKey($node,$byVolume)):$beam; $completed=$ordered[0]??null; [$state,$unplaced]=$completed!==null&&self::nodeKey($completed,$byVolume)<$incumbentKey?$completed:$incumbent; return new SingleContainerSolution($state,$unplaced); @@ -90,13 +97,16 @@ private static function packLegacy(Container $container,int $sequence,array $ite foreach($batches as $position=>$batch){ $expansions=[];$exhausted=false; foreach($beam as [$state,$unplaced]){ + if($deadline->effortStops()){$exhausted=true;$expansions[]=[$state,array_merge($unplaced,$batch)];continue;} $stats->searchNodesExpanded++; try{$children=self::placeBatch($state,$batch,$config,$constraints,$stats,$deadline,$scorer,$width);} catch(TimeLimitReached){$exhausted=true;$children=[];} if($children!==[]){foreach($children as $child)$expansions[]=[$child,$unplaced];} else $expansions[]=[$state,array_merge($unplaced,$batch)]; } - $beam=array_slice(StableSorter::sortBy($expansions,static fn(array $node):array=>self::nodeKey($node,$byVolume)),0,$width); + $beam=count($expansions)>1 + ?array_slice(StableSorter::sortBy($expansions,static fn(array $node):array=>self::nodeKey($node,$byVolume)),0,$width) + :$expansions; if($exhausted){ [$state,$unplaced]=$beam[0]; $pending=GroupBatcher::flatten(array_slice($batches,$position+1)); diff --git a/src/Algorithm/CandidateFinder.php b/src/Algorithm/CandidateFinder.php index cbfa625..27e544a 100644 --- a/src/Algorithm/CandidateFinder.php +++ b/src/Algorithm/CandidateFinder.php @@ -67,10 +67,15 @@ public static function find(ContainerState $state,ItemInstance $item,PackingConf // support rule stays in regardless: `supportChecks` counts every time the chain // reaches it. $active=[]; + $inertChain=true; foreach($constraints as $constraint){ if(ConstraintActivity::isInert($constraint,$container,$item,$stackSensitive,$routeSensitive))continue; - $active[]=[$constraint,$constraint instanceof SupportConstraint,ConstraintActivity::isPositionIndependent($constraint)]; + $isSupport=$constraint instanceof SupportConstraint; + if(!$isSupport||$constraint->canReject($item))$inertChain=false; + $active[]=[$constraint,$isSupport,ConstraintActivity::isPositionIndependent($constraint)]; } + $inertSupportChecks=count($active); + $defaultScorer=get_class($scorer)===\Packvium\Extension\DefaultCandidateScorer::class; // A rule that reads no position answers the same at every (point, rotation) of this // search, so it is asked once, by the first position that reaches it. // Keyed by chain position; the stored value is the verdict's `allowed`. @@ -86,15 +91,23 @@ public static function find(ContainerState $state,ItemInstance $item,PackingConf $scan=array_slice($scan,0,$config->maxCandidatePoints); } if($container->axles!==null)$scan=self::appendUnseen($scan,self::axleBalancedPoints($state,$item,$forms,$limitX)); - }else{$scan=$points;usort($scan,static fn(Point $a,Point $b):int=>[$a->z,$a->y,$a->x]<=>[$b->z,$b->y,$b->x]);} + }else{ + $scan=$points; + if(count($scan)>1)usort($scan,static fn(Point $a,Point $b):int=>[$a->z,$a->y,$a->x]<=>[$b->z,$b->y,$b->x]); + } $out=[];$best=null; $retained=$max!==null&&$max>1?new \SplPriorityQueue():null; $placementOffset=count($bounds)-count($placed); + // The point's check alone let a point's rotations run a counter past its limit, so + // each counted step is compared with its ceiling first; reaching one stops the scan + // through the same check, which records the stop. + [$attemptCeiling,$candidateCeiling]=$deadline->placementCeilings($stats); foreach($scan as $point){ $deadline->check(); $stats->candidatePointsConsidered++; $x1=$point->x;$y1=$point->y;$z1=$point->z; foreach($forms as [$rotation,$physical,$envelope,$dx,$dy,$dz,$shape]){ + if($stats->placementsAttempted>=$attemptCeiling)$deadline->check(); $stats->placementsAttempted++; $x2=$x1+$dx;$y2=$y1+$dy;$z2=$z1+$dz; if($x2>$limitX||$y2>$limitY||$z2>$limitZ)continue; @@ -127,19 +140,35 @@ public static function find(ContainerState $state,ItemInstance $item,PackingConf } } if($blocked)continue; - $ctx=new ConstraintContext($container,$placed,$item,$point,$rotation,$physical,$envelope,$stackSensitive,$routeSensitive); - foreach($active as $link=>[$constraint,$countsSupport,$positionIndependent]){ - if($countsSupport)$stats->supportChecks++; - $allowed=$positionIndependent - ?($fixedVerdicts[$link]??=$constraint->evaluate($ctx)->allowed) - :$constraint->evaluate($ctx)->allowed; - if(!$allowed){$blocked=true;break;} + if($inertChain){ + $stats->supportChecks+=$inertSupportChecks; + }else{ + $ctx=new ConstraintContext($container,$placed,$item,$point,$rotation,$physical,$envelope,$stackSensitive,$routeSensitive); + foreach($active as $link=>[$constraint,$countsSupport,$positionIndependent]){ + if($countsSupport)$stats->supportChecks++; + $allowed=$positionIndependent + ?($fixedVerdicts[$link]??=$constraint->evaluate($ctx)->allowed) + :$constraint->evaluate($ctx)->allowed; + if(!$allowed){$blocked=true;break;} + } + if($blocked)continue; } - if($blocked)continue; - $score=$scorer->score($state,$point,$envelope); - $position=$tentative?->position; - if($reserveCheck){ - $position??=new Point($x1+$clearance,$y1+$clearance,$z1+$clearance); + if(!$reserveCheck){ + if($stats->candidatesEvaluated>=$candidateCeiling)$deadline->check(); + $stats->candidatesEvaluated++; + if($defaultScorer){ + if($max===1&&$best!==null&&$z1>$best->score[0])continue; + if($retained!==null&&$retained->count()===$max&&$z1>$retained->top()->score[0])continue; + $score=[$z1,$z2>$bagZ?$z2:$bagZ,($limitX-$x2)*($limitY-$y2)+($limitZ-$z2),$y1,$x1]; + }else{ + $score=$scorer->score($state,$point,$envelope); + } + $position=$tentative?->position; + }else{ + $score=$defaultScorer + ?[$z1,$z2>$bagZ?$z2:$bagZ,($limitX-$x2)*($limitY-$y2)+($limitZ-$z2),$y1,$x1] + :$scorer->score($state,$point,$envelope); + $position=$tentative?->position??new Point($x1+$clearance,$y1+$clearance,$z1+$clearance); $reservePlacement=$tentative??new Placement( $item,$position,$rotation,$physical,$point,$envelope, ); @@ -156,8 +185,9 @@ public static function find(ContainerState $state,ItemInstance $item,PackingConf $projected=BigInt::add($state->usedVolume,Nesting::usedVolumeDelta($placed,$reservePlacement)); } if(BigInt::compare($projected,$usable)>0)continue; + if($stats->candidatesEvaluated>=$candidateCeiling)$deadline->check(); + $stats->candidatesEvaluated++; } - $stats->candidatesEvaluated++; // Selection follows every predicate and scorer call so extensions and // effort counters observe exactly the same enumeration as a full sort. if($max===1&&$best!==null&&$score>=$best->score)continue; diff --git a/src/Algorithm/Deadline.php b/src/Algorithm/Deadline.php index 1b5ca75..818a4f0 100644 --- a/src/Algorithm/Deadline.php +++ b/src/Algorithm/Deadline.php @@ -9,6 +9,12 @@ * existing call site already polls `expired()`/`check()`, so attaching an effort * budget (via `withEffort`) needs no changes anywhere else; leaving it unattached * (the default) reproduces the exact prior time-only behaviour. + * + * A spent budget answers `true` to a poll, and that poll is a search refusing further work: + * the deadline records it (`effortStopped()`), so the effort flag says the budget stopped the + * search, not that a counter equals its limit afterwards. A search that used exactly + * its budget and finished was not stopped. Code asking afterwards whether a search was cut + * reads `stopped()`, which records nothing. */ final class Deadline { @@ -18,6 +24,8 @@ final class Deadline private int $started; private readonly \Closure $clock; private readonly bool $usesRealClock; + /** Per-solve state: set once a poll found the effort budget spent and the search stopped there. */ + private bool $effortStopped=false; public function __construct( private readonly int $limitNs, @@ -66,7 +74,22 @@ public static function until(int $absoluteNs):self public function elapsedMs():int{return intdiv(($this->clock)()-$this->started,1_000_000);} public function remainingNs():int{return $this->limitNs-(($this->clock)()-$this->started);} + /** Whether the budget is spent; a query that records nothing. */ public function effortExceeded():bool{return $this->effortBudget!==null && $this->stats!==null && $this->effortBudget->exceeded($this->stats);} + /** + * Whether the effort budget stops the search at this poll, recording that it did. Only a + * caller about to do more work asks: asking is refusing it. + */ + public function effortStops():bool + { + if(!$this->effortExceeded())return false; + $this->effortStopped=true; + return true; + } + /** Whether a poll found the effort budget spent and the search stopped there. */ + public function effortStopped():bool{return $this->effortStopped;} + /** Whether the search was cut, by the effort budget at a poll or by the clock; records nothing. */ + public function stopped():bool{return $this->effortStopped || $this->remainingNs()<=0;} /** * How many more search nodes counted into `$counted` may expand before `effortExceeded()` * trips, while nothing else that budget counts moves: zero once it has tripped, and @@ -79,7 +102,33 @@ public function searchNodesLeft(SearchStats $counted):int if($this->effortBudget===null||$this->effortBudget->maxSearchNodes===null||$this->stats!==$counted)return PHP_INT_MAX; return $this->effortBudget->maxSearchNodes-$counted->searchNodesExpanded; } - public function expired():bool{return $this->effortExceeded() || $this->remainingNs()<=0;} + /** + * The `placementsAttempted` and `candidatesEvaluated` values of `$counted` at which the budget + * stops a search, `PHP_INT_MAX` for a counter no limit reads. A scan compares a counter with + * its ceiling before each step, so the counter never passes its limit. O(1). + * + * @return array{int,int} + */ + public function placementCeilings(SearchStats $counted):array + { + $budget=$this->effortBudget; + if($budget===null||$this->stats!==$counted)return [PHP_INT_MAX,PHP_INT_MAX]; + return [$budget->maxPlacementAttempts??PHP_INT_MAX,$budget->maxCandidatesEvaluated??PHP_INT_MAX]; + } + /** + * Whether the budget leaves room for `$candidates`, `$placements` and `$nodes` more counted + * steps on `$counted`, so a solver that counts a run of steps at once never passes a limit; + * a query that records nothing. O(1). + */ + public function effortRoom(SearchStats $counted,int $candidates,int $placements,int $nodes):bool + { + $budget=$this->effortBudget; + if($budget===null||$this->stats!==$counted)return true; + return ($budget->maxCandidatesEvaluated===null||$counted->candidatesEvaluated+$candidates<=$budget->maxCandidatesEvaluated) + && ($budget->maxPlacementAttempts===null||$counted->placementsAttempted+$placements<=$budget->maxPlacementAttempts) + && ($budget->maxSearchNodes===null||$counted->searchNodesExpanded+$nodes<=$budget->maxSearchNodes); + } + public function expired():bool{return $this->effortStops() || $this->remainingNs()<=0;} public function slice(int $parts):self { diff --git a/src/Algorithm/ExactSmallSolver.php b/src/Algorithm/ExactSmallSolver.php index a8ad960..68cdb97 100644 --- a/src/Algorithm/ExactSmallSolver.php +++ b/src/Algorithm/ExactSmallSolver.php @@ -144,6 +144,6 @@ public function packOne(Container $container,int $sequence,array $items,PackingC // generated discrete points. It stops at the first full packing and does not // minimize the rest of the public objective vector, so it cannot claim global // optimality. - return new SingleContainerSolution($best,array_values(array_filter($items,static fn($i)=>!isset($packed[$i->id()]))),false,$deadline->expired()); + return new SingleContainerSolution($best,array_values(array_filter($items,static fn($i)=>!isset($packed[$i->id()]))),false,$deadline->stopped()); } } diff --git a/src/Algorithm/GridSolver.php b/src/Algorithm/GridSolver.php index 9ab6612..56522f3 100644 --- a/src/Algorithm/GridSolver.php +++ b/src/Algorithm/GridSolver.php @@ -150,7 +150,10 @@ public function packOne(Container $container,int $sequence,array $items,PackingC if($prototype->group!==null&&$totalclearance->ticks; $sameDeclaredType=count(array_unique(array_map(static fn($i)=>$i->item->id,array_slice($items,0,$total))))===1; - if($total>0&&!$config->requirePlacementCoordinates&&$nestingTicks===0&&$sameDeclaredType){ + // The fast path counts the whole lattice in one step, so it is taken only when the + // budget covers it; otherwise the per-item loop below places the same lattice one + // counted item at a time and stops on the limit, never past it. + if($total>0&&!$config->requirePlacementCoordinates&&$nestingTicks===0&&$sameDeclaredType&&$deadline->effortRoom($stats,$total,$total,1)){ // Quantity-compression fast path: the lattice parameters above // already fully determine every coordinate in O(r); skip the O(n) loop // that would otherwise build one Placement per instance purely to fill diff --git a/src/Algorithm/HomogeneousBlockSolver.php b/src/Algorithm/HomogeneousBlockSolver.php index 4196ae3..0bcc11e 100644 --- a/src/Algorithm/HomogeneousBlockSolver.php +++ b/src/Algorithm/HomogeneousBlockSolver.php @@ -33,10 +33,16 @@ final class HomogeneousBlockSolver implements SingleContainerSolver public const LOOKAHEAD_WIDTH=4; /** The lookahead's lattice-node budget per mode, as a multiple of the node limit. */ public const LOOKAHEAD_NODE_FACTOR=64; + /** + * Lattice nodes one mode may enumerate under every profile but `quality` -- the bound + * `quality` gives the same solver through `containerPlanNodeLimit`. + */ + public const START_NODE_LIMIT=100_000; /** - * `fast` and `balanced` leave `containerPlanNodeLimit` at one, which would stop the - * lattice enumeration before its first block; their start carries its own bound. + * Every profile but `quality` leaves `containerPlanNodeLimit` at one, which would stop + * the lattice enumeration before its first block, so under them the solver carries its + * own bound (`nodeLimit()`); the start `fast` and `balanced` add passes it. * * @param list $constraints * @param list $modes @@ -50,19 +56,27 @@ public function packOne(Container $container,int $sequence,array $items,PackingC { if(!$this->supports($container,$items,$config)) return (new ExtremePointSolver($this->constraints))->packOne($container,$sequence,$items,$config,$stats,$deadline); - $nodeLimit=$this->nodeLimit??$config->containerPlanNodeLimit; - $best=null;$reached=false; + $nodeLimit=$this->nodeLimit($config); + $best=null; foreach($this->modes as $mode){ - if($deadline->expired()){$reached=true;break;} + if($deadline->expired())break; $solution=$this->packMode($container,$sequence,$items,$config,$stats,$deadline,$mode,$nodeLimit); if($config->profile===SolverProfile::Quality&&!$deadline->expired()) $solution=$this->lookahead($container,$sequence,$items,$config,$stats,$deadline,$mode,$nodeLimit*self::LOOKAHEAD_NODE_FACTOR,$solution); - $reached=$reached||$solution->timeLimitReached; $byVolume=$config->objective==='maximum_volume'; if($best===null||$this->solutionKey($solution,$byVolume)<$this->solutionKey($best,$byVolume))$best=$solution; } - if($best===null)return new SingleContainerSolution(new ContainerState($container,$sequence),$items,false,$reached); - return new SingleContainerSolution($best->state,$best->unpacked,false,$reached); + // Only the deadline or the effort budget marks the answer as stopped by a limit: the + // node limit is a search bound, and the answer it leaves is the search's own. + if($best===null)return new SingleContainerSolution(new ContainerState($container,$sequence),$items,false,$deadline->stopped()); + return new SingleContainerSolution($best->state,$best->unpacked,false,$deadline->stopped()); + } + + /** Lattice nodes one mode may enumerate: the start's own bound, `quality`'s node limit, or START_NODE_LIMIT. */ + private function nodeLimit(PackingConfig $config):int + { + if($this->nodeLimit!==null)return $this->nodeLimit; + return $config->profile===SolverProfile::Quality?$config->containerPlanNodeLimit:self::START_NODE_LIMIT; } public function supports(Container $container,array $items,PackingConfig $config):bool @@ -268,7 +282,7 @@ private static function leadingBlocks(array $spaces,array $capacities,array $rot if($nodes+$groupNodes>$stopAt||(($nodes+$groupNodes-1)>>self::CLOCK_SHIFT)!==(($nodes-1)>>self::CLOCK_SHIFT)){ $expanded=self::expandable($nodes,$groupNodes,$stopAt,$deadline); $nodes+=$expanded;$stats->searchNodesExpanded+=$expanded; - if($expanded<$groupNodes){$reached=true;return [];} + if($expanded<$groupNodes){$deadline->effortStops();$reached=true;return [];} }else{$nodes+=$groupNodes;$stats->searchNodesExpanded+=$groupNodes;} if($envelopeHeight>$spaceHeight)continue; $maximumZ=intdiv($spaceHeight,$envelopeHeight); @@ -320,7 +334,7 @@ private function packMode(Container $container,int $sequence,array $items,Packin $remaining=self::byType($items); $nodes=0;$reached=false; $solution=$this->complete($container,new ContainerState($container,$sequence),[new Space(new Point(0,0,0),$container->innerDimensions)],$remaining,self::rotationsOf($remaining,$config),$config,$stats,$deadline,$mode,$nodeLimit,$nodes,$reached); - return new SingleContainerSolution($solution->state,$solution->unpacked,false,$reached||$deadline->expired()); + return new SingleContainerSolution($solution->state,$solution->unpacked,false,$reached||$deadline->stopped()); } /** @@ -371,7 +385,8 @@ private function complete(Container $container,ContainerState $state,array $spac while($spaces!==[]&&array_sum(array_map('count',$remaining))>0){ $leading=self::round($container,$state,$spaces,$remaining,$rotations,$mode,1,$nodeLimit,$nodes,$stats,$deadline,$reached); if($leading===[]||$reached)break; - $spaces=self::commit($state,$spaces,$remaining,$leading[0],$config,$stats); + $spaces=self::commit($state,$spaces,$remaining,$leading[0],$config,$stats,$deadline); + if($spaces===null){$reached=true;break;} } $unpacked=[];foreach($remaining as $available)foreach($available as $instance)$unpacked[]=$instance; return new SingleContainerSolution($state,$unpacked,false,$reached); @@ -406,7 +421,8 @@ private function lookahead(Container $container,int $sequence,array $items,Packi $chosen=$known===null?null:[$this->solutionKey($known,$byVolume),$known,$leading[0]]; foreach($known===null?$leading:array_slice($leading,1) as $block){ $trialState=$state->copy();$trialRemaining=$remaining; - $trialSpaces=self::commit($trialState,$spaces,$trialRemaining,$block,$config,$stats); + $trialSpaces=self::commit($trialState,$spaces,$trialRemaining,$block,$config,$stats,$deadline); + if($trialSpaces===null){$reached=true;break;} $trial=$this->complete($container,$trialState,$trialSpaces,$trialRemaining,$rotations,$config,$stats,$deadline,$mode,$nodeLimit,$nodes,$reached); if($reached)break; $trialKey=$this->solutionKey($trial,$byVolume); @@ -415,34 +431,58 @@ private function lookahead(Container $container,int $sequence,array $items,Packi if($reached||$chosen===null)break; [$chosenKey,$known,$block]=$chosen; if($chosenKey<$bestKey){$best=$known;$bestKey=$chosenKey;} - $spaces=self::commit($state,$spaces,$remaining,$block,$config,$stats); + $spaces=self::commit($state,$spaces,$remaining,$block,$config,$stats,$deadline); + if($spaces===null){$reached=true;break;} } - return new SingleContainerSolution($best->state,$best->unpacked,false,$best->timeLimitReached||$deadline->expired()); + return new SingleContainerSolution($best->state,$best->unpacked,false,$best->timeLimitReached||$deadline->stopped()); } /** * Place every member of the block, bottom layer first, and return the spaces left. * + * Each member is one counted candidate and placement. When the effort budget has room for + * fewer than the whole block, the members it has room for are placed, the budget records the + * stop, and `null` says the search ends here: a counter never passes its limit. + * * @param list $spaces * @param array> $remaining * @param array $best - * @return list + * @return list|null */ - private static function commit(ContainerState $state,array $spaces,array &$remaining,array $best,PackingConfig $config,SearchStats $stats):array + private static function commit(ContainerState $state,array $spaces,array &$remaining,array $best,PackingConfig $config,SearchStats $stats,Deadline $deadline):?array { - $chosen=array_slice($remaining[$best['itemId']],0,$best['count']); - $remaining[$best['itemId']]=array_slice($remaining[$best['itemId']],$best['count']); - $clearance=$config->clearance->ticks;$index=0; - for($z=0;$z<$best['nz'];$z++)for($y=0;$y<$best['ny'];$y++)for($x=0;$x<$best['nx'];$x++){ + [$attemptCeiling,$candidateCeiling]=$deadline->placementCeilings($stats); + $members=max(0,min($best['count'],$attemptCeiling-$stats->placementsAttempted,$candidateCeiling-$stats->candidatesEvaluated)); + self::placeMembers($state,$remaining,$best,$members,$config,$stats); + if($members<$best['count']){ + $deadline->effortStops(); + return null; + } + $blockDimensions=new Dimensions(new Length($best['nx']*$best['envelope']->length->ticks),new Length($best['ny']*$best['envelope']->width->ticks),new Length($best['nz']*$best['envelope']->height->ticks)); + return MaximalSpaceSolver::subtractAll($spaces,[new AxisAlignedBox($best['space']->origin,$blockDimensions)],$stats); + } + + /** + * The block's first `$members` members, x fastest, then y, then z: a layer is filled before + * the one above it, so a member above the bottom layer rests on the member beneath it. + * + * @param array> $remaining + * @param array $best + */ + private static function placeMembers(ContainerState $state,array &$remaining,array $best,int $members,PackingConfig $config,SearchStats $stats):void + { + $chosen=array_slice($remaining[$best['itemId']],0,$members); + $remaining[$best['itemId']]=array_slice($remaining[$best['itemId']],$members); + $clearance=$config->clearance->ticks;$perLayer=$best['nx']*$best['ny']; + for($index=0;$index<$members;$index++){ + $x=$index%$best['nx'];$y=intdiv($index,$best['nx'])%$best['ny'];$z=intdiv($index,$perLayer); $point=new 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=new Point($point->x+$clearance,$point->y+$clearance,$point->z+$clearance); // Above its bottom layer a member rests on an identical footprint; the // bottom layer rests on whatever is there. $support=$z===0?self::memberSupport($state,$chosen[$index],$point,$best['envelope']):1.0; - $state->addDirect(new Placement($chosen[$index++],$position,$best['rotation'],$best['physical'],$point,$best['envelope'],$support)); + $state->addDirect(new Placement($chosen[$index],$position,$best['rotation'],$best['physical'],$point,$best['envelope'],$support)); $stats->candidatesEvaluated++;$stats->placementsAttempted++; } - $blockDimensions=new Dimensions(new Length($best['nx']*$best['envelope']->length->ticks),new Length($best['ny']*$best['envelope']->width->ticks),new Length($best['nz']*$best['envelope']->height->ticks)); - return MaximalSpaceSolver::subtractAll($spaces,[new AxisAlignedBox($best['space']->origin,$blockDimensions)],$stats); } } diff --git a/src/Algorithm/MaximalSpaceSolver.php b/src/Algorithm/MaximalSpaceSolver.php index c3489ea..069af46 100644 --- a/src/Algorithm/MaximalSpaceSolver.php +++ b/src/Algorithm/MaximalSpaceSolver.php @@ -44,9 +44,12 @@ public function packOne(Container $container,int $sequence,array $items,PackingC $unplaced=[]; $batches=GroupBatcher::batches($items); foreach($batches as $position=>$batch){ - $snapshot=$state->copy();$saved=$spaces;$placed=true; + $snapshot=count($batch)>1?$state->copy():$state;$saved=$spaces;$placed=true; try{ foreach($batch as $item){ + // An item past a full container reaches no check of its own, so a spent + // budget is checked before its node is counted. + if($deadline->effortStops())throw new TimeLimitReached('effort budget spent'); $stats->searchNodesExpanded++; $chosen=self::choose($state,$spaces,$item,$config,$constraints,$stats,$deadline,$scorer); if($chosen===null){$placed=false;break;} @@ -185,19 +188,21 @@ private static function survivors(array $kept):array } /** - * Whether another extent contains `$extents[$index]`, for extents ordered by origin z. + * Whether another extent contains `$extents[$index]`, for extents ordered by + * `(z1, y1, x1, -volume)`. * - * A container starts at or below what it contains, so the scan stops at the first - * extent that starts higher: O(prefix) instead of O(s), with no index to build. + * A containing extent starts at or before `$extents[$index]` on every axis and has strictly + * larger volume when origins match, so it always appears at a position `< $index` with + * `$extent[2] <= $z1` already guaranteed. * * @param list $extents */ private static function isContained(array $extents,int $index):bool { - [$x1,$y1,$z1,$x2,$y2,$z2]=$extents[$index]; - foreach($extents as $other=>$extent){ - if($extent[2]>$z1)return false; - if($other!==$index&&$extent[0]<=$x1&&$extent[1]<=$y1&&$x2<=$extent[3]&&$y2<=$extent[4]&&$z2<=$extent[5])return true; + [$x1,$y1,,$x2,$y2,$z2]=$extents[$index]; + for($other=0;$other<$index;++$other){ + $extent=$extents[$other]; + if($extent[3]>=$x2&&$extent[4]>=$y2&&$extent[5]>=$z2&&$extent[0]<=$x1&&$extent[1]<=$y1)return true; } return false; } diff --git a/src/Algorithm/ObjectiveBoundScope.php b/src/Algorithm/ObjectiveBoundScope.php new file mode 100644 index 0000000..4e5faa1 --- /dev/null +++ b/src/Algorithm/ObjectiveBoundScope.php @@ -0,0 +1,33 @@ + [0, 1, 2, 3, 4], + 'lowest_cost' => [0, 2, 1, 3, 4], + ]; + + /** + * O(containers) time, O(1) space; no instances or arithmetic are needed to refuse. + * @param list $containers + * @return list|null + */ + public static function order(array $containers, string $objective): ?array + { + $order = self::OBJECTIVE_ORDERS[$objective] ?? null; + if ($order === null) { + return null; + } + foreach ($containers as $container) { + if ($container->madeToMeasure !== null) { + return null; + } + } + return $order; + } +} diff --git a/src/Algorithm/ObjectiveBounds.php b/src/Algorithm/ObjectiveBounds.php index 4c84ce7..b7d4646 100644 --- a/src/Algorithm/ObjectiveBounds.php +++ b/src/Algorithm/ObjectiveBounds.php @@ -27,17 +27,22 @@ * to a float. * * Complexity. `O(n log n + c log c)` for `n` instances and `c` container types, the same as - * the Python port: one sort of the volumes, one of the weights, one of the per-unit costs. - * The volume sort compares decimal strings through `BigInt::compare` rather than PHP's - * default string order, where "9" would sort after "10". + * the Python port: one sort of the volumes, one of the weights, one of the container types + * by cost. The volume sort compares decimal strings through `BigInt::compare` rather than + * PHP's default string order, where "9" would sort after "10". + * + * Which instances, and when. The bound is a property of the request: `Packer` computes it + * once per pack through `forRequest`, over every instance the request declares -- fixed + * placements included -- before the first solver runs. Refusal is the + * one predicate of docs/OPTIMALITY-CERTIFICATES.md, the same in every engine. * * Namespace. `Packvium\Algorithm` is one of the three namespaces `spec/api/php.json` * records as internal, and that is deliberate rather than incidental. A lower bound is - * solver machinery: `SolverOrchestrator` -- its only production caller, and already in this - * namespace -- records it into `SearchStats`, and nothing serializes it. Filed first under - * the public `Packvium\Objective`, it added two classes to a frozen public API for no - * caller outside this namespace, which is a contract this project would then owe forever. - * Python and JavaScript reached the same place by construction: neither exports its bound. + * solver machinery, and nothing serializes it until the 1.8.0 result fields are emitted + *. Filed first under the public `Packvium\Objective`, it added two classes to a + * frozen public API for no caller outside the engine, which is a contract this project would + * then owe forever. Python and JavaScript reached the same place by construction: neither + * exports its bound. */ final class ObjectiveBounds { @@ -62,6 +67,83 @@ final class ObjectiveBounds /** Largest result integer every binding can carry without changing its value. */ public const MAX_BOUND_VALUE = '9007199254740991'; + /** + * The objectives a bound is stated for, and where each reads the five bounds: the + * position in `compute`'s default-order vector of each of the objective's score keys. + * `lowest_cost` scores cost ahead of the container count, and the document shows the + * permuted vector is still a bound. Every other objective gets none. + */ + public const OBJECTIVE_ORDERS = ObjectiveBoundScope::OBJECTIVE_ORDERS; + + /** + * The request's bound in `$objective`'s own score order, or null where none is stated. + * + * Null for an objective with no bound, for a request with a `made_to_measure` container + * (D13: the unused-volume key would read the machine maximum while the score + * reads the fabricated box), and for a request past a declared ceiling. The last + * withholds the bound and nothing else -- the pack is answered as it was before any + * bound existed, rather than failing with `BoundOverflowException` (finding F5). + * + * @param list $instances every instance of the request, fixed ones included + * @param list $containers + * @return list|null + */ + public static function forRequest(array $instances, array $containers, string $objective): ?array + { + $order = ObjectiveBoundScope::order($containers, $objective); + if ($order === null) { + return null; + } + try { + $vector = self::compute($instances, $containers); + } catch (BoundOverflowException $refused) { + return null; + } + return array_map(static fn(int $position): int => $vector[$position], $order); + } + + /** + * The `optimality` fields one result states against its request's bound + * (OPTIMALITY-GAP.md § 1.1), or null where § 1.5 withholds them. + * + * `$bound` is in the result's own score order. The prefix runs through the first position + * the score misses, or is the whole bound when nothing is missed -- the attained case, + * which carries no gap at all rather than a zero one. A score below its bound means the + * bound is wrong, and an engine must not publish a claim it has just refuted, so nothing + * is stated. At most five comparisons, `O(1)`. + * + * @param list $score + * @param list $bound + * @return array|null + */ + public static function statement(array $score, array $bound): ?array + { + foreach ($bound as $index => $least) { + $achieved = $score[$index]; + if ($achieved < $least) { + return null; + } + if ($achieved > $least) { + $difference = BigInt::subtract((string)$achieved, (string)$least); + if (BigInt::compare($difference, self::MAX_BOUND_VALUE) > 0) { + return null; + } + $absolute = (int)$difference; + $fields = [ + 'lower_bound' => array_slice($bound, 0, $index + 1), + 'gap_key' => $index, + 'absolute_gap' => $absolute, + ]; + if ($least > 0) { + // Unreduced, so the pair reads as (score - bound, bound) by eye. + $fields['relative_gap'] = ['numerator' => $absolute, 'denominator' => $least]; + } + return $fields; + } + } + return ['lower_bound' => $bound]; + } + /** * Refuse a sum past the declared ceiling instead of carrying it further. * @@ -81,6 +163,12 @@ private static function guard(string $total, string $quantity): string return $total; } + /** A capacity total is guarded only when it is a number: an unbounded one is not. */ + private static function guardFinite(?string $total, string $quantity): ?string + { + return $total === null ? null : self::guard($total, $quantity); + } + /** Refuse a result before PHP or JavaScript can round it on a binding boundary. */ private static function guardOutput(string $value, string $quantity): int { @@ -96,7 +184,7 @@ private static function guardOutput(string $value, string $quantity): int /** * Every bound for one request, in the default objective's key order. * - * @param list $instances quantity-expanded, exactly as the solver sees them + * @param list $instances quantity-expanded, free and fixed alike * @param list $containers the types the request permits * @return array{int,int,int,int,int} */ @@ -149,39 +237,10 @@ public static function fromNormalised(array $instances, array $containers): arra $weights[] = $instance['weight']; $shrinks = $shrinks || $instance['shrinks']; } - // Native comparison while both sides fit one, `BigInt` only when they do not. The - // sort is the dominant cost at scale -- ten thousand instances is about 133,000 - // comparisons -- and PHP's own string order is not an option here, where "9" would - // sort after "10". - usort($volumes, static function (string $a, string $b): int { - if (strlen($a) <= 18 && strlen($b) <= 18) { - return (int)$a <=> (int)$b; - } - return BigInt::compare($a, $b); - }); + self::sortVolumes($volumes); sort($weights); $count = count($instances); - // The a-priori check, once, on the way in. Every later product is bounded by these - // totals times `PPM`, so guarding them here is what makes the rest safe by derivation - // rather than by hoping each step stays small. - self::guard(self::sumBig($volumes), 'instance volume'); - self::guard(self::sumBig(array_map(strval(...), $weights)), 'instance weight'); - self::guard( - self::sumBig(array_map( - static fn(array $c): string => BigInt::multiply($c['usable'], (string)($c['quantity'] ?? 1)), - $containers, - )), - 'container capacity', - ); - self::guard( - self::sumBig(array_map( - static fn(array $c): string => BigInt::multiply((string)$c['cost_minor'], (string)($c['quantity'] ?? 1)), - $containers, - )), - 'opening cost', - ); - $usable = array_map(static fn(array $c): string => $c['usable'], $containers); $inner = array_map(static fn(array $c): string => $c['inner'], $containers); $areas = array_map(static fn(array $c): int => $c['base_area'], $containers); @@ -191,8 +250,21 @@ public static function fromNormalised(array $instances, array $containers): arra $quantities = array_map(static fn(array $c): ?int => $c['quantity'], $containers); $costs = array_map(static fn(array $c): int => $c['cost_minor'], $containers); - $unpacked = self::unpackedBound($volumes, $weights, $shrinks, $count, $usable, - $payloads, $slots, $quantities); + // The one refusal predicate, on the way in: the two instance sums and every + // *finite* capacity total. Every later product is bounded by these times `PPM`, so + // guarding them here makes the rest safe by derivation. Nothing the formulas never + // form is guarded -- no single type's capacity or cost, no total an unlimited type + // makes unbounded -- and the shapes are never read, so the same request is refused + // in every engine. The five results are held to the portable ceiling on the way out. + self::guard(self::sumBig($volumes), 'instance volume'); + self::guard(self::sumBig(array_map(strval(...), $weights)), 'instance weight'); + $capacities = [ + 'volume' => self::guardFinite(self::volumeCapacity($usable, $quantities), 'volume capacity'), + 'payload' => self::guardFinite(self::nativeCapacity($payloads, $quantities), 'payload capacity'), + 'slots' => self::guardFinite(self::nativeCapacity($slots, $quantities), 'slot capacity'), + ]; + + $unpacked = self::unpackedBound($volumes, $weights, $shrinks, $count, $capacities); $placed = $count - $unpacked; $opened = self::containerBound($volumes, $weights, $shrinks, $placed, $containers, $usable, $payloads, $slots); @@ -234,22 +306,17 @@ private static function occupiesLessThanItsBox(Item $item): bool * * @param list $volumes ascending * @param list $weights ascending - * @param list $usable - * @param list $payloads - * @param list $slots - * @param list $quantities + * @param array{volume:?string,payload:?string,slots:?string} $capacities null when unbounded */ private static function unpackedBound(array $volumes, array $weights, bool $shrinks, - int $count, array $usable, array $payloads, array $slots, array $quantities): int + int $count, array $capacities): int { $placeable = $count; if (!$shrinks) { - $volumeCapacity = self::volumeCapacity($usable, $quantities); - $placeable = min($placeable, self::fitBig($volumes, $volumeCapacity)); + $placeable = min($placeable, self::fitBig($volumes, $capacities['volume'])); } - $payloadCapacity = self::nativeCapacity($payloads, $quantities); - $placeable = min($placeable, self::fitNative($weights, $payloadCapacity)); - $slotCapacity = self::nativeCapacity($slots, $quantities); + $placeable = min($placeable, self::fitNative($weights, $capacities['payload'])); + $slotCapacity = $capacities['slots']; if ($slotCapacity !== null) { $placeable = min($placeable, BigInt::compare($slotCapacity, (string)$count) >= 0 ? $count : (int)$slotCapacity); @@ -306,6 +373,12 @@ private static function containerBound(array $volumes, array $weights, bool $shr * `L2`: at least `L1` containers open, each costing at least the cheapest the inventory * still holds. * + * The types are sorted by cost once and taken greedily, `min(quantity, still needed)` of + * each: the sum of the `L1` cheapest per-unit entries without building that multiset, + * `O(c log c)` rather than the `O(c * L1 log(c * L1))` of materialising and sorting up to + * `L1` copies of every type (finding F6). Ties in cost cannot change the sum, so the + * order among them does not matter. + * * @param list $costs * @param list $quantities */ @@ -314,17 +387,19 @@ private static function costBound(array $costs, array $quantities, int $opened): if ($opened <= 0) { return 0; } - $available = []; - foreach ($costs as $index => $cost) { - $quantity = $quantities[$index]; - $repeat = $quantity === null ? $opened : min($quantity, $opened); - for ($taken = 0; $taken < $repeat; $taken++) { - $available[] = $cost; + $cheapestFirst = array_keys($costs); + usort($cheapestFirst, static fn(int $a, int $b): int => $costs[$a] <=> $costs[$b]); + $needed = $opened; + $selected = '0'; + foreach ($cheapestFirst as $index) { + if ($needed <= 0) { + break; } + $quantity = $quantities[$index]; + $taken = $quantity === null ? $needed : min($quantity, $needed); + $selected = BigInt::add($selected, BigInt::multiply((string)$costs[$index], (string)$taken)); + $needed -= $taken; } - sort($available); - $selected = self::sumBig(array_map(strval(...), array_slice($available, 0, $opened))); - self::guard($selected, 'opening cost'); return self::guardOutput($selected, 'opening cost'); } @@ -459,14 +534,47 @@ private static function nativeCapacity(array $values, array $quantities): ?strin continue; } $total = BigInt::add($total, BigInt::multiply((string)$value, (string)$quantity)); - self::guard($total, 'container capacity'); } return $total; } + /** + * Ascending decimal strings, in numeric order. + * + * PHP's own string order is not an option, where "9" would sort after "10". When every + * volume fits a native integer -- a 10 mm cube is 4.1e15 cubic ticks -- they are sorted + * as integers in C and written back as the same decimal strings. Measured on 51,200 + * volumes: 17 ms, against 78 ms for the comparator callback and 182 ms for + * `SORT_NUMERIC`, which re-parses both strings on every comparison. Only a volume wider + * than a native integer needs the `BigInt` comparator. + * + * @param list $volumes + */ + private static function sortVolumes(array &$volumes): void + { + $widest = $volumes === [] ? 0 : max(array_map(strlen(...), $volumes)); + if ($widest <= 18) { + $native = array_map(intval(...), $volumes); + sort($native); + $volumes = array_map(strval(...), $native); + return; + } + usort($volumes, static function (string $a, string $b): int { + if (strlen($a) <= 18 && strlen($b) <= 18) { + return (int)$a <=> (int)$b; + } + return BigInt::compare($a, $b); + }); + } + /** * The largest `n` such that the `n` smallest values sum to at most the capacity. * + * The running total is `$flushed + $native`: native additions until one more would + * overflow, then one `BigInt` addition that folds the chunk in. The capacity test stays + * native too, against the room the capacity leaves above `$flushed`, so a long run of + * small values costs native work rather than a string addition and comparison each. + * * @param list $ascending */ private static function fitBig(array $ascending, ?string $capacity): int @@ -474,30 +582,42 @@ private static function fitBig(array $ascending, ?string $capacity): int if ($capacity === null) { return count($ascending); } - $used = '0'; + $flushed = '0'; + $native = 0; + $room = self::nativeRoom($capacity, $flushed); foreach ($ascending as $taken => $value) { - $used = BigInt::add($used, $value); - if (BigInt::compare($used, $capacity) > 0) { + if (strlen($value) > 18 || $native > PHP_INT_MAX - (int)$value) { + $flushed = BigInt::add(BigInt::add($flushed, (string)$native), $value); + $native = 0; + if (BigInt::compare($flushed, $capacity) > 0) { + return $taken; + } + $room = self::nativeRoom($capacity, $flushed); + continue; + } + $native += (int)$value; + if ($room !== null && $native > $room) { return $taken; } } return count($ascending); } + /** + * `capacity - flushed` as a native integer, or null when it exceeds every native total. + * + * The caller guarantees `flushed <= capacity`. + */ + private static function nativeRoom(string $capacity, string $flushed): ?int + { + $room = BigInt::subtract($capacity, $flushed); + return BigInt::compare($room, (string)PHP_INT_MAX) <= 0 ? (int)$room : null; + } + /** @param list $ascending */ private static function fitNative(array $ascending, ?string $capacity): int { - if ($capacity === null) { - return count($ascending); - } - $used = '0'; - foreach ($ascending as $taken => $value) { - $used = BigInt::add($used, (string)$value); - if (BigInt::compare($used, $capacity) > 0) { - return $taken; - } - } - return count($ascending); + return self::fitBig(array_map(strval(...), $ascending), $capacity); } /** @@ -526,39 +646,34 @@ private static function finiteMax(array $values): ?int * The same trade `HullShape::axisFitsNatively` makes, and for the same reason: string * arithmetic is exact and slow, so it should be reached for when it is needed rather * than always. A single volume fits a 64-bit integer comfortably -- a 10 mm cube is - * 4.1e15 cubic ticks -- and only the running total can leave that range, at around two - * thousand such items. So the loop adds natively until one more addend would overflow, - * and only then switches to `BigInt` for the remainder. + * 4.1e15 cubic ticks -- and only the running total leaves that range, at around two + * thousand such items. So values are added natively in chunks, and a chunk is folded + * into the `BigInt` total only when one more addend would overflow it: one string + * addition per ~2^63 of total rather than one per value past the first overflow, which + * is what the bound computed on every pack pays at scale. * - * Measured on ten thousand instances: 459 ms before, and the exactness is unchanged -- - * the switch happens on the value that would have wrapped, not after it. + * The exactness is unchanged: a chunk is folded before the value that would wrap it is + * added, and a value too wide for `(int)` goes straight to `BigInt`. * * @param list $values */ private static function sumBig(array $values): string { + $total = '0'; $native = 0; - $index = 0; - $count = count($values); - for (; $index < $count; $index++) { - $value = $values[$index]; - // A value too wide for a native integer, or a total that would pass one, ends the - // fast path. `is_numeric` guards nothing here -- these are engine-produced decimal - // strings -- but the width check is what keeps `(int)` from silently truncating. + foreach ($values as $value) { if (strlen($value) > 18) { - break; + $total = BigInt::add($total, $value); + continue; } $addend = (int)$value; if ($native > PHP_INT_MAX - $addend) { - break; + $total = BigInt::add($total, (string)$native); + $native = 0; } $native += $addend; } - $total = (string)$native; - for (; $index < $count; $index++) { - $total = BigInt::add($total, $values[$index]); - } - return $total; + return BigInt::add($total, (string)$native); } /** @param list $values */ diff --git a/src/Algorithm/SearchStats.php b/src/Algorithm/SearchStats.php index 4b2bb69..a418f46 100644 --- a/src/Algorithm/SearchStats.php +++ b/src/Algorithm/SearchStats.php @@ -11,21 +11,6 @@ final class SearchStats public int $supportChecks=0; public int $spacePartitions=0; public int $searchNodesExpanded=0; - /** - * The request-level lower bound on the objective vector, computed once per start at the - * root of an `exact_small` or global-beam solve. - * - * Deliberately absent from `metrics()`: `algorithm.metrics` is serialised into every - * result, so a key there changes the bytes of every committed golden and has to land in - * all four engines at once. Reporting a gap to a caller is also a new public result - * field, which this project reserves and rejects before a contract freeze rather than - * adding mid-line. `null` when no bound was computed -- a non-default objective keys its - * score vector differently, so a bound compared against it would compare different - * quantities. - * - * @var array{int,int,int,int,int}|null - */ - public ?array $objectiveLowerBound=null; public function metrics():SolverMetrics { diff --git a/src/Algorithm/SolverOrchestrator.php b/src/Algorithm/SolverOrchestrator.php index f321faf..b0a2351 100644 --- a/src/Algorithm/SolverOrchestrator.php +++ b/src/Algorithm/SolverOrchestrator.php @@ -106,20 +106,11 @@ private function solveSequential(array $items,array $containers,PackingConfig $c foreach($starts as $position=>[$solver,$orderName,$order]){ if($deadline->expired())break; $stats=new SearchStats(); - $budget=$deadline->slice(count($starts)-$position); - if($effortBudget!==null)$budget=$budget->withEffort($effortBudget,$stats); + $budget=self::startBudget($deadline,$effortBudget,$stats,count($starts)-$position); $startConfig=str_ends_with($orderName,':greedy')?self::greedyConfig($config):$config; [$packed,$unpacked,$exhaustive,$reached,$dominantLattice]=$this->acrossContainers($solver,$order,$containers,$startConfig,$stats,$budget,$preloaded); - $effortReached=$budget->effortExceeded(); - $wallReached=$reached&&$budget->remainingNs()<=0; - if($effortReached){ - $unpacked=array_map( - static fn(UnpackedItem $item):UnpackedItem=>$item->reason==='time_limit' - ? new UnpackedItem($item->instance,'effort_limit',$item->details) - : $item, - $unpacked, - ); - } + [$wallReached,$effortReached]=self::limitsReached($reached,$budget->effortStopped(),$budget->remainingNs()); + $unpacked=self::effortLimited($unpacked,$effortReached); $id=$solver->name().':'.$orderName; $results[]=new RawSolution($id,$packed,$unpacked,$stats,$wallReached,$effortReached,$exhaustive); $records[$position]=new StartRecord($id,true,!$reached,$reached); @@ -193,19 +184,10 @@ private function solveConcurrent(array $items,array $containers,PackingConfig $c [$solver0,$orderName0,$order0]=$starts[0]; $stats0=new SearchStats(); - $budget0=$deadline->slice(count($starts)); - if($effortBudget!==null)$budget0=$budget0->withEffort($effortBudget,$stats0); + $budget0=self::startBudget($deadline,$effortBudget,$stats0,count($starts)); [$packed0,$unpacked0,$exhaustive0,$reached0,$dominantLattice0]=$this->acrossContainers($solver0,$order0,$containers,$config,$stats0,$budget0,$preloaded); - $effortReached0=$budget0->effortExceeded(); - $wallReached0=$reached0&&$budget0->remainingNs()<=0; - if($effortReached0){ - $unpacked0=array_map( - static fn(UnpackedItem $item):UnpackedItem=>$item->reason==='time_limit' - ? new UnpackedItem($item->instance,'effort_limit',$item->details) - : $item, - $unpacked0, - ); - } + [$wallReached0,$effortReached0]=self::limitsReached($reached0,$budget0->effortStopped(),$budget0->remainingNs()); + $unpacked0=self::effortLimited($unpacked0,$effortReached0); $id0=$solver0->name().':'.$orderName0; $results[0]=new RawSolution($id0,$packed0,$unpacked0,$stats0,$wallReached0,$effortReached0,$exhaustive0); $records[0]=new StartRecord($id0,true,!$reached0,$reached0); @@ -242,7 +224,7 @@ private function solveConcurrent(array $items,array $containers,PackingConfig $c // process's own fork running the exact same code, never from // external/attacker-controlled input, so the default (allow // every class) `unserialize()` on the parent's side is safe. - fwrite($pair[1],serialize([$packed,$unpacked,$exhaustive,$reached,$stats])); + fwrite($pair[1],serialize([$packed,$unpacked,$exhaustive,$reached,$stats,$budget->effortStopped()])); fclose($pair[1]); exit(0); } @@ -255,17 +237,9 @@ private function solveConcurrent(array $items,array $containers,PackingConfig $c $data=stream_get_contents($socket); fclose($socket); pcntl_waitpid($pids[$position],$status); - [$packed,$unpacked,$exhaustive,$reached,$stats]=unserialize($data); - $effortReached=$effortBudget!==null&&$effortBudget->exceeded($stats); - $wallReached=$reached&&($absoluteDeadlineNs-hrtime(true))<=0; - if($effortReached){ - $unpacked=array_map( - static fn(UnpackedItem $item):UnpackedItem=>$item->reason==='time_limit' - ? new UnpackedItem($item->instance,'effort_limit',$item->details) - : $item, - $unpacked, - ); - } + [$packed,$unpacked,$exhaustive,$reached,$stats,$effortStopped]=unserialize($data); + [$wallReached,$effortReached]=self::limitsReached($reached,$effortStopped,$absoluteDeadlineNs-hrtime(true)); + $unpacked=self::effortLimited($unpacked,$effortReached); $id=$solver->name().':'.$orderName; $results[$position]=new RawSolution($id,$packed,$unpacked,$stats,$wallReached,$effortReached,$exhaustive); $records[$position]=new StartRecord($id,true,!$reached,$reached); @@ -284,16 +258,8 @@ private function runOneStartInProcess(SingleContainerSolver $solver,string $orde $budget=Deadline::until($absoluteDeadlineNs); if($effortBudget!==null)$budget=$budget->withEffort($effortBudget,$stats); [$packed,$unpacked,$exhaustive,$reached,]=$this->acrossContainers($solver,$order,$containers,$config,$stats,$budget,$preloaded); - $effortReached=$budget->effortExceeded(); - $wallReached=$reached&&$budget->remainingNs()<=0; - if($effortReached){ - $unpacked=array_map( - static fn(UnpackedItem $item):UnpackedItem=>$item->reason==='time_limit' - ? new UnpackedItem($item->instance,'effort_limit',$item->details) - : $item, - $unpacked, - ); - } + [$wallReached,$effortReached]=self::limitsReached($reached,$budget->effortStopped(),$budget->remainingNs()); + $unpacked=self::effortLimited($unpacked,$effortReached); $id=$solver->name().':'.$orderName; return [ new RawSolution($id,$packed,$unpacked,$stats,$wallReached,$effortReached,$exhaustive), @@ -301,6 +267,49 @@ private function runOneStartInProcess(SingleContainerSolver $solver,string $orde ]; } + /** + * The deadline one start runs under. + * + * Without an effort budget each start gets an even share of what is left, so a slow start + * cannot starve the rest. Under one the clock is only the request's fuse: every start gets + * all that is left, and the clock stops the search only as a whole, so the portfolio-wide + * deadline and `time_limit_reached` say it did. A slice would let a start the clock cut + * lose quietly, making a budgeted answer depend on the machine's speed. + */ + private static function startBudget(Deadline $deadline,?EffortBudget $effortBudget,SearchStats $stats,int $startsLeft):Deadline + { + if($effortBudget===null)return $deadline->slice($startsLeft); + return $deadline->withEffort($effortBudget,$stats); + } + + /** + * Which limit cut a start, as `[wall clock, effort budget]`: at most one, and only for a + * start that was cut. The effort budget is named only when a poll found it spent and the + * search stopped there, not when a finished start used exactly its budget. + * + * @return array{bool,bool} + */ + private static function limitsReached(bool $reached,bool $effortStopped,int $remainingNs):array + { + $effortReached=$reached&&$effortStopped; + return [$reached&&!$effortReached&&$remainingNs<=0,$effortReached]; + } + + /** + * @param list $unpacked + * @return list + */ + private static function effortLimited(array $unpacked,bool $effortReached):array + { + if(!$effortReached)return $unpacked; + return array_map( + static fn(UnpackedItem $item):UnpackedItem=>$item->reason==='time_limit' + ? new UnpackedItem($item->instance,'effort_limit',$item->details) + : $item, + $unpacked, + ); + } + private function finishPortfolio(array $items,array $containers,Deadline $deadline,array $results,array $records,array $preloaded=[]):PortfolioRun { $globalDeadline=$deadline->expired(); @@ -473,12 +482,6 @@ private function supportIsTheBlocker(ItemInstance $item,array $packed,PackingCon return false; } - /** - * Lattice nodes the block start added to `fast` and `balanced` may enumerate per mode -- - * the bound `quality` gives the same solver through `containerPlanNodeLimit`. - */ - private const BLOCK_START_NODE_LIMIT=100_000; - /** * The block start `fast` and `balanced` add to their own, or `null`. * @@ -504,7 +507,7 @@ private function blockStart(array $items,PackingConfig $config,array $containers foreach($items as $instance)if(ItemOrdering::lead($instance,$config)!=$lead)return null; // One enumeration, the one the objective ranks by: `fast` is latency-bound. $modes=!$fast?HomogeneousBlockSolver::MODES:($config->objective==='maximum_volume'?['volume']:['count']); - $solver=new HomogeneousBlockSolver([],null,$modes,self::BLOCK_START_NODE_LIMIT); + $solver=new HomogeneousBlockSolver([],null,$modes,HomogeneousBlockSolver::START_NODE_LIMIT); if($containers===[])return null; foreach($containers as $container)if(!$solver->supports($container,$items,$config))return null; return $solver; @@ -639,32 +642,6 @@ private function orders(array $items,PackingConfig $config):array } /** @return array{0:list,1:list,2:bool,3:bool,4:bool} */ - /** - * Compute the request-level lower bound once, at the root. - * - * The plan beam already computes the same relaxation per node to prune with; the - * request-level bound is that formula with the state empty. `exact_small` gets it for - * the opposite reason: it exhausts its candidate set, and a bound is what turns "I - * stopped looking" into a statement about the request. - * - * Only for the default objective -- `lowest_cost` and `maximum_value` order their score - * keys differently, so a bound vector compared against them would line cost up against - * container count. Nothing downstream reads this yet, and that is deliberate. - * - * @param list $items - * @param list $containers - */ - private static function recordRootBound(SingleContainerSolver $solver,bool $beam,array $items,array $containers,PackingConfig $config,SearchStats $stats):void - { - if($stats->objectiveLowerBound!==null)return; - if(!$beam&&$solver->name()!=='exact_small')return; - if($config->objective!=='default')return; - // Its unused-volume and height keys are ratios against a declared size, and a - // made-to-measure box has none until it is cut: the bound would not be admissible. - foreach($containers as $container)if($container->madeToMeasure!==null)return; - $stats->objectiveLowerBound=ObjectiveBounds::compute($items,$containers); - } - /** * The greedy round's key for one trial, read from the container the trial built: a * made-to-measure trial carries its fabricated box, so the key prices the box that ships, @@ -775,7 +752,6 @@ private static function fabricated(Container $declared,SingleContainerSolution $ private function acrossContainers(SingleContainerSolver $solver,array $items,array $containers,PackingConfig $config,SearchStats $stats,Deadline $deadline,array $preloaded=[]):array { $beam=$config->containerPlanBeamWidth>1&&($this->containerSelector===null||$this->containerSelector instanceof DefaultContainerSelector); - self::recordRootBound($solver,$beam,$items,$containers,$config,$stats); [$opened,$reached]=$this->openPreloaded($solver,$preloaded,$items,$containers,$config,$stats,$deadline); if($beam){ return $this->acrossContainerPlans($solver,$items,$containers,$config,$stats,$deadline,$opened,$reached); @@ -787,7 +763,7 @@ private function acrossContainers(SingleContainerSolver $solver,array $items,arr $selector=$this->containerSelector??new DefaultContainerSelector(); while($remaining!==[]&&count($packed)<$maximum&&!$reached){ if($deadline->expired()){$reached=true;break;} - $candidates=[]; + $best=null;$bestScore=null; foreach($ordered as $container){ if($inventory[$container->id]!==null&&$inventory[$container->id]<=0)continue; try{[$one]=$this->trial($solver,$container,$seq[$container->id]+1,$remaining,$config,$stats,$deadline);} @@ -795,12 +771,11 @@ private function acrossContainers(SingleContainerSolver $solver,array $items,arr if($one->timeLimitReached)$reached=true; if($one->state->placementCount()===0){if($reached)break;continue;} $candidateScore=self::roundScore($one,$remaining,$config,$selector); - $candidates[]=[$candidateScore,$one]; + if($bestScore===null||$candidateScore<$bestScore){$bestScore=$candidateScore;$best=$one;} if($reached)break; } - if($candidates===[])break; - $candidates=StableSorter::sortBy($candidates,static fn(array $candidate):array=>$candidate[0]); - $best=$candidates[0][1];$c=$best->state->container;$seq[$c->id]++; + if($best===null)break; + $c=$best->state->container;$seq[$c->id]++; $exhaustive=$exhaustive&&$best->exhaustive; $dominantLattice=$dominantLattice&&$best->dominantLattice; if($inventory[$c->id]!==null)$inventory[$c->id]--; @@ -816,8 +791,8 @@ private function acrossContainers(SingleContainerSolver $solver,array $items,arr if($reason==='no_feasible_placement')$reason=self::limitReason($i,$containers,$opened,$config)??$reason; $unpacked[]=new UnpackedItem($i,$reason,$details); } - // Optimality may only be claimed when an exhaustive search filled a single - // container — the cheapest one, since containers are tried in cost order. + // `RawSolution::$exhaustive` is a hint for extension scorers, not a proof: the result's + // `optimal` status comes only from attaining the lower bound (`Packer::statedOptimality`). $proven=$exhaustive&&!$reached&&$unpacked===[]&&count($packed)===1; return [$packed,$unpacked,$proven,$reached,$dominantLattice]; } @@ -916,12 +891,17 @@ private function acrossContainerPlans(SingleContainerSolver $solver,array $items // Numbered per container type, as the greedy path and every other engine number // them: a fixed placement names `#`, so the count must be the type's own. $beam=$reached?[]:[$initial];$incumbent=$initial;$nodes=0; + $incumbentScore=self::planScore($incumbent,$incumbent['remaining'],$config); $maximum=$config->maxContainers??array_sum(array_map(static fn(Container $container):int=>$container->quantity??count($items),$containers)); $ordered=StableSorter::sortBy($containers,static fn(Container $container):array=>[$container->costMinor,...$container->innerDimensions->volumeKey(),$container->id]); while($beam!==[]&&$nodes<$config->containerPlanNodeLimit){ $expansions=[]; foreach($beam as $plan){ - if($plan['remaining']===[]||count($plan['packed'])>=$maximum){if(self::planScore($plan,$plan['remaining'],$config)=$maximum){ + $planScore=self::planScore($plan,$plan['remaining'],$config); + if($planScore<$incumbentScore){$incumbent=$plan;$incumbentScore=$planScore;} + continue; + } foreach($ordered as $container){ if($nodes>=$config->containerPlanNodeLimit)break; if($deadline->expired()){$reached=true;break;} @@ -934,21 +914,27 @@ private function acrossContainerPlans(SingleContainerSolver $solver,array $items [$committed,$ids]=self::commitState($one->state);$child=$plan;$child['packed'][]=$committed;$child['remaining']=array_values(array_filter($plan['remaining'],static fn(ItemInstance $item):bool=>!isset($ids[$item->id()])));$child['sequences'][$container->id]++; if($child['inventory'][$container->id]!==null)$child['inventory'][$container->id]--; $child['exhaustive']=$plan['exhaustive']&&$one->exhaustive;$child['dominant']=$plan['dominant']&&$one->dominantLattice;$expansions[]=$child; - if(self::planScore($child,$child['remaining'],$config)$item->id(),$plan['remaining'])).'::'.json_encode($plan['inventory'],JSON_THROW_ON_ERROR);$previous=$dominant[$signature]??null;if($previous===null||self::planScore($plan,[],$config)$item->id(),$plan['remaining'])).'::'.json_encode($plan['inventory'],JSON_THROW_ON_ERROR); + $partialScore=self::planScore($plan,[],$config); + $previous=$dominant[$signature]??null; + if($previous===null||$partialScore<$previous[0])$dominant[$signature]=[$partialScore,$plan]; + } // planBound re-derives the additive volume/payload relaxation with BigInt // string math; one evaluation per plan instead of one per stable-usort // comparison leaves the ordering untouched. - $beam=array_slice( - StableSorter::sortBy(array_values($dominant),static fn(array $plan):array=>self::planBound($plan,$containers,$config)), - 0,$config->containerPlanBeamWidth, - ); + $survivors=array_map(static fn(array $pair):array=>$pair[1],array_values($dominant)); + $beam=count($survivors)>1 + ?array_slice(StableSorter::sortBy($survivors,static fn(array $plan):array=>self::planBound($plan,$containers,$config)),0,$config->containerPlanBeamWidth) + :$survivors; } $unpacked=[];$opened=self::openedByType($incumbent['packed']);foreach($incumbent['remaining'] as $item){[$reason,$details]=$this->unpackedReason($item,$containers,$reached);if($reason==='no_feasible_placement'&&$this->supportIsTheBlocker($item,$incumbent['packed'],$config))$reason='insufficient_support';if($reason==='no_feasible_placement')$reason=self::limitReason($item,$containers,$opened,$config)??$reason;$unpacked[]=new UnpackedItem($item,$reason,$details);} $proven=$incumbent['exhaustive']&&!$reached&&$unpacked===[]&&count($incumbent['packed'])===1; diff --git a/src/Artifacts/OperationalArtifact.php b/src/Artifacts/OperationalArtifact.php index 4eeef69..03def28 100644 --- a/src/Artifacts/OperationalArtifact.php +++ b/src/Artifacts/OperationalArtifact.php @@ -36,7 +36,7 @@ final class OperationalArtifact * The engine's own name is deliberately not recorded: four correct builders naming * themselves would emit four different documents. */ - public const SUITE_VERSION = '1.7.0'; + public const SUITE_VERSION = '1.8.0'; /** The deterministic part of `result.algorithm`; `duration_ms` is wall-clock time. */ private const SOLVER_FIELDS = ['profile', 'solver', 'seed', 'time_limit_reached', 'effort_limit_reached']; diff --git a/src/Constraint/AxleLoad.php b/src/Constraint/AxleLoad.php index 773919c..31c19ce 100644 --- a/src/Constraint/AxleLoad.php +++ b/src/Constraint/AxleLoad.php @@ -1,6 +1,7 @@ position->ticks; - $rearX = (string)$rear->position->ticks; - $span = BigInt::subtract($rearX, $frontX); // positive: enforced by Container::__construct - - $totalWeight = (string)$tareWeightTicks; - foreach ($units as $unit) { - $totalWeight = BigInt::add($totalWeight, (string)$unit->weightTicks); - } - - $doubledWeightedX = BigInt::multiply((string)$tareWeightTicks, (string)$tareDoubledCenterX); - foreach ($units as $unit) { - $doubledCenterX = (string)(2 * $unit->box->origin->x + $unit->box->dimensions->length->ticks); - $doubledWeightedX = BigInt::add($doubledWeightedX, BigInt::multiply((string)$unit->weightTicks, $doubledCenterX)); - } - - $denominator = BigInt::multiply('2', $span); - $twoTotalRearX = BigInt::multiply(BigInt::multiply('2', $totalWeight), $rearX); - $twoTotalFrontX = BigInt::multiply(BigInt::multiply('2', $totalWeight), $frontX); - return [ - 'denominator' => $denominator, - 'front_numerator' => self::signedSubtract($twoTotalRearX, $doubledWeightedX), - 'rear_numerator' => self::signedSubtract($doubledWeightedX, $twoTotalFrontX), - ]; + return AxleTotals::reactions($axles, AxleTotals::ofUnits($units, $tareWeightTicks, $tareDoubledCenterX)); } public static function exceeded(array $axles, array $units, int $tareWeightTicks=0, int $tareDoubledCenterX=0): ?array { - [$front, $rear] = $axles; - $reaction = self::reactions($axles, $units, $tareWeightTicks, $tareDoubledCenterX); - if ($front->maxLoad !== null && self::overLimit($reaction['front_numerator'], $front->maxLoad->ticks, $reaction['denominator'])) { - return ['axle_overloaded', 'front']; - } - if ($rear->maxLoad !== null && self::overLimit($reaction['rear_numerator'], $rear->maxLoad->ticks, $reaction['denominator'])) { - return ['axle_overloaded', 'rear']; - } - return null; - } - - private static function signedSubtract(string $left, string $right): string - { - $comparison = BigInt::compare($left, $right); - if ($comparison === 0) { return '0'; } - return $comparison > 0 - ? BigInt::subtract($left, $right) - : '-'.BigInt::subtract($right, $left); - } - - private static function overLimit(string $numerator, int $maxLoadTicks, string $denominator): bool - { - if (str_starts_with($numerator, '-')) { return false; } - $threshold = BigInt::multiply((string)$maxLoadTicks, $denominator); - return BigInt::compare($numerator, $threshold) > 0; + return AxleTotals::exceeded($axles, AxleTotals::ofUnits($units, $tareWeightTicks, $tareDoubledCenterX)); } /** diff --git a/src/Constraint/AxleLoadConstraint.php b/src/Constraint/AxleLoadConstraint.php index 13c934e..cc680c6 100644 --- a/src/Constraint/AxleLoadConstraint.php +++ b/src/Constraint/AxleLoadConstraint.php @@ -1,6 +1,8 @@ |null */ + private ?array $placements = null; + private ?Container $container = null; + /** @var array{0:string,1:string} */ + private array $base = ['0', '0']; + public function evaluate(ConstraintContext $c): ConstraintResult { if ($c->container->axles === null) { return ConstraintResult::allow(); } - $candidate = $c->envelopeBox(); - $units = LoadCalculator::units( - $c->placements, - new LoadUnit( - $candidate, $c->item->weight()->ticks, null, null, $c->item->id(), - $c->item->item->nestingHeight===null?null:$c->item->item->id, - $c->item->item->nestingHeight?->ticks, - ), - ); - $failure = AxleLoad::exceeded( + if ($this->placements !== $c->placements || $this->container !== $c->container) { + $this->base = AxleTotals::ofPlacements( + $c->placements, + $c->container->tareWeight->ticks, + $c->container->innerDimensions->length->ticks, + ); + $this->placements = $c->placements; + $this->container = $c->container; + } + $failure = AxleTotals::exceeded( $c->container->axles, - $units, - $c->container->tareWeight->ticks, - $c->container->innerDimensions->length->ticks, + AxleTotals::plus($this->base, $c->envelopeBox(), $c->item->weight()->ticks), ); return $failure === null ? ConstraintResult::allow() : ConstraintResult::reject($failure[0], $failure[1]); } diff --git a/src/Constraint/Internal/AxleTotals.php b/src/Constraint/Internal/AxleTotals.php new file mode 100644 index 0000000..68088e1 --- /dev/null +++ b/src/Constraint/Internal/AxleTotals.php @@ -0,0 +1,128 @@ + $placements + * @return array{0:string,1:string} gross weight, doubled weighted x + */ + public static function ofPlacements(array $placements, int $tareWeightTicks, int $tareDoubledCenterX): array + { + $totalWeight = (string)$tareWeightTicks; + $doubledWeightedX = BigInt::multiply((string)$tareWeightTicks, (string)$tareDoubledCenterX); + foreach ($placements as $placement) { + $weight = (string)$placement->instance->weight()->ticks; + $totalWeight = BigInt::add($totalWeight, $weight); + $doubledCenterX = (string)(2 * $placement->envelopeOrigin->x + $placement->envelopeDimensions->length->ticks); + $doubledWeightedX = BigInt::add($doubledWeightedX, BigInt::multiply($weight, $doubledCenterX)); + } + return [$totalWeight, $doubledWeightedX]; + } + + /** + * Tare plus every unit, each at its own box's centre. O(m). + * + * @param list $units + * @return array{0:string,1:string} gross weight, doubled weighted x + */ + public static function ofUnits(array $units, int $tareWeightTicks, int $tareDoubledCenterX): array + { + $totalWeight = (string)$tareWeightTicks; + foreach ($units as $unit) { + $totalWeight = BigInt::add($totalWeight, (string)$unit->weightTicks); + } + $doubledWeightedX = BigInt::multiply((string)$tareWeightTicks, (string)$tareDoubledCenterX); + foreach ($units as $unit) { + $doubledCenterX = (string)(2 * $unit->box->origin->x + $unit->box->dimensions->length->ticks); + $doubledWeightedX = BigInt::add($doubledWeightedX, BigInt::multiply((string)$unit->weightTicks, $doubledCenterX)); + } + return [$totalWeight, $doubledWeightedX]; + } + + /** + * `$totals` with one more box of `$weightTicks` at its centre. O(1). + * + * @param array{0:string,1:string} $totals + * @return array{0:string,1:string} + */ + public static function plus(array $totals, AxisAlignedBox $box, int $weightTicks): array + { + $weight = (string)$weightTicks; + $doubledCenterX = (string)(2 * $box->origin->x + $box->dimensions->length->ticks); + return [BigInt::add($totals[0], $weight), BigInt::add($totals[1], BigInt::multiply($weight, $doubledCenterX))]; + } + + /** + * What each axle carries, as numerators over one shared denominator. + * + * @param array{0:Axle,1:Axle} $axles + * @param array{0:string,1:string} $totals + * @return array{denominator:string,front_numerator:string,rear_numerator:string} + */ + public static function reactions(array $axles, array $totals): array + { + [$front, $rear] = $axles; + $frontX = (string)$front->position->ticks; + $rearX = (string)$rear->position->ticks; + $span = BigInt::subtract($rearX, $frontX); // positive: enforced by Container::__construct + [$totalWeight, $doubledWeightedX] = $totals; + $twoTotal = BigInt::multiply('2', $totalWeight); + return [ + 'denominator' => BigInt::multiply('2', $span), + 'front_numerator' => self::signedSubtract(BigInt::multiply($twoTotal, $rearX), $doubledWeightedX), + 'rear_numerator' => self::signedSubtract($doubledWeightedX, BigInt::multiply($twoTotal, $frontX)), + ]; + } + + /** + * The first axle over its own limit, as [code, detail], front before rear. + * + * @param array{0:Axle,1:Axle} $axles + * @param array{0:string,1:string} $totals + * @return array{0:string,1:string}|null + */ + public static function exceeded(array $axles, array $totals): ?array + { + [$front, $rear] = $axles; + $reaction = self::reactions($axles, $totals); + if ($front->maxLoad !== null && self::overLimit($reaction['front_numerator'], $front->maxLoad->ticks, $reaction['denominator'])) { + return ['axle_overloaded', 'front']; + } + if ($rear->maxLoad !== null && self::overLimit($reaction['rear_numerator'], $rear->maxLoad->ticks, $reaction['denominator'])) { + return ['axle_overloaded', 'rear']; + } + return null; + } + + private static function signedSubtract(string $left, string $right): string + { + $comparison = BigInt::compare($left, $right); + if ($comparison === 0) { return '0'; } + return $comparison > 0 + ? BigInt::subtract($left, $right) + : '-'.BigInt::subtract($right, $left); + } + + private static function overLimit(string $numerator, int $maxLoadTicks, string $denominator): bool + { + if (str_starts_with($numerator, '-')) { return false; } + $threshold = BigInt::multiply((string)$maxLoadTicks, $denominator); + return BigInt::compare($numerator, $threshold) > 0; + } +} diff --git a/src/Constraint/Internal/LoadAnalysis.php b/src/Constraint/Internal/LoadAnalysis.php index 81d39ef..76a796e 100644 --- a/src/Constraint/Internal/LoadAnalysis.php +++ b/src/Constraint/Internal/LoadAnalysis.php @@ -20,30 +20,37 @@ final class LoadAnalysis { /** @var list */ private array $units; - private LoadSupportGraph $graph; + private ?LoadSupportGraph $graph; /** @var list|null */ private ?array $topLoads=null; /** @var list>|null */ private ?array $restingAbove=null; /** @param list $units */ - public function __construct(array $units,LoadSupportGraph $graph) + public function __construct(array $units,?LoadSupportGraph $graph=null) { $this->units=$units; $this->graph=$graph; } + public function graph():LoadSupportGraph + { + return $this->graph??=new LoadSupportGraph($this->units); + } + /** @return list */ public function topLoads():array { if($this->topLoads!==null)return $this->topLoads; $count=count($this->units); if($count===0)return $this->topLoads=[]; + $graph=$this->graph(); $loads=array_fill(0,$count,0); - $order=$this->graph->descendingIndices(); + $order=$graph->descendingIndices(); foreach($order as $upperIndex){ - $supports=$this->graph->supporters($upperIndex); - $totalArea=array_sum(array_map(static fn(ContactEdge $edge):int=>$edge->area,$supports)); + $supports=$graph->supporters($upperIndex); + $totalArea=0; + foreach($supports as $edge)$totalArea+=$edge->area; if($totalArea===0)continue; $downward=$this->units[$upperIndex]->weightTicks+$loads[$upperIndex]; $assigned=0;$last=count($supports)-1; @@ -105,10 +112,12 @@ public function restingAbove():array { if($this->restingAbove!==null)return $this->restingAbove; $count=count($this->units); - $sets=$count===0?[]:array_fill(0,$count,[]); - foreach($this->graph->descendingIndices() as $index){ + if($count===0)return $this->restingAbove=[]; + $graph=$this->graph(); + $sets=array_fill(0,$count,[]); + foreach($graph->descendingIndices() as $index){ $result=[]; - foreach($this->graph->children($index) as $child)$result+=[$child=>true]+$sets[$child]; + foreach($graph->children($index) as $child)$result+=[$child=>true]+$sets[$child]; $sets[$index]=$result; } return $this->restingAbove=$sets; @@ -134,13 +143,34 @@ public static function routed(array $stops):bool */ public function routeOrderViolated(array $stops):?array { - foreach($this->restingAbove() as $index=>$above){ + $count=count($this->units); + if($count===0)return null; + $graph=$this->graph(); + $maxStopAbove=array_fill(0,$count,-INF); + foreach($graph->descendingIndices() as $index){ + $best=-INF; + foreach($graph->children($index) as $child){ + $childStop=$stops[$child]; + if($childStop>$best)$best=$childStop; + $childAbove=$maxStopAbove[$child]; + if($childAbove>$best)$best=$childAbove; + } + $maxStopAbove[$index]=$best; + } + foreach($stops as $index=>$stop){ + if($maxStopAbove[$index]<=$stop)continue; // Ascending, as Python iterates: the detail names the first violator (S16). - $uppers=array_keys($above); - sort($uppers); - foreach($uppers as $upper) - if($stops[$upper]>$stops[$index]) - return ['unloading_order_violation',$this->units[$index]->label.' blocked by '.$this->units[$upper]->label]; + $seen=[]; + $pending=$graph->children($index); + $upperMin=null; + while($pending!==[]){ + $upper=array_pop($pending); + if(isset($seen[$upper]))continue; + $seen[$upper]=true; + if($stops[$upper]>$stop&&($upperMin===null||$upper<$upperMin))$upperMin=$upper; + foreach($graph->children($upper) as $child)$pending[]=$child; + } + return ['unloading_order_violation',$this->units[$index]->label.' blocked by '.$this->units[$upperMin]->label]; } return null; } diff --git a/src/Constraint/LoadCalculator.php b/src/Constraint/LoadCalculator.php index 6ad833f..bc5160d 100644 --- a/src/Constraint/LoadCalculator.php +++ b/src/Constraint/LoadCalculator.php @@ -40,13 +40,13 @@ public static function units(array $placements,?LoadUnit $extra=null):array */ public static function topLoads(array $units):array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->topLoads(); + return (new LoadAnalysis($units))->topLoads(); } /** First unit whose bearing limit is exceeded, as [code, detail]. @param list $units @return array{0:string,1:string}|null */ public static function overloaded(array $units):?array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->overloaded(); + return (new LoadAnalysis($units))->overloaded(); } /** @@ -61,7 +61,7 @@ public static function overloaded(array $units):?array */ public static function crushed(array $units):?array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->crushed(); + return (new LoadAnalysis($units))->crushed(); } /** @@ -75,13 +75,13 @@ public static function crushed(array $units):?array */ public static function restingAbove(array $units):array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->restingAbove(); + return (new LoadAnalysis($units))->restingAbove(); } /** Items resting anywhere above each unit. A count, not a weight. @param list $units @return list */ public static function stackedCounts(array $units):array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->stackedCounts(); + return (new LoadAnalysis($units))->stackedCounts(); } /** @@ -101,13 +101,13 @@ public static function stackedCounts(array $units):array public static function routeOrderViolated(array $units,array $stops):?array { if(!LoadAnalysis::routed($stops))return null; - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->routeOrderViolated($stops); + return (new LoadAnalysis($units))->routeOrderViolated($stops); } /** First unit whose stacked-item limit is exceeded, as [code, detail]. @param list $units @return array{0:string,1:string}|null */ public static function stackLimitExceeded(array $units):?array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->stackLimitExceeded(); + return (new LoadAnalysis($units))->stackLimitExceeded(); } /** @@ -126,6 +126,6 @@ public static function stackLimitExceeded(array $units):?array */ public static function stackDensityExceeded(array $units,?int $maxDensityTicks):?array { - return (new LoadAnalysis($units,new LoadSupportGraph($units)))->stackDensityExceeded($maxDensityTicks); + return (new LoadAnalysis($units))->stackDensityExceeded($maxDensityTicks); } } diff --git a/src/Constraint/SupportConstraint.php b/src/Constraint/SupportConstraint.php index 0959737..0f2bddb 100644 --- a/src/Constraint/SupportConstraint.php +++ b/src/Constraint/SupportConstraint.php @@ -54,6 +54,10 @@ public function evaluate(ConstraintContext $c):ConstraintResult $baseArea=$c->envelopeDimensions->baseAreaTicks(); if($area50% of the base already + // encloses the centroid and needs no hull check. + if($area*2>$baseArea)return ConstraintResult::allow(); // Area alone is not stability: a candidate can clear the ratio // while overhanging its own centre of gravity, which the ratio check above // cannot see. Checked only once a minimum ratio is already being enforced, diff --git a/src/Constraint/VolumeReserve.php b/src/Constraint/VolumeReserve.php index 37ef6de..0a1c186 100644 --- a/src/Constraint/VolumeReserve.php +++ b/src/Constraint/VolumeReserve.php @@ -14,6 +14,9 @@ final class VolumeReserve { public static function reserved(Container $container):string { + // Nothing set aside is the common case, and answering it here keeps the request-level + // bound from loading SupportConstraint for its ratio scale alone. + if($container->voidFillReserveRatio===0.0)return '0'; $scaled=(string)SupportConstraint::scaled($container->voidFillReserveRatio); return BigInt::divide(BigInt::multiply($container->innerDimensions->volumeString(),$scaled),'1000000'); } diff --git a/src/Packer.php b/src/Packer.php index 9585f46..4f131f5 100644 --- a/src/Packer.php +++ b/src/Packer.php @@ -3,7 +3,7 @@ namespace Packvium; -use Packvium\Algorithm\{Deadline, FixedPlacementAdmission, SolverOrchestrator}; +use Packvium\Algorithm\{Deadline, FixedPlacementAdmission, ObjectiveBoundScope, ObjectiveBounds, SolverOrchestrator}; use Packvium\Support\{RequestObjects, StableSorter, UnsupportedPlacements}; use Packvium\Config\PackingConfig; use Packvium\Domain\{Container, FixedPlacement, Item, PackingRequest, UnratedWeightException}; @@ -13,6 +13,7 @@ AlgorithmReport, PackingResult, PackingStatus, + ResultFact, TerminationSummary }; use Packvium\Validation\IndependentSolutionValidator; @@ -26,12 +27,71 @@ public function __construct( private readonly ?\Closure $clock = null, ) {} + /** + * The request's lower bound in the objective's score order, or null. + * + * Over every instance, fixed placements included. A custom solution scorer + * ranks by positions the bound knows nothing about, so it gets none. + * + * @return list|null + */ + private function objectiveBound(PackingRequest $request): ?array + { + if ($this->solutionScorer !== null + || ObjectiveBoundScope::order($request->containers, $this->config->objective) === null + ) { + return null; + } + return ObjectiveBounds::forRequest($request->instances(), $request->containers, + $this->config->objective); + } + + /** + * The status and `optimality` fact one ranked result states (OPTIMALITY-GAP.md § 1.3). + * + * Attainment is the only route to `optimal`: a complete result whose score meets the bound + * on every position is proven, and nothing else is -- an exhausted candidate set covers + * only the points it generated. A null fact keeps the one derived from the status alone, + * which is every result the bound does not speak for (§ 1.5). + * + * @param list $score + * @param list|null $objectiveBound + * @return array{0:PackingStatus,1:?ResultFact} + */ + private static function statedOptimality(PackingStatus $status, array $score, ?array $objectiveBound, bool $complete): array + { + if ($objectiveBound === null || $status === PackingStatus::InvalidResult) { + return [$status, null]; + } + $fields = ObjectiveBounds::statement($score, $objectiveBound); + if ($fields === null) { + return [$status, null]; + } + if ($complete && !array_key_exists('gap_key', $fields)) { + $status = PackingStatus::Optimal; + } + $code = $status === PackingStatus::Optimal ? 'proven_optimal' : ($complete ? 'not_proven' : 'best_found'); + return [$status, new ResultFact($code, $fields)]; + } + /** @param list $items @param list $containers @param list $fixedPlacements */ public function pack(array $items, array $containers, array $fixedPlacements = []): 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. RequestObjects::check($items, $containers, $this->config, $fixedPlacements); + return $this->solve($items, $containers, $fixedPlacements); + } + + /** + * The pack once the request's rules have passed. Private, so the public entry always + * checks; `ArrayCodec` reaches it after `RequestRules`, which refuses everything + * `RequestObjects` would. + * + * @param list $items @param list $containers @param list $fixedPlacements + */ + private function solve(array $items, array $containers, array $fixedPlacements): PackingResult + { $request = new PackingRequest($items, $containers, $fixedPlacements); // Both cost objectives price the same billed weight, so both need the divisor. // There is no library-chosen default: a wrong guess would silently misprice @@ -66,6 +126,10 @@ public function pack(array $items, array $containers, array $fixedPlacements = [ $this->config->clearance, $this->config->maxContainers, ); + // Once per pack, from the request alone and before the first solver runs, so that + // computing the bound never depends on which solver won, how many starts ran or + // how much of the deadline was left (OPTIMALITY-GAP.md F3). + $objectiveBound = $this->objectiveBound($request); $deadline = Deadline::ofMilliseconds($this->config->timeLimitMs, $this->clock); $orchestrator = new SolverOrchestrator( $this->extensions->placementConstraints, @@ -89,9 +153,7 @@ public function pack(array $items, array $containers, array $fixedPlacements = [ $score = $scorer->score($raw)->components; $status = ($raw->unpacked !== [] && $raw->timeLimitReached) ? PackingStatus::TimeLimit - : ($raw->unpacked !== [] - ? PackingStatus::BestFound - : ($raw->exhaustive ? PackingStatus::Optimal : PackingStatus::Feasible)); + : ($raw->unpacked !== [] ? PackingStatus::BestFound : PackingStatus::Feasible); $warnings = []; if ($this->config->validateResult) { // Independent validation re-derives every guarantee from per-item @@ -140,6 +202,7 @@ public function pack(array $items, array $containers, array $fixedPlacements = [ } $aggregateTimedOut = $termination->attributes['global_deadline_reached'] || $raw->timeLimitReached; + [$status, $optimality] = self::statedOptimality($status, $score, $objectiveBound, $raw->unpacked === []); $ranked[] = new PackingResult( $status, $raw->containers, @@ -158,6 +221,7 @@ public function pack(array $items, array $containers, array $fixedPlacements = [ $score, $warnings, termination: $termination, + optimality: $optimality, objective: $this->config->objective, ); } diff --git a/src/Sequence/LoadingDependencyGraph.php b/src/Sequence/LoadingDependencyGraph.php index f27a790..d822c26 100644 --- a/src/Sequence/LoadingDependencyGraph.php +++ b/src/Sequence/LoadingDependencyGraph.php @@ -218,11 +218,12 @@ public static function verifyLoadingPrefixBusinessRules(array $placements, array foreach ($order as $step => $index) { $present[] = $placements[$index]; $units = LoadCalculator::units($present); - $problem = LoadCalculator::overloaded($units) - ?? LoadCalculator::stackLimitExceeded($units) - ?? LoadCalculator::stackDensityExceeded($units, $maxDensityTicks); + $analysis = new \Packvium\Constraint\Internal\LoadAnalysis($units); + $problem = $analysis->overloaded() + ?? $analysis->stackLimitExceeded() + ?? $analysis->stackDensityExceeded($maxDensityTicks); if ($problem === null) { - $counts = LoadCalculator::stackedCounts($units); + $counts = $analysis->stackedCounts(); foreach ($units as $unitIndex => $unit) { if (!$present[$unitIndex]->instance->item->stackable && $counts[$unitIndex] > 0) { $problem = ['non_stackable_item_has_load', $unit->label]; @@ -232,7 +233,7 @@ public static function verifyLoadingPrefixBusinessRules(array $placements, array } if ($problem === null && $grouped) { $last = count($present) - 1; - $problem = (new LoadSupportGraph($units))->nonStackableFailure($present, $present[$last]->instance, $last); + $problem = $analysis->graph()->nonStackableFailure($present, $present[$last]->instance, $last); } if ($problem === null) { $candidate = $present[count($present) - 1]; diff --git a/src/Sequence/UnloadingDependencyGraph.php b/src/Sequence/UnloadingDependencyGraph.php index 1dc4f7a..f22fdf0 100644 --- a/src/Sequence/UnloadingDependencyGraph.php +++ b/src/Sequence/UnloadingDependencyGraph.php @@ -88,18 +88,13 @@ public static function safeRemovalOrder(array $boxes, Dimensions $container, arr $present = array_fill_keys(array_keys($boxes), true); $order = []; while ($present !== []) { - $unblocked = []; + $chosen = null; foreach (array_keys($present) as $index) { $blockedBySupport = false; foreach (array_keys($dependsOn[$index] ?? []) as $dependency) { if (isset($present[$dependency])) { $blockedBySupport = true; break; } } if ($blockedBySupport) { continue; } - $unblocked[] = $index; - } - sort($unblocked); - $chosen = null; - foreach ($unblocked as $index) { if (SequenceGeometry::clearDirection($index, $boxes[$index], $boxes, $present, $container, $directions) !== null) { $chosen = $index; break; @@ -162,18 +157,13 @@ public static function safeRouteRemovalOrder(array $boxes, array $stops, Dimensi if ($stops[$index] === $stop) { $due[$index] = true; } } while ($due !== []) { - $unblocked = []; + $chosen = null; foreach (array_keys($due) as $index) { $blockedBySupport = false; foreach (array_keys($dependsOn[$index] ?? []) as $dependency) { if (isset($remaining[$dependency])) { $blockedBySupport = true; break; } } if ($blockedBySupport) { continue; } - $unblocked[] = $index; - } - sort($unblocked); - $chosen = null; - foreach ($unblocked as $index) { if (SequenceGeometry::clearDirection($index, $boxes[$index], $boxes, $remaining, $container, $directions) !== null) { $chosen = $index; break; diff --git a/src/Serialization/ArrayCodec.php b/src/Serialization/ArrayCodec.php index 8e3db02..893e3c4 100644 --- a/src/Serialization/ArrayCodec.php +++ b/src/Serialization/ArrayCodec.php @@ -9,6 +9,7 @@ use Packvium\Extension\ExtensionRegistry; use Packvium\Packer; use Packvium\Policy\PolicyRuleSet; +use Packvium\Result\PackingResult; use Packvium\Support\{FixedPlacementShape,JsonValue,MeasureReading,ObjectRefusal,RequestRules}; use Packvium\Unit\{Length,Weight}; use Throwable; @@ -183,11 +184,25 @@ public static function pack($data):array catch(InvalidArgumentException|TypeError|ValueError|ArithmeticError $error){throw self::asRequestError($error);} $unit=(string)($data['units']['length']??'mm'); $out=$data['output']??[]; - $result=(new Packer($config,$extensions))->pack($items,$containers,$fixed)->toArray($out['length_unit']??self::outputLengthCode($unit),$out['weight_unit']??'g'); + $result=self::solve(new Packer($config,$extensions),$items,$containers,$fixed)->toArray($out['length_unit']??self::outputLengthCode($unit),$out['weight_unit']??'g'); $result['catalog_versions_used']=$references; return $result; } + /** + * `Packer::pack` without its object check. `RequestRules::check` has passed this request, + * and `RequestObjects` refuses only what those rules refuse, so the second walk could never + * fire here. The solve stays private to `Packer`, keeping its public `pack` the + * checked entry for object-built requests; the closure borrows its scope. + * + * @param list $items @param list $containers @param list $fixed + */ + private static function solve(Packer $packer,array $items,array $containers,array $fixed):PackingResult + { + $solve=\Closure::bind(static fn(Packer $packer):PackingResult=>$packer->solve($items,$containers,$fixed),null,Packer::class); + return $solve($packer); + } + /** * The request's model, in the reference's order: configuration, catalog references, * policy, items, containers, fixed placements. @@ -209,7 +224,7 @@ private static function model(array $data):array $extensions=new ExtensionRegistry(PolicyRuleSet::fromArray($data['policy']??null)->constraints()); $items=array_map(fn($r)=>self::item($r,$unit),$data['items']); $containers=array_map(fn($r)=>self::container($r,$unit),$data['containers']); - $fixed=array_map(fn($r)=>self::fixedPlacement($r,$unit),FixedPlacementShape::requireEntries($data['fixed_placements']??null,$unit)); + $fixed=self::fixedPlacements($data['fixed_placements']??null,$unit); return [$config,$references,$extensions,$items,$containers,$fixed]; } @@ -290,6 +305,19 @@ private static function hullVertices(?array $raw,string $unit):?array } private static function point(array $r,string $unit):Point{return new Point(Length::parse($r['x']??0,$unit)->ticks,Length::parse($r['y']??0,$unit)->ticks,Length::parse($r['z']??0,$unit)->ticks);} private static function box(array $r,string $unit):AxisAlignedBox{return new AxisAlignedBox(self::point($r['origin']??[],$unit),Dimensions::fromArray($r['dimensions'],$unit));} + /** + * Absent or null is none. Answered here, so that a request without fixed placements never + * compiles their shape checker. O(f) for f entries. + * + * @param mixed $raw + * @return list + */ + private static function fixedPlacements($raw,string $unit):array + { + if($raw===null)return []; + return array_map(fn($r)=>self::fixedPlacement($r,$unit),FixedPlacementShape::requireEntries($raw,$unit)); + } + /** * One entry `FixedPlacementShape` has already admitted, so every read here is exact: the * names are strings, the orientation a code, the instance an integer by value. diff --git a/src/Support/CanonicalJson.php b/src/Support/CanonicalJson.php index e7777b9..4c518db 100644 --- a/src/Support/CanonicalJson.php +++ b/src/Support/CanonicalJson.php @@ -29,8 +29,8 @@ */ final class CanonicalJson { - /** The largest magnitude every engine holds exactly. */ - public const MAX_EXACT_MAGNITUDE = 9007199254740991; + /** The largest magnitude every engine holds exactly; {@see JsonValue} owns it. */ + public const MAX_EXACT_MAGNITUDE = JsonValue::MAX_EXACT_MAGNITUDE; /** * @param mixed $value diff --git a/src/Support/FixedPlacementShape.php b/src/Support/FixedPlacementShape.php index fad8cdb..2e939c5 100644 --- a/src/Support/FixedPlacementShape.php +++ b/src/Support/FixedPlacementShape.php @@ -24,7 +24,7 @@ final class FixedPlacementShape private const AXES = ['x', 'y', 'z']; /** - * The entries as JSON gave them; absent or null is none. O(f) for f entries. + * The entries as JSON gave them. O(f) for f entries; an absent list never reaches here. * * Entries arrive from `json_decode(..., true)`, where `{}` is the empty array, so an empty * array stands for an empty object wherever an object is required. @@ -35,9 +35,6 @@ final class FixedPlacementShape */ public static function requireEntries($raw, string $unit = 'mm'): array { - if ($raw === null) { - return []; - } if (!JsonValue::isList($raw)) { throw self::malformed('fixed_placements is a list', '/fixed_placements'); } diff --git a/src/Support/JsonValue.php b/src/Support/JsonValue.php index ed6065e..0f27fbc 100644 --- a/src/Support/JsonValue.php +++ b/src/Support/JsonValue.php @@ -15,6 +15,12 @@ */ final class JsonValue { + /** + * The largest magnitude every engine holds exactly. Kept here, not in `CanonicalJson`, so + * that reading a request's integers never compiles the canonical writer. + */ + public const MAX_EXACT_MAGNITUDE = 9007199254740991; + /** @param mixed $value */ public static function isList($value): bool { @@ -44,10 +50,10 @@ public static function isList($value): bool public static function integer($value): ?int { if (\is_int($value)) { - return \abs($value) <= CanonicalJson::MAX_EXACT_MAGNITUDE ? $value : null; + return \abs($value) <= self::MAX_EXACT_MAGNITUDE ? $value : null; } if (!\is_float($value) || !\is_finite($value) || \floor($value) !== $value - || \abs($value) > CanonicalJson::MAX_EXACT_MAGNITUDE) { + || \abs($value) > self::MAX_EXACT_MAGNITUDE) { return null; } return (int) $value; diff --git a/src/Support/RequestRules.php b/src/Support/RequestRules.php index b5748f6..b56a7df 100644 --- a/src/Support/RequestRules.php +++ b/src/Support/RequestRules.php @@ -49,6 +49,8 @@ final class RequestRules 'solver_profile', 'solvers', 'time_limit_ms', ]; private const SIDES = ['length', 'width', 'height']; + /** The item fields the request schema types `boolean`, in schema order. */ + private const ITEM_BOOLEANS = ['keep_upright', 'stackable', 'must_be_on_floor']; /** Container fields a flat bag refuses, in reporting order (docs/DEFORMABLE-CONTAINERS.md). */ public const BAG_EXCLUSIONS = ['obstacles', 'axles', 'access_directions', 'max_stack_density', 'outer_dimensions', 'void_fill_reserve_ratio', 'made_to_measure']; @@ -135,6 +137,7 @@ private static function configuration($raw, string $unit): void } self::optionalRatio($configuration, 'minimum_support_ratio', $where, 1); self::optionalMeasure($configuration, 'clearance', $where, Length::class, $unit); + self::optionalBoolean($configuration, 'require_placement_coordinates', $where); $effort = JsonValue::get($configuration, 'effort_budget'); if ($effort !== null) { $budget = self::objectAt($effort, "{$where}/effort_budget"); @@ -193,6 +196,9 @@ private static function item($raw, string $where, string $unit): void self::optionalEachOneOf($item, 'allowed_rotations', $where, self::ROTATIONS); self::optionalOneOf($item, 'ground_contact_rule', $where, self::GROUND_CONTACT_RULES); self::optionalOneOf($item, 'shape_type', $where, self::SHAPE_TYPES); + foreach (self::ITEM_BOOLEANS as $name) { + self::optionalBoolean($item, $name, $where); + } } /** @param mixed $value */ @@ -606,6 +612,19 @@ private static function optionalInteger($object, string $name, string $where, in } } + /** + * Only JSON `true`/`false`: a `(bool)` cast would pack `"no"` as yes. O(1). + * + * @param stdClass|array $object + */ + private static function optionalBoolean($object, string $name, string $where): void + { + $value = JsonValue::get($object, $name); + if ($value !== null && !\is_bool($value)) { + throw self::refuse('wrong_type', "{$where}/{$name}", 'must be a boolean'); + } + } + /** @param stdClass|array $object */ private static function optionalRatio($object, string $name, string $where, ?int $maximum): void { diff --git a/src/Support/ResultValidation/Bound.php b/src/Support/ResultValidation/Bound.php new file mode 100644 index 0000000..f5dc74f --- /dev/null +++ b/src/Support/ResultValidation/Bound.php @@ -0,0 +1,531 @@ + [0, 1, 2, 3, 4], 'lowest_cost' => [0, 2, 1, 3, 4]]; + private const MAX_BOUND_SUM = '1000000000000000000000000000000'; + private const MAX_BOUND_VALUE = 9007199254740991; + private const SCORE_SCALE = 1000000; + private const GAP_FIELDS = ['gap_key', 'absolute_gap', 'relative_gap']; + + /** + * The reference's `_check_optimality_bound`. Absence of the field is never an issue. + * + * @param mixed $request @param mixed $result @param Ordered $items + * @param Ordered $containers @param list $score @param list $issues + */ + public static function check($request, $result, Ordered $items, Ordered $containers, array $score, array &$issues): void + { + $fact = Json::at($result, 'optimality'); + [$bound, $withheld] = self::stated($request, Json::at($result, 'status'), $items, $containers); + $departure = $bound === null ? null : self::departure($score, $bound); + $below = $departure !== null && $departure[1] < 0; + if ($below) { + $key = $departure[0]; + $issues[] = new ResultIssue('score_below_bound', "score[{$key}] is {$score[$key]}, below its lower bound {$bound[$key]}"); + } + $emitted = Json::get($fact, 'lower_bound'); + if ($emitted !== null && $bound !== null && $departure !== null + && Exact::compare(Exact::sub($score[$departure[0]], $bound[$departure[0]]), self::MAX_BOUND_VALUE) > 0) { + $issues[] = new ResultIssue('optimality_gap_mismatch', 'the gap exceeds the portable result ceiling'); + } + $counts = $emitted === null ? null : self::wellFormed($emitted); + if ($emitted === null) { + if (self::reportsGap($fact)) { + $issues[] = new ResultIssue('optimality_gap_mismatch', 'a gap is reported without lower_bound'); + } + } elseif ($counts === null) { + $issues[] = new ResultIssue('optimality_bound_mismatch', 'lower_bound must be a list of 1 to 5 non-negative integers'); + } elseif ($bound === null) { + $issues[] = new ResultIssue('optimality_bound_mismatch', "lower_bound is reported where no bound is stated: {$withheld}"); + } elseif ($below) { + $issues[] = new ResultIssue('optimality_bound_mismatch', 'lower_bound is reported for a score below the recomputed bound'); + } else { + self::checkReported($counts, $score, $bound, $departure, $fact, $issues); + } + $attained = $bound !== null && $departure === null; + self::checkClaims($result, $fact, $attained, $counts !== null && self::same($counts, $score), $issues); + } + + /** + * `[L0, L1, L2, L3, L4]`, or null where the one refusal predicate withholds the bound. Public so + * the shared scene can hold it to the oracle. + * + * @param list $volumes value runs sorted ascending + * @param list $weights + * @param list> $containers + * @return list|null + */ + public static function lowerBound(array $volumes, array $weights, bool $shrinks, array $containers): ?array + { + $quantities = self::column($containers, 'quantity'); + $guarded = [ + self::sumRuns($volumes), + self::sumRuns($weights), + self::capacityTotal(self::column($containers, 'usable'), $quantities), + self::capacityTotal(self::column($containers, 'payload'), $quantities), + self::capacityTotal(self::column($containers, 'max_items'), $quantities), + ]; + foreach ($guarded as $total) { + if ($total !== null && Exact::compare($total, self::MAX_BOUND_SUM) > 0) { + return null; + } + } + $count = self::runCount($volumes); + $placeable = 0; + if ($containers !== []) { + $placeable = $count; + if (!$shrinks) { + $placeable = self::min($placeable, self::fit($volumes, $guarded[2])); + } + $placeable = self::min($placeable, self::fit($weights, $guarded[3])); + if ($guarded[4] !== null) { + $placeable = self::min($placeable, $guarded[4]); + } + } + $opened = self::containerBound($volumes, $weights, $shrinks, $placeable, $containers); + $bound = [ + Exact::sub($count, $placeable), + $opened, + self::costBound($containers, $opened), + self::unusedBound($volumes, $shrinks, $placeable, $containers, $opened), + self::heightBound($volumes, $shrinks, $placeable, $containers, $opened), + ]; + foreach ($bound as $value) { + if (Exact::compare($value, self::MAX_BOUND_VALUE) > 0) { + return null; + } + } + return $bound; + } + + /** + * `[value, count]` pairs sorted by value: one per item type, however large its quantity. + * + * @param list $pairs @return list + */ + public static function runs(array $pairs): array + { + \usort($pairs, static function (array $left, array $right): int { return Exact::compare($left[0], $right[0]); }); + return $pairs; + } + + /** + * The bound in the objective's score order, or null with the reason none is stated (§ 1.5). + * + * @param mixed $request @param mixed $status @param Ordered $items @param Ordered $containers + * @return array{0:list|null,1:string} + */ + private static function stated($request, $status, Ordered $items, Ordered $containers): array + { + $name = Json::get(Json::get($request, 'configuration', []), 'objective', 'default'); + if (!\is_string($name) || !\array_key_exists($name, self::ORDERS)) { + return [null, 'objective ' . PyFormat::repr($name) . ' has no bound']; + } + foreach ($containers->values() as $spec) { + if ($spec->madeToMeasure !== null) { + return [null, 'a container is made to measure']; + } + } + if (Json::isText($status, 'infeasible')) { + return [null, "status is 'infeasible'"]; + } + if (Json::isText($status, 'invalid_result')) { + return [null, "status is 'invalid_result'"]; + } + [$volumes, $weights, $shrinks, $types] = self::inputs($items, $containers); + $bound = self::lowerBound($volumes, $weights, $shrinks, $types); + if ($bound === null) { + return [null, 'the request is past a declared ceiling']; + } + return [\array_map(static function (int $position) use ($bound) { return $bound[$position]; }, self::ORDERS[$name]), '']; + } + + /** + * The request as the bound reads it: every instance, fixed ones included, and every type. + * + * @param Ordered $items @param Ordered $containers @return array{0:list,1:list,2:bool,3:list>} + */ + private static function inputs(Ordered $items, Ordered $containers): array + { + $volumes = []; + $weights = []; + $shrinks = false; + foreach ($items->values() as $spec) { + $quantity = Json::int($spec->quantity, 'quantity'); + $volumes[] = [Exact::mul(Exact::mul($spec->dimensions[0], $spec->dimensions[1]), $spec->dimensions[2]), $quantity]; + $weights[] = [$spec->weight, $quantity]; + $shrinks = $shrinks || ($quantity > 0 && ($spec->nestingHeight !== null + || Json::isText($spec->shapeType, 'convex_hull') || Json::isText($spec->shapeType, 'compressible'))); + } + $types = []; + foreach ($containers->values() as $spec) { + $volume = $spec->volume(); + $scaled = ContainerCheck::ratioPpm($spec->voidFillReserveRatio); + $reserved = Exact::add( + Exact::mul(Exact::floorDiv($volume, self::SCORE_SCALE), $scaled), + Exact::floorDiv(Exact::mul(Exact::mod($volume, self::SCORE_SCALE), $scaled), self::SCORE_SCALE) + ); + $types[] = [ + 'inner_volume' => $volume, + 'base_area' => Exact::mul($spec->inner[0], $spec->inner[1]), + 'height' => $spec->inner[2], + 'usable' => Exact::sub($volume, $reserved), + 'cost_minor' => $spec->costMinor, + 'payload' => $spec->maxPayload, + 'max_items' => $spec->maxItems, + 'quantity' => $spec->quantity, + ]; + } + return [self::runs($volumes), self::runs($weights), $shrinks, $types]; + } + + /** @param list> $containers @return list */ + private static function column(array $containers, string $field): array + { + return \array_map(static function (array $container) use ($field) { return $container[$field]; }, $containers); + } + + /** @param list $runs @return int|string */ + private static function sumRuns(array $runs) + { + $total = 0; + foreach ($runs as [$value, $count]) { + $total = Exact::add($total, Exact::mul($value, $count)); + } + return $total; + } + + /** @param list $runs @return int|string */ + private static function runCount(array $runs) + { + $total = 0; + foreach ($runs as [, $count]) { + $total = Exact::add($total, $count); + } + return $total; + } + + /** @param int|string $left @param int|string $right @return int|string */ + private static function min($left, $right) + { + return Exact::compare($left, $right) <= 0 ? $left : $right; + } + + /** @param list $values @return int|string */ + private static function largest(array $values) + { + $largest = $values[0]; + foreach ($values as $value) { + $largest = Exact::max($largest, $value); + } + return $largest; + } + + /** + * How many of the smallest values sum to at most `capacity`; all of them when unlimited. + * + * @param list $runs @param int|string|null $capacity @return int|string + */ + private static function fit(array $runs, $capacity) + { + if ($capacity === null) { + return self::runCount($runs); + } + if (Exact::sign($capacity) < 0) { + return 0; + } + $taken = 0; + $used = 0; + foreach ($runs as [$value, $count]) { + $room = Exact::sign($value) === 0 ? $count : self::min($count, Exact::floorDiv(Exact::sub($capacity, $used), $value)); + $taken = Exact::add($taken, $room); + $used = Exact::add($used, Exact::mul($value, $room)); + if (Exact::compare($room, $count) < 0) { + break; + } + } + return $taken; + } + + /** The sum of the `count` smallest values. @param list $runs @param int|string $count @return int|string */ + private static function smallest(array $runs, $count) + { + $total = 0; + foreach ($runs as [$value, $available]) { + if (Exact::sign($count) <= 0) { + break; + } + $taken = self::min($available, $count); + $total = Exact::add($total, Exact::mul($value, $taken)); + $count = Exact::sub($count, $taken); + } + return $total; + } + + /** + * `Σ value × quantity`, or null when unbounded; a type that holds nothing adds 0. + * + * @param list $values @param list $quantities @return int|string|null + */ + private static function capacityTotal(array $values, array $quantities) + { + $total = 0; + foreach ($values as $index => $value) { + $quantity = $quantities[$index]; + if ($value === null) { + return null; + } + if ($quantity === null) { + if (Exact::sign($value) > 0) { + return null; + } + continue; + } + $total = Exact::add($total, Exact::mul($value, $quantity)); + } + return $total; + } + + /** + * `L1`, given `L0`. + * + * @param list $volumes @param list $weights + * @param int|string $placed @param list> $containers @return int|string + */ + private static function containerBound(array $volumes, array $weights, bool $shrinks, $placed, array $containers) + { + if (Exact::sign($placed) <= 0 || $containers === []) { + return 0; + } + $bound = 1; + $largestVolume = self::largest(self::column($containers, 'usable')); + if (!$shrinks && Exact::sign($largestVolume) > 0) { + $bound = Exact::max($bound, Exact::ceilDiv(self::smallest($volumes, $placed), $largestVolume)); + } + $payloads = self::column($containers, 'payload'); + if (!\in_array(null, $payloads, true)) { + $largestPayload = self::largest($payloads); + if (Exact::sign($largestPayload) > 0) { + $bound = Exact::max($bound, Exact::ceilDiv(self::smallest($weights, $placed), $largestPayload)); + } + } + $slots = self::column($containers, 'max_items'); + if (!\in_array(null, $slots, true)) { + $largestSlots = self::largest($slots); + if (Exact::sign($largestSlots) > 0) { + $bound = Exact::max($bound, Exact::ceilDiv($placed, $largestSlots)); + } + } + return $bound; + } + + /** + * `L2`: the `opened` cheapest containers the inventory holds, greedily by cost (stable). + * + * @param list> $containers @param int|string $opened @return int|string + */ + private static function costBound(array $containers, $opened) + { + $order = \array_keys($containers); + \usort($order, static function (int $left, int $right) use ($containers): int { + return Exact::compare($containers[$left]['cost_minor'], $containers[$right]['cost_minor']) ?: $left <=> $right; + }); + $total = 0; + foreach ($order as $index) { + if (Exact::sign($opened) <= 0) { + break; + } + $quantity = $containers[$index]['quantity']; + $taken = $quantity === null ? $opened : self::min($quantity, $opened); + $total = Exact::add($total, Exact::mul($containers[$index]['cost_minor'], $taken)); + $opened = Exact::sub($opened, $taken); + } + return $total; + } + + /** + * `L3`. + * + * @param list $volumes @param int|string $placed + * @param list> $containers @param int|string $opened @return int|string + */ + private static function unusedBound(array $volumes, bool $shrinks, $placed, array $containers, $opened) + { + $inner = self::positive(self::column($containers, 'inner_volume')); + if (Exact::sign($opened) <= 0 || $shrinks || $inner === []) { + return 0; + } + $smallestInner = $inner[0]; + foreach ($inner as $volume) { + $smallestInner = self::min($smallestInner, $volume); + } + $filled = Exact::ceilDiv(Exact::mul(self::smallest(\array_reverse($volumes), $placed), self::SCORE_SCALE), $smallestInner); + $unused = Exact::sub(Exact::sub(Exact::mul($opened, self::SCORE_SCALE), $filled), Exact::sub($opened, 1)); + return Exact::max(0, $unused); + } + + /** + * `L4`. + * + * @param list $volumes @param int|string $placed + * @param list> $containers @param int|string $opened @return int|string + */ + private static function heightBound(array $volumes, bool $shrinks, $placed, array $containers, $opened) + { + $floors = self::positive(self::column($containers, 'base_area')); + $heights = self::positive(self::column($containers, 'height')); + if (Exact::sign($opened) <= 0 || $shrinks || $floors === [] || $heights === []) { + return 0; + } + $required = Exact::ceilDiv(self::smallest($volumes, $placed), self::largest($floors)); + $aggregate = Exact::floorDiv(Exact::mul($required, self::SCORE_SCALE), self::largest($heights)); + return Exact::max(0, Exact::sub($aggregate, Exact::sub($opened, 1))); + } + + /** @param list $values @return list */ + private static function positive(array $values): array + { + $kept = []; + foreach ($values as $value) { + if ($value !== null && Exact::sign($value) > 0) { + $kept[] = $value; + } + } + return $kept; + } + + /** + * The first position where `score` leaves `bound`, and whether it is above (1) or below (-1). + * + * @param list $score @param list $bound @return array{0:int,1:int}|null + */ + private static function departure(array $score, array $bound): ?array + { + $shared = \min(\count($score), \count($bound)); + for ($index = 0; $index < $shared; ++$index) { + $order = Exact::compare($score[$index], $bound[$index]); + if ($order !== 0) { + return [$index, $order]; + } + } + return null; + } + + /** `lower_bound` as counts when it is a list of 1 to 5 non-negative integers. @param mixed $emitted @return list|null */ + private static function wellFormed($emitted): ?array + { + if (!Json::isList($emitted) || \count($emitted) < 1 || \count($emitted) > 5) { + return null; + } + foreach ($emitted as $value) { + if (!\is_int($value) || $value < 0) { + return null; + } + } + return $emitted; + } + + /** @param mixed $fact */ + private static function reportsGap($fact): bool + { + foreach (self::GAP_FIELDS as $name) { + if (Json::get($fact, $name) !== null) { + return true; + } + } + return false; + } + + /** @param list $left @param list $right */ + private static function same(array $left, array $right): bool + { + if (\count($left) !== \count($right)) { + return false; + } + foreach ($left as $index => $value) { + if (Exact::compare($value, $right[$index]) !== 0) { + return false; + } + } + return true; + } + + /** + * @param list $emitted @param list $score @param list $bound + * @param array{0:int,1:int}|null $departure @param mixed $fact @param list $issues + */ + private static function checkReported(array $emitted, array $score, array $bound, ?array $departure, $fact, array &$issues): void + { + $prefix = \array_slice($bound, 0, \count($emitted)); + if (!self::same($emitted, $prefix)) { + $issues[] = new ResultIssue('optimality_bound_mismatch', 'reported ' . PyFormat::intList($emitted) . ', recomputed ' . PyFormat::intList($prefix)); + } + $length = \count($bound); + $expected = ['gap_key' => null, 'absolute_gap' => null, 'relative_gap' => null]; + if ($departure !== null) { + $key = $departure[0]; + $absolute = Exact::sub($score[$key], $bound[$key]); + $length = $key + 1; + $expected = [ + 'gap_key' => $key, + 'absolute_gap' => $absolute, + 'relative_gap' => Exact::sign($bound[$key]) === 0 ? null : [$absolute, $bound[$key]], + ]; + } + if (\count($emitted) !== $length) { + $issues[] = new ResultIssue('optimality_gap_mismatch', 'lower_bound has ' . \count($emitted) . " entries, expected {$length}"); + } + foreach ($expected as $name => $value) { + if (!self::gapFieldMatches($name, Json::get($fact, $name), $value)) { + $text = $value === null ? 'absent' : ($name === 'relative_gap' ? "{$value[0]}/{$value[1]}" : (string)$value); + $issues[] = new ResultIssue('optimality_gap_mismatch', "{$name} should be {$text}"); + } + } + } + + /** @param mixed $actual @param int|string|array{0:int|string,1:int|string}|null $expected */ + private static function gapFieldMatches(string $name, $actual, $expected): bool + { + if ($expected === null) { + return $actual === null; + } + if ($name !== 'relative_gap') { + return \is_int($actual) && Exact::compare($actual, $expected) === 0; + } + return Json::isDict($actual) && \count($actual) === 2 + && \is_int(Json::get($actual, 'numerator')) && Exact::compare($actual['numerator'], $expected[0]) === 0 + && \is_int(Json::get($actual, 'denominator')) && Exact::compare($actual['denominator'], $expected[1]) === 0; + } + + /** @param mixed $result @param mixed $fact @param list $issues */ + private static function checkClaims($result, $fact, bool $attained, bool $claimsAttainment, array &$issues): void + { + $status = Json::at($result, 'status'); + $complete = Json::truthy(Json::at($result, 'complete')); + if (Json::isText($status, 'optimal') && !($complete && $attained)) { + $issues[] = new ResultIssue('result_fact_mismatch', "status 'optimal' requires a complete result that attains the recomputed lower bound"); + } + if (Json::isText($status, 'feasible') && $complete && $claimsAttainment) { + $issues[] = new ResultIssue('result_fact_mismatch', "status is 'feasible', but the result attains its lower_bound; expected 'optimal'"); + } + if (Json::isText(Json::get($fact, 'code'), 'proven_optimal') && !$claimsAttainment) { + $issues[] = new ResultIssue('unproven_optimality_claim', "optimality.code is 'proven_optimal' without an attained lower_bound"); + } + } +} diff --git a/src/Support/ResultValidation/Json.php b/src/Support/ResultValidation/Json.php index 1517c34..e1a2018 100644 --- a/src/Support/ResultValidation/Json.php +++ b/src/Support/ResultValidation/Json.php @@ -63,6 +63,24 @@ public static function at($value, string $key) return $value[$key]; } + /** + * A field the request schema types boolean: absent or null is its default, and any other + * non-boolean is refused, as every engine refuses it, rather than read by truthiness. + * + * @param mixed $value + */ + public static function flag($value, string $key, bool $default, string $where): bool + { + $flag = self::get($value, $key); + if ($flag === null) { + return $default; + } + if (!\is_bool($flag)) { + throw MalformedResult::contract("{$where}.{$key} must be a boolean"); + } + return $flag; + } + /** Something the reference iterates; only a list is admitted. @param mixed $value @return list */ public static function items($value, string $what): array { diff --git a/src/Support/ResultValidation/Lattice.php b/src/Support/ResultValidation/Lattice.php index 8e80ed0..eb8eb4b 100644 --- a/src/Support/ResultValidation/Lattice.php +++ b/src/Support/ResultValidation/Lattice.php @@ -15,12 +15,12 @@ final class Lattice private const SIDES = ['length', 'width', 'height']; /** - * @param mixed $request @param mixed $result + * @param mixed $result * @param Ordered $items @param Ordered $containers * @param int|string $clearance @param list $issues * @return mixed the result with every valid summary expanded into placements */ - public static function materialize($request, $result, Ordered $items, Ordered $containers, $clearance, array &$issues) + public static function materialize(bool $coordinatesRequired, $result, Ordered $items, Ordered $containers, $clearance, array &$issues) { $packedContainers = Json::items(Json::at($result, 'containers'), 'result.containers'); $summarized = false; @@ -30,9 +30,8 @@ public static function materialize($request, $result, Ordered $items, Ordered $c if (!$summarized) { return $result; } - $optional = Json::get(Json::get($request, 'configuration', []), 'require_placement_coordinates') === false; foreach ($packedContainers as $index => $packed) { - $placements = self::expand($packed, $optional, $items, $containers, $clearance, $issues); + $placements = self::expand($packed, $coordinatesRequired, $items, $containers, $clearance, $issues); if ($placements !== null) { $packedContainers[$index]['placements'] = $placements; } @@ -46,14 +45,14 @@ public static function materialize($request, $result, Ordered $items, Ordered $c * @param int|string $clearance @param list $issues * @return list|null the placements to write into the container, or null to leave it */ - private static function expand($packed, bool $optional, Ordered $items, Ordered $containers, $clearance, array &$issues): ?array + private static function expand($packed, bool $coordinatesRequired, Ordered $items, Ordered $containers, $clearance, array &$issues): ?array { $summary = Json::get($packed, 'lattice_summary'); if ($summary === null) { return null; } $containerId = PyFormat::str(Json::get($packed, 'id', '?')); - if (!$optional) { + if ($coordinatesRequired) { $issues[] = new ResultIssue('unexpected_lattice_summary', "{$containerId}: request requires explicit placement coordinates"); } if (Json::truthy(Json::get($packed, 'placements'))) { diff --git a/src/Support/ResultValidation/ObjectiveCheck.php b/src/Support/ResultValidation/ObjectiveCheck.php index f4ff2f9..07b8228 100644 --- a/src/Support/ResultValidation/ObjectiveCheck.php +++ b/src/Support/ResultValidation/ObjectiveCheck.php @@ -11,8 +11,13 @@ */ final class ObjectiveCheck { - /** @param mixed $request @param mixed $result @param Ordered $fabricated @param list $issues */ - public static function check($request, $result, Context $context, Ordered $fabricated, array &$issues): void + /** + * Checks the reported score against the recomputed one, and returns the reported score. + * + * @param mixed $request @param mixed $result @param Ordered $fabricated @param list $issues + * @return list + */ + public static function check($request, $result, Context $context, Ordered $fabricated, array &$issues): array { $configuration = Json::get($request, 'configuration', []); $selected = Json::get($configuration, 'objective', 'default'); @@ -34,6 +39,7 @@ public static function check($request, $result, Context $context, Ordered $fabri if (!$same) { $issues[] = new ResultIssue('score_mismatch', 'reported ' . PyFormat::intList($reported) . ', recomputed ' . PyFormat::intList($recomputed)); } + return $reported; } /** diff --git a/src/Support/ResultValidation/Spec.php b/src/Support/ResultValidation/Spec.php index f255c6a..45a6742 100644 --- a/src/Support/ResultValidation/Spec.php +++ b/src/Support/ResultValidation/Spec.php @@ -16,10 +16,14 @@ final class Spec public static function items($request, string $unit): Ordered { $items = new Ordered(); - foreach (Json::items(Json::at($request, 'items'), 'request.items') as $raw) { + foreach (Json::items(Json::at($request, 'items'), 'request.items') as $index => $raw) { + $where = "items[{$index}]"; + $keepUpright = Json::flag($raw, 'keep_upright', false, $where); + $stackable = Json::flag($raw, 'stackable', true, $where); + $mustBeOnFloor = Json::flag($raw, 'must_be_on_floor', false, $where); $rotations = Json::get($raw, 'allowed_rotations'); if ($rotations === null) { - $rotations = Json::truthy(Json::get($raw, 'keep_upright')) ? self::UPRIGHT_ROTATIONS : \array_keys(Geometry::ROTATIONS); + $rotations = $keepUpright ? self::UPRIGHT_ROTATIONS : \array_keys(Geometry::ROTATIONS); } else { $rotations = Json::items($rotations, 'allowed_rotations'); } @@ -30,8 +34,8 @@ public static function items($request, string $unit): Ordered $spec->weight = Units::toTicks(Json::get($raw, 'weight', 0), 'g', Units::WEIGHT); $spec->quantity = Json::get($raw, 'quantity', 1); $spec->allowedRotations = \array_values($rotations); - $spec->stackable = Json::truthy(Json::get($raw, 'stackable', true)); - $spec->mustBeOnFloor = Json::truthy(Json::get($raw, 'must_be_on_floor')); + $spec->stackable = $stackable; + $spec->mustBeOnFloor = $mustBeOnFloor; $spec->maxTopLoad = self::measured($raw, 'max_top_load', 'g', Units::WEIGHT); $spec->minimumSupportRatio = Units::ratioOf(Json::get($raw, 'minimum_support_ratio', 0)); $spec->tags = self::tagSet(Json::get($raw, 'tags', []), 'tags'); diff --git a/src/Support/ResultValidation/Units.php b/src/Support/ResultValidation/Units.php index 84774c7..1ed2bd2 100644 --- a/src/Support/ResultValidation/Units.php +++ b/src/Support/ResultValidation/Units.php @@ -22,10 +22,49 @@ final class Units 'lb' => 3628738960, 'lbs' => 3628738960, 'tick' => 1, 'ticks' => 1, ]; + /** + * Every name the engines read in `units.length`, after stripping ASCII whitespace and + * lowering ASCII case, to the `LENGTH` key it means. + */ + private const REQUEST_LENGTH_UNITS = [ + 'mm' => 'mm', 'cm' => 'cm', 'm' => 'm', 'in' => 'in', 'inch' => 'inch', 'inches' => 'inches', + 'ft' => 'ft', 'tick' => 'tick', 'ticks' => 'ticks', 'millimeter' => 'mm', 'millimeters' => 'mm', + ]; + public const TICKS_PER_MM = 16000; public const TICKS_PER_GRAM = 8000000; public const TICKS_PER_KG = 8000000000; + /** + * `units.length` as every engine reads it: `units` or `units.length` absent or + * null is millimetres; `units` that is not an object, or a length unit the engines do not + * know, is refused naming the field. PHP decodes `[]` and `{}` alike, so `units: []` reads + * as an empty object, as the PHP engine reads it. + * + * @param mixed $request + */ + public static function lengthUnit($request): string + { + $units = Json::get($request, 'units'); + if ($units === null) { + return 'mm'; + } + if (!Json::isDict($units)) { + throw MalformedResult::contract('units must be an object'); + } + $length = Json::get($units, 'length'); + if ($length === null) { + return 'mm'; + } + $key = \is_string($length) + ? \strtr(\trim($length, " \t\n\r\v\f"), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') + : ''; + if (!\array_key_exists($key, self::REQUEST_LENGTH_UNITS)) { + throw MalformedResult::contract('units.length must be one of mm, cm, m, in, ft'); + } + return self::REQUEST_LENGTH_UNITS[$key]; + } + /** `table[unit]`, an exact key. @param array $table */ public static function perUnit(array $table, string $unit): int { diff --git a/src/Support/ResultValidation/Validator.php b/src/Support/ResultValidation/Validator.php index 8195a80..4c46c70 100644 --- a/src/Support/ResultValidation/Validator.php +++ b/src/Support/ResultValidation/Validator.php @@ -27,15 +27,16 @@ public static function validate($request, $result): array throw MalformedResult::contract('result is missing ' . PyFormat::stringRepr($key)); } } - $unit = Json::text(Json::get(Json::get($request, 'units', []), 'length', 'mm'), 'units.length'); + $unit = Units::lengthUnit($request); $configuration = Json::get($request, 'configuration', []); + $coordinatesRequired = Json::flag($configuration, 'require_placement_coordinates', true, 'configuration'); $clearance = Units::toTicks(Json::get($configuration, 'clearance', 0), $unit, Units::LENGTH); $globalSupport = Units::ratioOf(Json::get($configuration, 'minimum_support_ratio', 0)); $items = Spec::items($request, $unit); $containers = Spec::containers($request, $unit); $issues = []; - $validated = Lattice::materialize($request, $result, $items, $containers, $clearance, $issues); + $validated = Lattice::materialize($coordinatesRequired, $result, $items, $containers, $clearance, $issues); Accounting::checkStructure($validated, $issues); Facts::checkAlgorithmMetrics($validated, $issues); Facts::checkEffortBudget($request, $validated, $issues); @@ -50,7 +51,8 @@ public static function validate($request, $result): array ObjectiveCheck::checkFixedPlacements($request, $validated, $unit, $issues); ObjectiveCheck::checkGroups($request, $validated, $issues); ObjectiveCheck::checkSolverSelection($request, $validated, $issues); - ObjectiveCheck::check($request, $validated, $context, $fabricated, $issues); + $score = ObjectiveCheck::check($request, $validated, $context, $fabricated, $issues); + Bound::check($request, $validated, $items, $containers, $score, $issues); return $issues; } diff --git a/src/Validation/IndependentSolutionValidator.php b/src/Validation/IndependentSolutionValidator.php index 4787642..ceee35e 100644 --- a/src/Validation/IndependentSolutionValidator.php +++ b/src/Validation/IndependentSolutionValidator.php @@ -33,7 +33,8 @@ public function validate(PackingRequest $request,array $containers,float $minimu // A stacking-group list refuses load on the same edges `stackable` does. if(!$item->stackable||$item->acceptsStackingGroups!==[])$stackSensitive=true; } - $stackGraph=$stackSensitive?new LoadSupportGraph(LoadCalculator::units($placements)):null; + $units=LoadCalculator::units($placements); + $stackGraph=$stackSensitive?new LoadSupportGraph($units):null; foreach(self::collisionPairs($placements) as [$left,$right]) $issues[]=new ValidationIssue('collision',$placements[$left]->instance->id().' with '.$placements[$right]->instance->id()); foreach($packed->placements as $index=>$p){ @@ -76,9 +77,9 @@ public function validate(PackingRequest $request,array $containers,float $minimu } // A second, whole-container bearing pass: the per-placement check above // reports the first offender it meets, this one is anchored on the container. - $units=LoadCalculator::units($packed->placements); $densityLimit=$packed->container->maxStackDensity?->ticks; - $failure=LoadCalculator::overloaded($units)??LoadCalculator::crushed($units)??LoadCalculator::stackLimitExceeded($units)??LoadCalculator::stackDensityExceeded($units,$densityLimit); + $analysis=new \Packvium\Constraint\Internal\LoadAnalysis($units,$stackGraph); + $failure=$analysis->overloaded()??$analysis->crushed()??$analysis->stackLimitExceeded()??$analysis->stackDensityExceeded($densityLimit); if($failure!==null)$issues[]=new ValidationIssue($failure[0],$packed->id().': '.$failure[1]); if($packed->container->axles!==null){ $axleFailure=AxleLoad::exceeded( diff --git a/tests/BoundLoadingTest.php b/tests/BoundLoadingTest.php new file mode 100644 index 0000000..29abba3 --- /dev/null +++ b/tests/BoundLoadingTest.php @@ -0,0 +1,58 @@ +pack([$item], [$container])->toArray(); +$loaded = false; +foreach (get_included_files() as $file) { + if (basename($file) === 'ObjectiveBounds.php') { $loaded = true; } +} +echo json_encode(['loaded' => $loaded, 'bound' => $result['optimality']['lower_bound'] ?? null]); +PHP; + $process = proc_open([PHP_BINARY, '-r', $script, $case], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, dirname(__DIR__)); + self::assertTrue(is_resource($process)); + $out = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $err); + return json_decode($out, true, flags: JSON_THROW_ON_ERROR); + } + + public static function testAnUnsupportedObjectiveDoesNotLoadTheBound(): void + { + self::assertSame(['loaded' => false, 'bound' => null], self::probe('unsupported')); + } + + public static function testAMachineDoesNotLoadTheBound(): void + { + self::assertSame(['loaded' => false, 'bound' => null], self::probe('machine')); + } + + public static function testACustomScorerDoesNotLoadTheBound(): void + { + self::assertSame(['loaded' => false, 'bound' => null], self::probe('custom')); + } + + public static function testAnUnpackableRequestStillStatesItsBound(): void + { + self::assertSame(['loaded' => true, 'bound' => [0]], self::probe('impossible')); + } +} diff --git a/tests/EffortBoundaryTest.php b/tests/EffortBoundaryTest.php new file mode 100644 index 0000000..3ff4c7c --- /dev/null +++ b/tests/EffortBoundaryTest.php @@ -0,0 +1,273 @@ + ['length' => 'cm'], + 'configuration' => [ + 'objective' => 'lowest_landed_cost', + 'effort_budget' => ['max_candidates_evaluated' => 1_000_000, 'max_placement_attempts' => 1_000_000, 'max_search_nodes' => 1_000_000], + 'time_limit_ms' => 300_000, + 'dimensional_weight_length_unit' => 'cm', + 'dimensional_weight_weight_unit' => 'kg', + 'dimensional_weight_divisor' => 5000, + ], + 'items' => [ + ['id' => 'product-21', 'quantity' => 10, 'dimensions' => ['length' => '22', 'width' => '18', 'height' => '25'], 'weight' => '1.4 kg'], + ['id' => 'product-22', 'quantity' => 20, 'dimensions' => ['length' => '9', 'width' => '9', 'height' => '10'], 'weight' => '0.25 kg'], + ['id' => 'product-11', 'quantity' => 20, 'dimensions' => ['length' => '10', 'width' => '8', 'height' => '9'], 'weight' => '0.35 kg'], + ], + 'containers' => [ + ['id' => 'small', 'inner_dimensions' => ['length' => '25', 'width' => '20', 'height' => '15'], 'cost_minor' => 40, + 'rate_table' => ['weight_brackets_g' => [1000, 2000, 5000, 10000], 'prices_minor' => [495, 695, 995, 1495]]], + ['id' => 'large', 'inner_dimensions' => ['length' => '40', 'width' => '30', 'height' => '30'], + 'outer_dimensions' => ['length' => '41', 'width' => '31', 'height' => '31'], 'cost_minor' => 90, + 'rate_table' => ['weight_brackets_g' => [3000, 20000], 'prices_minor' => [1200, 1900], 'minimum_charge_minor' => 1000, 'fuel_surcharge_permille' => 85]], + ], + ]; + + private const CUBES = [['id' => 'cube', 'quantity' => 60, 'dimensions' => ['length' => '10', 'width' => '10', 'height' => '10']]]; + + /** + * @param array|null $budget + * @param list|null $solvers + * @param list>|null $items + * @return array + */ + private static function request(?array $budget, ?array $solvers = null, string $profile = 'fast', ?array $items = null): array + { + $configuration = ['solver_profile' => $profile, 'time_limit_ms' => 60_000]; + if ($budget !== null) { + $configuration['effort_budget'] = $budget; + } + if ($solvers !== null) { + $configuration['solvers'] = $solvers; + } + return [ + 'units' => ['length' => 'mm'], + 'configuration' => $configuration, + 'items' => $items ?? [ + ['id' => 'a', 'quantity' => 3, 'dimensions' => ['length' => '40', 'width' => '30', 'height' => '20']], + ['id' => 'b', 'quantity' => 3, 'dimensions' => ['length' => '25', 'width' => '25', 'height' => '35']], + ], + 'containers' => [['id' => 'c', 'inner_dimensions' => ['length' => '100', 'width' => '80', 'height' => '90']]], + ]; + } + + public static function testAStartThatFinishesOnExactlyItsBudgetIsNotStopped(): void + { + $unlimited = ArrayCodec::pack(self::request(null)); + $used = $unlimited['algorithm']['placements_attempted']; + + $exact = ArrayCodec::pack(self::request(['max_placement_attempts' => $used])); + + self::assertFalse($exact['algorithm']['effort_limit_reached']); + self::assertSame('complete', $exact['termination']['code']); + self::assertFalse($exact['termination']['winning_start_truncated']); + self::assertSame($unlimited['score'], $exact['score']); + self::assertSame($unlimited['containers'], $exact['containers']); + } + + public static function testABudgetOneShortStopsTheStartOnItsLimit(): void + { + $unlimited = ArrayCodec::pack(self::request(null)); + $used = $unlimited['algorithm']['placements_attempted']; + + $short = ArrayCodec::pack(self::request(['max_placement_attempts' => $used - 1])); + + self::assertTrue($short['termination']['any_start_truncated']); + self::assertLessThanOrEqual($used - 1, $short['algorithm']['placements_attempted']); + $truncated = array_values(array_filter($short['termination']['starts'], static fn(array $start): bool => $start['truncated'])); + self::assertTrue($truncated !== []); + foreach ($truncated as $start) { + self::assertFalse($start['completed'], $start['id']); + } + } + + public static function testABlockCommitPlacesOnlyWhatTheBudgetHasRoomFor(): void + { + $result = ArrayCodec::pack(self::request(['max_placement_attempts' => 7], ['homogeneous_blocks'], 'quality', self::CUBES)); + + self::assertSame(7, $result['algorithm']['placements_attempted']); + self::assertSame(7, $result['algorithm']['candidates_evaluated']); + self::assertSame(7, $result['summary']['packed_item_count']); + self::assertTrue($result['algorithm']['effort_limit_reached']); + self::assertSame('effort_limit', $result['termination']['code']); + } + + public static function testANamedBlockSolverOutsideQualityReachesItsFirstBlock(): void + { + // Both profiles leave the node limit at one, short of the first block. + foreach (['fast', 'balanced'] as $profile) { + $result = ArrayCodec::pack(self::request(null, ['homogeneous_blocks'], $profile, self::CUBES)); + + self::assertSame(60, $result['summary']['packed_item_count'], $profile); + self::assertSame('complete', $result['termination']['code'], $profile); + } + } + + public static function testANodeLimitStopIsTheSearchAnswerNotATimeLimit(): void + { + $request = self::request(null, ['homogeneous_blocks'], 'quality', self::CUBES); + $request['configuration']['container_plan_node_limit'] = 1; + + $result = ArrayCodec::pack($request); + + self::assertSame(0, $result['summary']['packed_item_count']); + self::assertFalse($result['algorithm']['time_limit_reached']); + self::assertSame('complete', $result['termination']['code']); + } + + public static function testALatticeTooLargeForTheBudgetIsPlacedOneCountedItemAtATime(): void + { + $request = self::request(['max_candidates_evaluated' => 7], ['grid'], 'fast', self::CUBES); + $request['configuration']['require_placement_coordinates'] = false; + + $result = ArrayCodec::pack($request); + + self::assertSame(7, $result['algorithm']['candidates_evaluated']); + self::assertSame(7, $result['summary']['packed_item_count']); + self::assertTrue($result['algorithm']['effort_limit_reached']); + } + + public static function testAnEffortBudgetGivesEveryStartTheWholeRemainingClock(): void + { + $deadline = new Deadline(1_000_000_000, static fn(): int => 0); + $startBudget = new ReflectionMethod(SolverOrchestrator::class, 'startBudget'); + + $budgeted = $startBudget->invoke(null, $deadline, new EffortBudget(maxSearchNodes: 5), new SearchStats(), 8); + $unbudgeted = $startBudget->invoke(null, $deadline, null, new SearchStats(), 8); + + self::assertSame(1_000_000_000, $budgeted->remainingNs()); + self::assertSame(intdiv(1_000_000_000, 8), $unbudgeted->remainingNs()); + } + + /** + * (b): with the clock not sliced per start, a run either finishes every start and + * gives the one answer, or the fuse fired as a whole and `time_limit_reached` says so. + */ + public static function testABudgetThatNeverBindsGivesOneAnswerOrSaysTheClockFired(): void + { + $request = self::M1_CART; + $reference = ArrayCodec::pack($request); + $request['configuration']['time_limit_ms'] = 1_500; + + for ($run = 0; $run < 3; $run++) { + $result = ArrayCodec::pack($request); + self::assertTrue( + $result['algorithm']['time_limit_reached'] + || [$result['score'], $result['containers']] === [$reference['score'], $reference['containers']], + "run {$run} answered differently without saying the clock fired", + ); + } + } + + public static function testAPollOnASpentBudgetRecordsTheStopAndALaterReadDoesNot(): void + { + $stats = new SearchStats(); + $deadline = Deadline::ofMilliseconds(60_000)->withEffort(new EffortBudget(maxSearchNodes: 3), $stats); + $stats->searchNodesExpanded = 3; + + self::assertFalse($deadline->stopped()); + self::assertFalse($deadline->effortStopped()); + self::assertTrue($deadline->expired()); + self::assertTrue($deadline->effortStopped()); + self::assertTrue($deadline->stopped()); + } + + /** @return list<\Packvium\Domain\ItemInstance> */ + private static function cubes(int $quantity): array + { + return Item::create('cube', Dimensions::mm(30, 20, 10), quantity: $quantity)->instances(); + } + + public static function testAFeasibleCandidatePastItsLimitStopsTheScanInsideAPoint(): void + { + $config = PackingConfig::balanced(); + $stats = new SearchStats(); + $deadline = Deadline::ofMilliseconds(60_000)->withEffort(new EffortBudget(maxCandidatesEvaluated: 1), $stats); + $state = new ContainerState(Container::create('c', Dimensions::mm(100, 100, 100)), 1); + + self::assertThrows(TimeLimitReached::class, static fn() => CandidateFinder::find( + $state, self::cubes(1)[0], $config, ConstraintSet::defaults(0.0), $stats, $deadline, new DefaultCandidateScorer(), 1, + )); + + self::assertSame(1, $stats->candidatesEvaluated); + self::assertSame(1, $stats->candidatePointsConsidered); + self::assertTrue($deadline->effortStopped()); + } + + public static function testABeamNodeReachedWithASpentBudgetIsRefusedAndNotCounted(): void + { + foreach (['greedy' => PackingConfig::balanced(), 'beam' => PackingConfig::quality()] as $name => $config) { + $stats = new SearchStats(); + $stats->searchNodesExpanded = 2; + $deadline = Deadline::ofMilliseconds(60_000)->withEffort(new EffortBudget(maxSearchNodes: 2), $stats); + + $solution = (new ExtremePointSolver())->packOne(Container::create('c', Dimensions::mm(100, 100, 100)), 1, self::cubes(3), $config, $stats, $deadline); + + self::assertSame(2, $stats->searchNodesExpanded, $name); + self::assertTrue($solution->timeLimitReached, $name); + self::assertTrue($deadline->effortStopped(), $name); + } + } + + public static function testAMaximalSpaceItemReachedWithASpentBudgetIsRefusedAndNotCounted(): void + { + $stats = new SearchStats(); + $stats->searchNodesExpanded = 2; + $deadline = Deadline::ofMilliseconds(60_000)->withEffort(new EffortBudget(maxSearchNodes: 2), $stats); + + $solution = (new MaximalSpaceSolver())->packOne(Container::create('c', Dimensions::mm(100, 100, 100)), 1, self::cubes(3), PackingConfig::quality(), $stats, $deadline); + + self::assertSame(2, $stats->searchNodesExpanded); + self::assertCount(3, $solution->unpacked); + self::assertTrue($solution->timeLimitReached); + self::assertTrue($deadline->effortStopped()); + } + + /** Every placement budget below a wide beam's own use stops it on the limit; its own use does not. */ + public static function testAWideBeamStopsOnEveryPlacementLimitShortOfItsUseAndOnlyThere(): void + { + $box = Container::create('c', Dimensions::mm(100, 100, 100)); + $full = new SearchStats(); + (new ExtremePointSolver())->packOne($box, 1, self::cubes(3), PackingConfig::quality(), $full, Deadline::ofMilliseconds(60_000)); + for ($limit = 1; $limit <= $full->placementsAttempted; $limit++) { + $stats = new SearchStats(); + $deadline = Deadline::ofMilliseconds(60_000)->withEffort(new EffortBudget(maxPlacementAttempts: $limit), $stats); + (new ExtremePointSolver())->packOne($box, 1, self::cubes(3), PackingConfig::quality(), $stats, $deadline); + self::assertLessThanOrEqual($limit, $stats->placementsAttempted, "limit {$limit}"); + self::assertSame($limit < $full->placementsAttempted, $deadline->effortStopped(), "limit {$limit}"); + } + } +} diff --git a/tests/ObjectiveBoundsTest.php b/tests/ObjectiveBoundsTest.php index 1d83179..cd9ff8c 100644 --- a/tests/ObjectiveBoundsTest.php +++ b/tests/ObjectiveBoundsTest.php @@ -2,8 +2,12 @@ declare(strict_types=1); namespace Packvium\Tests; -use Packvium\Domain\{Container, Dimensions, Item, ItemInstance, Rotation, ShapeType}; +use Packvium\Domain\{Container, Dimensions, FixedPlacement, Item, ItemInstance, MadeToMeasure, PackingRequest, Point, Rotation, ShapeType}; use Packvium\Algorithm\{BoundOverflowException, ObjectiveBounds}; +use Packvium\Config\PackingConfig; +use Packvium\Objective\DefaultSolutionScorer; +use Packvium\Packer; +use Packvium\Result\PackingStatus; use Packvium\Support\BigInt; use Packvium\Unit\{Length, Weight}; @@ -267,4 +271,167 @@ public static function testABoundThatCannotCrossEveryBindingExactlyIsRefused(): self::assertThrows(BoundOverflowException::class, static fn() => ObjectiveBounds::fromNormalised($instances, $containers)); } + + /** + * Every edge of the one refusal predicate, and the fixed-placement case. + * + * Written from the oracle, `scripts/optimality_bounds.py`, rather than from an engine, + * because the predicate is what arbitrates between engines (finding F4). Before + * this engine refused `Σ usable * (quantity ?? 1)` and `Σ cost * (quantity ?? 1)` -- totals + * that do not exist for an unlimited type -- and guarded a payload total part-way through, + * so the order the types were listed in decided whether a request was answered. + */ + public static function testEverySceneEdgeIsAnsweredOrRefusedAsTheOraclePredicateSays(): void + { + $edges = self::scene()['edges'] ?? []; + self::assertGreaterThan(9, count($edges), 'the scene carries too few edges to hold the predicate'); + foreach ($edges as $edge) { + foreach ([$edge['containers'], array_reverse($edge['containers'])] as $containers) { + if ($edge['bounds'] === null) { + self::assertThrows(BoundOverflowException::class, + static fn() => ObjectiveBounds::fromNormalised($edge['instances'], $containers), + "{$edge['name']} must be refused"); + continue; + } + self::assertSame($edge['bounds'], + ObjectiveBounds::fromNormalised($edge['instances'], $containers), $edge['name']); + } + } + } + + /** `lowest_cost` reads the same five numbers in its own order; nothing else gets any. */ + public static function testTheRequestBoundIsStatedOnlyForTheCoveredObjectives(): void + { + $items = self::instancesOf(Item::create('i', Dimensions::mm(60, 50, 40)), 2); + $crate = [Container::create('c', Dimensions::mm(100, 100, 100), costMinor: 7)]; + $default = ObjectiveBounds::forRequest($items, $crate, 'default'); + self::assertSame(ObjectiveBounds::compute($items, $crate), $default); + [$unpacked, $count, $cost, $unused, $height] = $default; + self::assertSame([$unpacked, $cost, $count, $unused, $height], + ObjectiveBounds::forRequest($items, $crate, 'lowest_cost')); + foreach (['maximum_value', 'maximum_volume', 'open_dimension_height', 'shipping_cost'] as $objective) { + self::assertNull(ObjectiveBounds::forRequest($items, $crate, $objective), $objective); + } + + // D13: a box cut to measure has no declared size until it is cut. + $ticks = static fn(int $mm): array => [$mm * Length::TICKS_PER_MM, $mm * Length::TICKS_PER_MM, $mm * Length::TICKS_PER_MM]; + $machine = [new Container('m', Dimensions::mm(1510, 1510, 1510), + madeToMeasure: new MadeToMeasure($ticks(60), $ticks(25)))]; + self::assertNull(ObjectiveBounds::forRequest($items, $machine, 'default')); + } + + /** A custom solution scorer ranks by positions the bound knows nothing about. */ + public static function testACustomSolutionScorerGetsNoBound(): void + { + $request = new PackingRequest([Item::create('i', Dimensions::mm(10, 10, 10))], + [Container::create('c', Dimensions::mm(100, 100, 100))]); + $bound = new \ReflectionMethod(Packer::class, 'objectiveBound'); + self::assertNotNull($bound->invoke(new Packer(), $request)); + self::assertNull($bound->invoke(new Packer(solutionScorer: new DefaultSolutionScorer()), $request)); + } + + /** + * F1, end to end: a fixed slab and a free one fill a 100 mm cube exactly. + * + * The bound used to see only the free slab and claim half the cube must stay empty -- + * 500000 ppm on key 3 against a packing that scores 0. Over every instance it is the + * packing's own score: attained, and so a proof. + */ + public static function testAFixedPlacementIsCountedByTheBound(): void + { + $slab = Item::create('slab', Dimensions::mm(100, 100, 50), quantity: 2); + $cube = Container::create('cube', Dimensions::mm(100, 100, 100), quantity: 1); + $fixed = [new FixedPlacement('slab', 'cube', new Point(0, 0, 0), Rotation::LWH, 1)]; + $result = (new Packer(PackingConfig::quality(20000)))->pack([$slab], [$cube], $fixed); + self::assertSame([0, 1, 0, 0, 1000000], $result->score); + + $request = new PackingRequest([$slab], [$cube], $fixed); + self::assertSame($result->score, ObjectiveBounds::forRequest($request->instances(), [$cube], 'default')); + // Attained and complete: the one route to a proof (OPTIMALITY-GAP.md § 1.3, Q1). + self::assertSame(PackingStatus::Optimal, $result->status); + self::assertSame(['code' => 'proven_optimal', 'lower_bound' => [0, 1, 0, 0, 1000000]], + $result->optimality->toArray()); + $freeOnly = ObjectiveBounds::compute(array_slice($request->instances(), 1), [$cube]); + self::assertSame(500000, $freeOnly[3], 'the free slab alone over-states key 3'); + } + + /** + * F5: past the ceiling the bound is withheld and the pack is answered. + * + * A payload total of 10^31 is finite and past the ceiling. The quality profile runs the + * container-set beam and `exact_small` the exhaustive search, where the old per-start + * bound called `compute` with no `catch`, so on 1.7.0 both fail the whole pack with + * `BoundOverflowException`. Four different items, because identical ones reach the + * lattice fast path before either search. + */ + public static function testARequestPastTheCeilingIsStillAnsweredOnTheBeam(): void + { + $items = array_map(static fn(int $n): Item => Item::create("i$n", + Dimensions::mm(30 + 7 * $n, 20 + 3 * $n, 15 + 5 * $n), 1), range(0, 3)); + $box = Container::create('c', Dimensions::mm(100, 100, 100), + maxPayload: new Weight(1_000_000_000_000_000_000), quantity: 10_000_000_000_000); + $instances = array_merge(...array_map(static fn(Item $i): array => $i->instances(), $items)); + self::assertNull(ObjectiveBounds::forRequest($instances, [$box], 'default')); + foreach ([PackingConfig::quality(20000), PackingConfig::exactSmall(20000)] as $config) { + $result = (new Packer($config))->pack($items, [$box]); + self::assertSame([], $result->unpacked, $config->profile->value); + // Withheld means no statement at all, and no warning (Q6). + self::assertSame(['code' => 'not_proven'], $result->optimality->toArray()); + } + } + + /** F6: the cheapest entries of the per-unit multiset, from one sort of the types. */ + public static function testTheCostBoundTakesTheCheapestTypesWithoutExpandingThem(): void + { + $cost = new \ReflectionMethod(ObjectiveBounds::class, 'costBound'); + self::assertSame(5 + 7 * 3, $cost->invoke(null, [9, 5, 7], [3, 1, null], 4)); + self::assertSame(14, $cost->invoke(null, [9, 5], [1, 1], 4)); + self::assertSame(3 * 1_000_000_000_000, $cost->invoke(null, [3], [null], 1_000_000_000_000)); + } + + /** + * The prefix through the first missed position, the unreduced relative gap, + * its absence on a zero bound, and nothing for a score below its bound. + */ + public static function testAStatementStopsAtTheFirstMissedPosition(): void + { + $bound = [0, 2, 10, 500, 7]; + self::assertSame(['lower_bound' => $bound], ObjectiveBounds::statement($bound, $bound)); + self::assertSame([ + 'lower_bound' => [0, 2, 10], 'gap_key' => 2, 'absolute_gap' => 4, + 'relative_gap' => ['numerator' => 4, 'denominator' => 10], + ], ObjectiveBounds::statement([0, 2, 14, 0, 0], $bound)); + self::assertSame(['lower_bound' => [0], 'gap_key' => 0, 'absolute_gap' => 1], + ObjectiveBounds::statement([1, 0, 0, 0, 0], $bound)); + self::assertNull(ObjectiveBounds::statement([0, 1, 99, 0, 0], $bound)); + } + + /** + * Q1: `exact_small` exhausting its candidates on one container is no longer a proof. Four + * unlike items cannot fill the cube, so the bound is missed and the result says how far. + */ + public static function testExhaustingASearchIsNoLongerAProof(): void + { + $items = array_map(static fn(int $n): Item => Item::create("i$n", + Dimensions::mm(30 + 7 * $n, 20 + 3 * $n, 15 + 5 * $n), 1), range(0, 3)); + $result = (new Packer(PackingConfig::exactSmall(20000)))->pack( + $items, [Container::create('c', Dimensions::mm(100, 100, 100))]); + self::assertSame([], $result->unpacked); + self::assertSame(PackingStatus::Feasible, $result->status); + $fact = $result->optimality->toArray(); + self::assertSame('not_proven', $fact['code']); + self::assertTrue(array_key_exists('gap_key', $fact), 'a missed bound carries its gap'); + } + + /** + * OPTIMALITY-GAP.md § 1.5 condition 5: a score below its bound makes the bound the + * suspect, so the result states nothing. Unreachable through a sound bound, so the + * packer's own rule is called with one that is not. + */ + public static function testAScoreBelowItsBoundWithholdsTheStatement(): void + { + $stated = new \ReflectionMethod(Packer::class, 'statedOptimality'); + self::assertSame([PackingStatus::Feasible, null], + $stated->invoke(null, PackingStatus::Feasible, [0, 1, 0, 0, 0], [0, 2, 0, 0, 0], true)); + } } diff --git a/tests/PackingTest.php b/tests/PackingTest.php index c73b03c..65bd003 100644 --- a/tests/PackingTest.php +++ b/tests/PackingTest.php @@ -634,28 +634,31 @@ public static function testIncompatibleItemsAreSeparated(): void // -------------------------------------------------------------------- reporting - public static function testExactSmallDoesNotClaimGlobalOptimalityWithoutACertificate(): void + public static function testExactSmallClaimsOptimalityOnlyThroughTheBound(): void { - // The discrete search stops at its first full packing and has no lower-bound - // certificate for the remaining objective keys. + // The discrete search proves nothing by stopping; the request's lower bound does + // when the packing attains it on every key (OPTIMALITY-GAP.md § 1.3). A perfect fill + // of one container attains it, and the result carries the bound it attained. $items = [Support::item('a', 100, 100, 100, ['quantity' => 2]), Support::item('b', 100, 100, 100, ['quantity' => 2])]; $containers = [Support::box('box', 200, 200, 100)]; $result = Support::pack($items, $containers, PackingConfig::exactSmall(5_000)); self::assertTrue($result->complete()); - self::assertSame(PackingStatus::Feasible, $result->status); + self::assertSame(PackingStatus::Optimal, $result->status); + self::assertSame(['code' => 'proven_optimal', 'lower_bound' => $result->score], + $result->optimality->toArray()); } - public static function testASingleItemTypeTakesTheLatticePathAndClaimsNoProof(): void + public static function testASingleItemTypeOnTheLatticePathIsProvenByTheBoundAlone(): void { - // The lattice cannot prove optimality, so it must not be reported as optimal - // even when it happens to fill the container perfectly. + // The lattice cannot prove optimality itself; a perfect fill is proven by the bound, + // whichever solver produced it, and by nothing else. $items = [Support::item('cube', 100, 100, 100, ['quantity' => 4])]; $containers = [Support::box('box', 200, 200, 100)]; $result = Support::pack($items, $containers, PackingConfig::exactSmall(5_000)); self::assertTrue($result->complete()); - self::assertSame(PackingStatus::Feasible, $result->status); + self::assertSame(PackingStatus::Optimal, $result->status); } public static function testAnIncompleteAnswerWithinTheBudgetReportsBestFound(): void @@ -782,7 +785,8 @@ public static function testQuantityCompressionStillValidatesEndToEndWhenRequeste $result = Support::pack($items, $containers, $config); - self::assertSame(PackingStatus::Feasible, $result->status); + // Two hundred cubes fill a fifth of the bin; the bound proves nothing beats one bin. + self::assertSame(PackingStatus::Optimal, $result->status); self::assertSame([], $result->warnings); self::assertSame([], $result->containers[0]->placements); self::assertNotNull($result->containers[0]->latticeSummary); diff --git a/tests/RequestObjectsCorpusTest.php b/tests/RequestObjectsCorpusTest.php index d2258b5..3b64cda 100644 --- a/tests/RequestObjectsCorpusTest.php +++ b/tests/RequestObjectsCorpusTest.php @@ -31,6 +31,7 @@ final class RequestObjectsCorpusTest extends TestCase private const COERCED = 'the codec casts it, so the objects it builds are a different, valid request'; private const TYPED = 'a typed parameter cannot hold it'; private const OUTPUT = 'the output units are arguments of the result\'s serializer, not part of the objects packed'; + private const UNITS = 'the request\'s length unit is an argument of each measure, not an object packed'; private const 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'; private const NEGATIVE_POSITION = 'a Point cannot hold a negative coordinate; the measure it is read with ' @@ -93,6 +94,34 @@ final class RequestObjectsCorpusTest extends TestCase 'output-length-unit-unknown' => self::OUTPUT, 'output-weight-unit-unknown' => self::OUTPUT, 'output-before-configuration' => self::OUTPUT, + // The request's length unit is an argument of each measure, not an object of its own. + 'units-not-object' => self::UNITS, + 'units-list' => self::UNITS, + 'units-length-object' => self::TYPED, + 'units-length-list' => self::TYPED, + // A bool property cannot hold anything else; the codec casts what it reads. + 'item-keep-upright-text' => self::COERCED, + 'item-keep-upright-text-true' => self::COERCED, + 'item-keep-upright-zero' => self::COERCED, + 'item-keep-upright-one' => self::COERCED, + 'item-keep-upright-list' => self::COERCED, + 'item-stackable-text' => self::COERCED, + 'item-stackable-text-true' => self::COERCED, + 'item-stackable-zero' => self::COERCED, + 'item-stackable-one' => self::COERCED, + 'item-stackable-list' => self::COERCED, + 'item-must-be-on-floor-text' => self::COERCED, + 'item-must-be-on-floor-text-true' => self::COERCED, + 'item-must-be-on-floor-zero' => self::COERCED, + 'item-must-be-on-floor-one' => self::COERCED, + 'item-must-be-on-floor-list' => self::COERCED, + 'configuration-require-placement-coordinates-text' => self::COERCED, + 'configuration-require-placement-coordinates-text-true' => self::COERCED, + 'configuration-require-placement-coordinates-zero' => self::COERCED, + 'configuration-require-placement-coordinates-one' => self::COERCED, + 'configuration-require-placement-coordinates-list' => self::COERCED, + 'item-booleans-in-schema-order' => self::COERCED, + 'configuration-boolean-before-items' => self::COERCED, ]; /** Cases the object path answers differently on purpose (docs/ERRORS.md). */ @@ -104,10 +133,12 @@ final class RequestObjectsCorpusTest extends TestCase 'an-item-id-before-its-quantity' => self::CONSTRUCTOR_FIRST, 'item-ids-before-containers' => self::CONSTRUCTOR_FIRST, 'container-ids-before-fixed-placements' => self::CONSTRUCTOR_FIRST, + 'configuration-boolean-before-effort-budget' => 'two faults: the EffortBudget is built, and refuses its own ' + . 'value, before the PackingConfig that holds it', ]; /** Pinned, so the skip set cannot grow without this line changing too. */ - private const SKIPPED = 61; + private const SKIPPED = 88; public static function testTheSkipSetIsPinnedAndNamesOnlyCorpusCases(): void { diff --git a/tests/ResultValidationBoundTest.php b/tests/ResultValidationBoundTest.php new file mode 100644 index 0000000..9326e2f --- /dev/null +++ b/tests/ResultValidationBoundTest.php @@ -0,0 +1,186 @@ + [['id' => 'cube', 'quantity' => 1, 'dimensions' => ['length' => '10', 'width' => '10', 'height' => '10']]], + 'containers' => [['id' => 'box', 'inner_dimensions' => ['length' => '20', 'width' => '10', 'height' => '10']]], + ]; + + private const GAP = [ + 'code' => 'not_proven', + 'lower_bound' => [0, 1, 0, 500000, 500000], + 'gap_key' => 4, + 'absolute_gap' => 500000, + 'relative_gap' => ['numerator' => 500000, 'denominator' => 500000], + ]; + + /** @param mixed $value @return int|string */ + private static function integer($value) + { + return Exact::normalize(\is_int($value) ? $value : (string)$value); + } + + /** @param list> $instances @return list */ + private static function runs(array $instances, string $field): array + { + return Bound::runs(\array_map(static function (array $instance) use ($field): array { + return [self::integer($instance[$field]), 1]; + }, $instances)); + } + + public static function testTheBoundMatchesEveryCaseAndEdgeOfTheSharedScene(): void + { + $path = \dirname(__DIR__, 2) . '/conformance/scene/objective-bounds.json'; + if (!\is_file($path)) { + self::skip('the shared cross-language scene fixture is not part of this package'); + } + $scene = \json_decode((string)\file_get_contents($path), true); + $compared = 0; + foreach (\array_merge($scene['cases'], $scene['edges']) as $case) { + $containers = \array_map(static function (array $container): array { + return [ + 'inner_volume' => self::integer($container['inner']), + 'base_area' => self::integer($container['base_area']), + 'height' => self::integer($container['height']), + 'usable' => self::integer($container['usable']), + 'cost_minor' => self::integer($container['cost_minor']), + 'payload' => $container['payload'], + 'max_items' => $container['max_items'], + 'quantity' => $container['quantity'], + ]; + }, $case['containers']); + $shrinks = \in_array(true, \array_column($case['instances'], 'shrinks'), true); + $computed = Bound::lowerBound(self::runs($case['instances'], 'volume'), self::runs($case['instances'], 'weight'), $shrinks, $containers); + self::assertSame($case['bounds'], $computed, 'bound diverges from the oracle on ' . ($case['name'] ?? $case['fixture'])); + ++$compared; + } + self::assertGreaterThan(400, $compared); + } + + public static function testANegativeOrUnlimitedCapacityAndZeroVolumesAreReadAsTheReferenceReadsThem(): void + { + $container = [ + 'inner_volume' => 1000, 'base_area' => 100, 'height' => 10, 'usable' => -1, 'cost_minor' => 3, + 'payload' => 5, 'max_items' => null, 'quantity' => 1, + ]; + self::assertSame([2, 0, 0, 0, 0], Bound::lowerBound([[0, 2]], [[1, 2]], false, [$container])); + $container['quantity'] = null; + self::assertSame([0, 1, 3, 1000000, 0], Bound::lowerBound([[0, 2]], [[1, 2]], false, [$container])); + self::assertSame([1, 0, 0, 0, 0], Bound::lowerBound([[1, 1]], [[0, 1]], false, [])); + } + + /** @param array $optimality @return list */ + private static function codes(array $optimality, ?callable $change = null): array + { + $result = ArrayCodec::pack(self::REQUEST); + $result['optimality'] = $optimality; + if ($change !== null) { + $result = $change($result); + } + return \array_map(static function (ResultIssue $issue): string { return $issue->code; }, validate(self::REQUEST, $result)); + } + + public static function testAStatedGapAndItsAbsenceAreBothValid(): void + { + self::assertSame([], self::codes(self::GAP)); + self::assertSame([], self::codes(['code' => 'not_proven'])); + } + + public static function testABoundWhereNoneIsStatedOrOfTheWrongShapeIsAMismatch(): void + { + $details = static function (array $request, array $result): array { + return \array_map(static function (ResultIssue $issue): string { return $issue->detail; }, validate($request, $result)); + }; + $result = ArrayCodec::pack(self::REQUEST); + $result['optimality'] = ['code' => 'not_proven', 'lower_bound' => []]; + self::assertSame(['lower_bound must be a list of 1 to 5 non-negative integers'], $details(self::REQUEST, $result)); + + // Payload capacity 8e24 weight ticks times a million boxes passes the 10^30 ceiling. + $huge = self::REQUEST; + $huge['containers'][0]['max_payload'] = ['value' => '1000000000000000', 'unit' => 'kg']; + $huge['containers'][0]['quantity'] = 1000000; + // The same packing answers it; only the inventory changed. + $packed = ArrayCodec::pack(self::REQUEST); + $packed['optimality']['lower_bound'] = [0]; + self::assertSame(['lower_bound is reported where no bound is stated: the request is past a declared ceiling'], $details($huge, $packed)); + + $invalid = ArrayCodec::pack(self::REQUEST); + $invalid['status'] = 'invalid_result'; + $invalid['optimality'] = ['code' => 'not_proven', 'lower_bound' => [0]]; + self::assertContains( + "lower_bound is reported where no bound is stated: status is 'invalid_result'", + $details(self::REQUEST, $invalid) + ); + } + + public static function testEachCheckFiresOnItsOwnMutation(): void + { + $wrongBound = self::GAP; + $wrongBound['lower_bound'][1] = 2; + self::assertSame(['optimality_bound_mismatch'], self::codes($wrongBound)); + $wrongGap = self::GAP; + $wrongGap['absolute_gap'] = 1; + self::assertSame(['optimality_gap_mismatch'], self::codes($wrongGap)); + self::assertSame(['score_mismatch', 'score_below_bound', 'optimality_bound_mismatch'], self::codes(self::GAP, static function (array $result): array { + $result['score'][3] = 0; + return $result; + })); + $claimed = self::GAP; + $claimed['code'] = 'proven_optimal'; + self::assertSame(['result_fact_mismatch', 'unproven_optimality_claim'], self::codes($claimed, static function (array $result): array { + $result['status'] = 'optimal'; + return $result; + })); + } + public static function testUnrepresentableGapsAreWithheldAndRejectedWhenForged(): void + { + foreach ([0, 1] as $cheapCost) { + $dimensions = ['length' => '10', 'width' => '10', 'height' => '10']; + $request = [ + 'items' => [['id' => 'i', 'quantity' => 2, 'dimensions' => $dimensions]], + 'containers' => [ + ['id' => 'cheap', 'inner_dimensions' => ['length' => '1', 'width' => '1', 'height' => '1'], 'cost_minor' => $cheapCost, 'max_items' => 1], + ['id' => 'big', 'inner_dimensions' => $dimensions, 'cost_minor' => 9007199254740991, 'max_items' => 1], + ], + ]; + $result = ArrayCodec::pack($request); + self::assertSame(['code' => 'not_proven'], $result['optimality']); + self::assertSame([], validate($request, $result)); + $absolute = 18014398509481982 - 2 * $cheapCost; + $result['optimality'] = ['code' => 'not_proven', 'lower_bound' => [0, 2, 2 * $cheapCost], 'gap_key' => 2, 'absolute_gap' => $absolute]; + if ($cheapCost !== 0) { + $result['optimality']['relative_gap'] = ['numerator' => $absolute, 'denominator' => 2 * $cheapCost]; + } + self::assertTrue(\in_array('optimality_gap_mismatch', \array_map(static fn(ResultIssue $issue): string => $issue->code, validate($request, $result)), true)); + } + } + + public static function testTheGapCeilingIsInclusive(): void + { + foreach ([0, 1] as $least) { + $bound = [0, 1, $least, 0, 0]; + $statement = \Packvium\Algorithm\ObjectiveBounds::statement([0, 1, $least + 9007199254740991, 0, 0], $bound); + self::assertSame(9007199254740991, $statement['absolute_gap']); + self::assertSame(null, \Packvium\Algorithm\ObjectiveBounds::statement([0, 1, $least + 9007199254740992, 0, 0], $bound)); + } + } + +} diff --git a/tests/SolverTest.php b/tests/SolverTest.php index 3c432b6..f764ea0 100644 --- a/tests/SolverTest.php +++ b/tests/SolverTest.php @@ -503,7 +503,8 @@ public static function testACountedLimitStopsTheBlockSolverOnTheNodeThePerNodeWa // The solver counts a whole lattice group in one step and ranks only groups that can // lead. Each row is [limit, placements, stopped]: the limit from which that outcome // holds, pinned from the per-node walk over every limit from 1 to 7000, so a stop - // must land on the same node, in a node limit and in an effort budget alike. + // must land on the same node, in a node limit and in an effort budget alike. Only the + // effort budget reports the stop as a limit: the node limit is a search bound. $items=[...self::instances('a',108,76,30,['quantity'=>40]),...self::instances('b',110,43,25,['quantity'=>33]), ...self::instances('c',92,81,55,['quantity'=>39]),...self::instances('d',61,47,33,['quantity'=>12])]; $box=Container::create('box',Dimensions::mm(587,233,220)); @@ -522,12 +523,13 @@ public static function testACountedLimitStopsTheBlockSolverOnTheNodeThePerNodeWa $solution=(new HomogeneousBlockSolver([],null,[$mode],$limit))->packOne($box,1,$items,PackingConfig::fast(),$stats,self::generous()); self::assertSame(min($limit,$total),$stats->searchNodesExpanded,"{$mode} node limit {$limit}"); self::assertSame($expectedPlaced,count($solution->state->placements),"{$mode} node limit {$limit}"); - self::assertSame($expectedStopped,$solution->timeLimitReached,"{$mode} node limit {$limit}"); + self::assertFalse($solution->timeLimitReached,"{$mode} node limit {$limit}"); $effort=new SearchStats(); $budget=self::generous()->withEffort(new EffortBudget(maxSearchNodes:$limit),$effort); $limited=(new HomogeneousBlockSolver([],null,[$mode],1_000_000))->packOne($box,1,$items,PackingConfig::fast(),$effort,$budget); self::assertSame(min($limit,$total),$effort->searchNodesExpanded,"{$mode} effort budget {$limit}"); self::assertSame($expectedPlaced,count($limited->state->placements),"{$mode} effort budget {$limit}"); + self::assertSame($expectedStopped,$limited->timeLimitReached,"{$mode} effort budget {$limit}"); } } } @@ -630,6 +632,23 @@ public static function testContainerPlanSearchBoundsTheContainersStillNeededByPa self::assertSame([], Support::problems($result, $items, $containers)); } + public static function testContainerPlanSearchRanksSeveralSurvivingPlansByTheirBound(): void + { + // Two container types give each step more than one surviving plan, so the beam is + // ranked by the payload-aware bound rather than passed through as a single plan. + $items = [Item::create('heavy', Dimensions::mm(40, 40, 40), weight: '600 g', quantity: 3)]; + $containers = [ + Container::create('cheap', Dimensions::mm(100, 100, 100), maxPayload: '1 kg', costMinor: 10, quantity: 5), + Container::create('dear', Dimensions::mm(100, 100, 100), maxPayload: '1 kg', costMinor: 20, quantity: 5), + ]; + $config = new PackingConfig(timeLimitMs: 60_000, seed: 1, effortBudget: new EffortBudget(maxSearchNodes: 100_000), + containerPlanBeamWidth: 4, containerPlanNodeLimit: 64); + $result = (new \Packvium\Packer($config))->pack($items, $containers); + self::assertTrue($result->complete()); + self::assertCount(3, $result->containers); + self::assertSame([], Support::problems($result, $items, $containers)); + } + private static function assertPhysicallySound(ContainerState $state): void { $boundary = new AxisAlignedBox(new Point(0, 0, 0), $state->container->innerDimensions); @@ -956,6 +975,44 @@ public function score(ContainerState $state,Point $point,Dimensions $envelope):a } } + public static function testTheInlinedDefaultScoreSelectsWhatDefaultCandidateScorerWould(): void + { + // CandidateFinder builds DefaultCandidateScorer's key itself and skips a position whose + // height already loses. A scorer that only delegates to it takes the generic path, so + // both must select the same candidates and count the same work, reserve path included. + $delegating=new class implements \Packvium\Extension\CandidateScorer { + public function score(ContainerState $state,Point $point,Dimensions $envelope):array + { + return (new DefaultCandidateScorer())->score($state,$point,$envelope); + } + }; + [$base]=self::instances('base',40,40,30); + [$plain]=self::instances('a',30,20,10); + [$nesting]=self::instances('cup',30,20,10,['nestingHeight'=>Length::mm(4)]); + $points=array_map( + static fn(array $origin):Point=>new Point(...array_map(static fn(int $mm):int=>Length::mm($mm)->ticks,$origin)), + [[60,60,50],[0,50,0],[0,0,30],[50,0,0],[0,0,0],[60,0,30],[10,60,50],[70,70,0]], + ); + foreach([0.0,0.25] as $reserve){ + $state=new ContainerState(Container::create('c',Dimensions::mm(100,100,100),voidFillReserveRatio:$reserve),1); + $config=new PackingConfig(); + $constraints=ConstraintSet::defaults($config->minimumSupportRatio); + $state=BeamPacker::extended($state,$base,CandidateFinder::find( + $state,$base,$config,$constraints,new SearchStats(),self::generous(),new DefaultCandidateScorer(),1, + )[0]); + foreach([$plain,$nesting] as $item){ + foreach([null,1,2,3,128] as $width){ + $inlined=new SearchStats();$delegated=new SearchStats(); + $expected=CandidateFinder::find($state,$item,$config,$constraints,$delegated,self::generous(),$delegating,$width,$points); + $actual=CandidateFinder::find($state,$item,$config,$constraints,$inlined,self::generous(),new DefaultCandidateScorer(),$width,$points); + self::assertGreaterThan(0,count($expected)); + self::assertTrue($actual==$expected); + self::assertTrue($inlined==$delegated); + } + } + } + } + public static function testSpaceAndNodeMetricsCountTheirOwnSolverWork(): void { $stats=new SearchStats(); diff --git a/tools/php-compat/composer.json b/tools/php-compat/composer.json new file mode 100644 index 0000000..1e80d6e --- /dev/null +++ b/tools/php-compat/composer.json @@ -0,0 +1,13 @@ +{ + "name": "packvium/php-compat-build", + "description": "Release-only AST downgrade toolchain for the generated legacy-PHP artifact", + "type": "project", + "license": "MIT", + "require-dev": { + "rector/rector": "^2.4" + }, + "config": { + "allow-plugins": false, + "sort-packages": true + } +} diff --git a/tools/php-compat/composer.lock b/tools/php-compat/composer.lock new file mode 100644 index 0000000..25f96ee --- /dev/null +++ b/tools/php-compat/composer.lock @@ -0,0 +1,143 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "4e3168aab5c9e079ba2bf45f99528853", + "packages": [], + "packages-dev": [ + { + "name": "phpstan/phpstan", + "version": "2.2.7", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921", + "reference": "692db47b9dddb0487934e5236e77d48594aef921", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-07-29T17:39:32+00:00" + }, + { + "name": "rector/rector", + "version": "2.5.9", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "858b1fb95d94f658b54c01080bf7b6ae97274536" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/858b1fb95d94f658b54c01080bf7b6ae97274536", + "reference": "858b1fb95d94f658b54c01080bf7b6ae97274536", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.2.6" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.5.9" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2026-07-30T22:10:00+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/tools/php-compat/rector.php b/tools/php-compat/rector.php new file mode 100644 index 0000000..8b2992a --- /dev/null +++ b/tools/php-compat/rector.php @@ -0,0 +1,34 @@ +withCache($cacheRoot . '/files', null, $cacheRoot . '/container') + ->withoutParallel() + ->withPaths([ + $compatRoot . '/src', + $compatRoot . '/examples', + $compatRoot . '/bin', + // The package entry point. It was copied verbatim for three releases while every + // other shipped file was downgraded, and its `str_starts_with` made the 7.4 + // artifact fatal on its first autoload. + $compatRoot . '/autoload.php', + ]) + ->withDowngradeSets(php73: true);