From ddecdbb6267fcbd51faf9f6790a6872f1f487696 Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 26 Oct 2026 15:34:29 -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 | 75 ++ .github/workflows/release.yml | 4 +- CHANGELOG.md | 41 + CITATION.cff | 4 +- CONTRIBUTING.md | 21 + README.md | 28 +- artifacts.js | 2 +- codemeta.json | 6 +- docs/GUARANTEES.md | 21 +- docs/PUBLIC-API.md | 56 +- fallback.js | 487 ++++++++---- index.d.ts | 4 +- index.js | 26 +- package.json | 6 +- request-errors.js | 9 + result-validation/accounting.js | 237 ++++++ result-validation/bound.js | 324 ++++++++ result-validation/container.js | 371 +++++++++ result-validation/facts.js | 191 +++++ result-validation/geometry.js | 200 +++++ result-validation/index.js | 135 ++++ result-validation/json.js | 288 +++++++ result-validation/lattice.js | 156 ++++ result-validation/nonprintable.js | 728 ++++++++++++++++++ result-validation/num.js | 195 +++++ result-validation/objective.js | 258 +++++++ result-validation/outcome.js | 23 + result-validation/pyfmt.js | 96 +++ result-validation/spec.js | 206 +++++ result-validation/stacking.js | 342 ++++++++ result-validation/units.js | 130 ++++ test/effort-boundary.test.mjs | 150 ++++ test/fallback.test.mjs | 63 +- test/legacy-smoke.mjs | 4 +- test/native-stand-in.mjs | 7 + test/negative-seed.test.mjs | 33 + test/request-errors.test.mjs | 3 +- test/request-measures.test.mjs | 37 + test/result-validation-bound.test.mjs | 182 +++++ test/result-validation-corpus.test.mjs | 77 ++ test/result-validation-port.test.mjs | 227 ++++++ test/result-validation.test.mjs | 83 +- 48 files changed, 5553 insertions(+), 252 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 result-validation/accounting.js create mode 100644 result-validation/bound.js create mode 100644 result-validation/container.js create mode 100644 result-validation/facts.js create mode 100644 result-validation/geometry.js create mode 100644 result-validation/index.js create mode 100644 result-validation/json.js create mode 100644 result-validation/lattice.js create mode 100644 result-validation/nonprintable.js create mode 100644 result-validation/num.js create mode 100644 result-validation/objective.js create mode 100644 result-validation/outcome.js create mode 100644 result-validation/pyfmt.js create mode 100644 result-validation/spec.js create mode 100644 result-validation/stacking.js create mode 100644 result-validation/units.js create mode 100644 test/effort-boundary.test.mjs create mode 100644 test/negative-seed.test.mjs create mode 100644 test/request-measures.test.mjs create mode 100644 test/result-validation-bound.test.mjs create mode 100644 test/result-validation-corpus.test.mjs create mode 100644 test/result-validation-port.test.mjs diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml new file mode 100644 index 0000000..1f792e2 --- /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/engine" + 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..753c402 --- /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/engine 1.8.0 (JavaScript)" + 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..449e80a --- /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/engine 1.8.0 (JavaScript)" + 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..187c8b2 --- /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: 4 + 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-node/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..439456a --- /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-node/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-node/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..cb4a822 --- /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: 4 + 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..d2619cc --- /dev/null +++ b/.github/workflows/rc.yml @@ -0,0 +1,75 @@ +name: rc + +# A pre-release tag (`v1.9.0-rc.1`) builds and tests exactly as `release.yml` does, then +# publishes to npm under the `next` dist-tag. npm has no test registry, and a plain +# `npm publish` moves `latest`, so without `--tag next` every `npm install @packvium/engine` +# would get the RC. There is no GitHub Release: an RC is not a release. +# +# Before the first RC, once (the owner): +# 1. On npmjs.com, @packvium/engine -> Settings -> Trusted Publisher, add a second publisher +# (a package may have up to ten; the existing `release.yml` one stays): repository +# `packvium-node`, workflow `rc.yml`, environment `npm`, with `Allow npm publish` ticked. A new +# publisher that has not published within two days expires, so add it just before +# the first RC tag. +# 2. Set the repository variable RC_PUBLISHING to `enabled`. +# A consumer then installs it with `npm install @packvium/engine@next`. After the final +# release, `npm dist-tag rm @packvium/engine next` (or leave `next` on the RC). + +on: + push: + tags: ["v*-*"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: {node-version: "22"} + - run: npm test + + # The tag and the packaged version must agree. Without this, `git tag v0.2.0` on a + # tree that still says 0.1.0 publishes 0.1.0 under a name that claims otherwise, + # and the mistake is only visible after it is public and immutable. + - name: The tag must match the version in package.json + run: | + tagged="${GITHUB_REF_NAME#v}" + built=$(node -p "require('./package.json').version") + if [ "$tagged" != "$built" ]; then + echo "tag $GITHUB_REF_NAME does not match package.json version $built" >&2 + exit 1 + fi + + - run: npm pack + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: tarball + path: "*.tgz" + + # Inert until the owner sets the repository variable RC_PUBLISHING to `enabled` (Settings -> + # Secrets and variables -> Actions -> Variables). Without it the job is skipped and the run + # stays green, so an RC tag pushed before the accounts exist publishes nothing. + publish-next: + needs: build + if: vars.RC_PUBLISHING == 'enabled' + runs-on: ubuntu-latest + environment: npm + permissions: + id-token: write # npm Trusted Publishing and the attestation + attestations: write # build provenance, so a consumer can verify where a file came from + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: {node-version: "22", registry-url: "https://registry.npmjs.org"} + # OIDC trusted publishing needs npm 11.5.1 or newer; Node 22 bundles npm 10 (see release.yml). + - run: npm install -g npm@^11.5.1 + - run: npm --version + - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: tarball + - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: "*.tgz" + - run: npm publish ./*.tgz --provenance --access public --tag next diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b663e98..fb1cac5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,9 +9,11 @@ name: release # whatever reviewers the project wants. Requires npm CLI 11.5.1+ (setup-node's bundled # npm is new enough as of Node 22). +# A pre-release tag (`v1.9.0-rc.1`) never reaches the production registries: the negated +# pattern must follow the positive one, and `rc.yml` takes those tags instead. on: push: - tags: ["v*"] + tags: ["v*", "!v*-*"] permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 9387ec5..712b49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,47 @@ What changed in `@packvium/engine` on npm, release by release. The format follow [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] + +`validate` works after a plain `npm install`, results say how far they can be from the best +possible answer, and a mistyped flag is refused instead of guessed. + +### Added + +- **`optimality.lower_bound` and the gap** under the `default` and `lowest_cost` objectives, + computed with exact `BigInt` volumes; `validate` checks them. + +### Changed + +- **`validate(request, result)` answers without the compiled addon**, with the same issues + in the same order. +- **Attaining the bound sets `optimal`**, and an effort budget names a stop: a run that ends + on exactly its budget is `complete`. +- **A non-boolean `keep_upright`, `stackable`, `must_be_on_floor` or + `require_placement_coordinates` throws `InvalidRequestError`** (`wrong_type`), with or + without the addon. + `validate` throws `MalformedResult` (`contract`) for such a request, with or without the addon, + and reads `null` as the default. +- **The JavaScript fallback reuses measures parsed during request checking** in the bound + and starts, including made-to-measure machine settings. Hull and fixed-position measures + retain their separate admission paths. The measured macOS / Node 26 warm small-request + paired wall-time median improved about 4%; retained results agree after removing timing. +- **Axle-limited solves up to 2x faster, `validate` up to 2.4x faster on nested results**, loading + and removal sequences up to 15% faster; answers are identical. + +### Fixed + +- **`solvers: ["homogeneous_blocks"]` packs under `fast` and `balanced`**; without the addon + it placed nothing and reported `complete`. +- **`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. + +### Deprecated + +- **`ValidatorUnavailableError`.** Still exported; nothing throws it. + ## [1.7.0] A validator and a scene converter join the package, `quality` looks ahead in its block solver, diff --git a/CITATION.cff b/CITATION.cff index 29c2ea3..e0f2a83 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-node" @@ -23,3 +23,5 @@ keywords: - packing-algorithm - logistics - deterministic + - box-packing + - load-planning diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92cbc7e..d87c824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,3 +73,24 @@ the 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 3060ee8..b213003 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ Items the search never reached are listed in `unpacked_items` with reason `effor `time_limit`, not as items that do not fit. [`examples/reproducibility.mjs`](examples/reproducibility.mjs) shows both. +`effort_limit` means the budget stopped work the search still had: a search that finishes on +exactly its budget reads `complete`, and `termination.any_start_truncated` says whether the +budget cut a start that might have won. + ## Errors A request that no engine may answer throws `InvalidRequestError`, a `RangeError`, before anything @@ -162,18 +166,15 @@ case above. geometry, support and load, accounting, the result's facts and its score. It is the same reference validator every Packvium package exports, so it returns the same list. -The validator runs only in the compiled native addon, and the published package does not ship -one yet, so after a plain `npm install` `validate` throws `ValidatorUnavailableError` (code -`validator_unavailable`) instead of answering. The JavaScript fallback's validator is planned -for 1.8. Check `backend()` first: +It answers on every backend: through the compiled addon when one is loaded, and otherwise +through the package's own JavaScript port of the same validator, so a plain `npm install` checks +results too (since 1.8.0; before that it threw `ValidatorUnavailableError` without the addon). ```js -import { MalformedResult, backend, pack, validate } from '@packvium/engine'; +import { MalformedResult, pack, validate } from '@packvium/engine'; -if (backend() === 'rust') { - const issues = validate(request, pack(request)); // [] -- valid - // [{ code: 'collision', detail: 'cube#1 with cube#2 in box#1' }] for two boxes in one place -} +const issues = validate(request, pack(request)); // [] -- valid +// [{ code: 'collision', detail: 'cube#1 with cube#2 in box#1' }] for two boxes in one place ``` It judges any engine's result, not only this package's. A result without the documented shape @@ -281,6 +282,11 @@ step's placement the way its scene item is named, to match one to the other. - Exact, deterministic placement with no floating-point geometry decisions. - Rotation, payload, stackability, support, clearance, obstacle and tag constraints. - Multiple container types and clear explanations for unpacked items. +- 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`, and any + other carries the gap to the bound. +- `validate(request, result)`, the reference validator, on every backend: a plain `npm install` + checks results without the compiled addon. - JSON input/output through `pack()` or `packJson()`. - Optional payload rebalancing with `rebalanceWeight()`. - Loading and removal sequence helpers for already placed boxes. @@ -313,7 +319,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) | @@ -344,7 +350,7 @@ CodeMeta form. @software{packvium_node, author = {{Packvium contributors}}, title = {Packvium for Node.js}, - version = {1.7.0}, + version = {1.8.0}, license = {MIT}, url = {https://packvium.com} } diff --git a/artifacts.js b/artifacts.js index acc1bf0..1b2b00e 100644 --- a/artifacts.js +++ b/artifacts.js @@ -22,7 +22,7 @@ export const FORMAT = 'packvium-operational-artifact/v1'; * The engine's own name is deliberately not recorded: four correct builders naming themselves * would emit four different documents. */ -export const SUITE_VERSION = '1.7.0'; +export const SUITE_VERSION = '1.8.0'; /** The deterministic part of `result.algorithm`; `duration_ms` is wall-clock time. */ const SOLVER_FIELDS = ['profile', 'solver', 'seed', 'time_limit_reached', 'effort_limit_reached']; diff --git a/codemeta.json b/codemeta.json index 943a863..f1da7f4 100644 --- a/codemeta.json +++ b/codemeta.json @@ -3,7 +3,7 @@ "@type": "SoftwareSourceCode", "name": "Packvium for Node.js", "description": "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.", - "version": "1.7.0", + "version": "1.8.0", "license": "https://spdx.org/licenses/MIT", "author": [ { @@ -26,6 +26,8 @@ "truck-loading", "packing-algorithm", "logistics", - "deterministic" + "deterministic", + "box-packing", + "load-planning" ] } diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index e0949d9..8cdcba6 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -14,8 +14,11 @@ After a result reports `feasible` or `optimal` and passes independent validation - **exact arithmetic** — all geometry is integer, in units of 1/16000 mm for length and 1/8 µg for weight. No coordinate is ever a binary floating-point number, so no placement decision depends on rounding; -- **determinism** — identical input and identical seed produce an identical result, on - any platform, when the search completes or stops on `configuration.effort_budget`. A +- **determinism** — for a fixed backend and version, identical input and seed produce + identical packing and deterministic diagnostics on supported platforms when the search + completes or stops on `configuration.effort_budget`. Wall-clock timing diagnostics, + such as `duration_ms` and `elapsed_ms`, are excluded. This does not promise identical + solutions between backends; their documented native quality differences remain. A search stopped by `time_limit_ms` stops where the host's speed let it, so it may differ between runs; the result says which limit stopped it; - **complete accounting** — every requested item instance appears exactly once, either @@ -24,9 +27,15 @@ After a result reports `feasible` or `optimal` and passes independent validation ## It does not guarantee - **A globally optimal packing.** The `fast`, `balanced` and `quality` profiles are - heuristic. Only `exact_small`, within its item limit, searches exhaustively — and even - then optimality is not certified in the result. A returned solution is a good solution, - not a proven best one. + heuristic, and `exact_small` searches exhaustively only within its item limit. A returned + solution is a good solution, not a proven best one, unless the result proves it: under the + `default` and `lowest_cost` objectives a result states `optimality.lower_bound`, and a + complete result whose score attains that bound reports `optimal` / `proven_optimal`. That + proves no packing obeying the request's rules scores better on that objective; it says + nothing about a rule the request cannot express. Any other stated result carries the gap + to the bound, an upper limit on what a longer search could still gain. The statement is + withheld if its gap exceeds the portable integer ceiling of `2^53 - 1`, even when the + request's bound fits. Where geometry makes a request hard, expect a gap. - **Physical safety in transit.** Nothing here models vibration, shock, acceleration, tipping, strapping or load shifting. A geometrically valid packing can still be an unsafe shipment. @@ -52,7 +61,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.7.0` freezes the public API. Field names, status codes, the objective +Version `1.8.0` freezes the public API. Field names, status codes, the objective vector, the numeric policy and the validation rules do not change without a major version, so any `1.x` is a safe upgrade from any earlier `1.x`. A caret or tilde constraint on `1.0` is enough; an exact pin is no longer required. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index a784f42..d737e88 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -155,10 +155,16 @@ request field. - `termination.code`: `complete`, `time_limit`, `effort_limit`, or `error`; - `optimality.code`: `proven_optimal`, `proven_infeasible`, `best_found`, or `not_proven`. The 1.1.0 freeze reserved and typed `gap_key`, `absolute_gap` and `relative_gap` - alongside it. The pre-1.1.0 extension point remains open: closing the object would - make a previously valid producer fail a 1.1.0 validator, which is a breaking change - and therefore not legal in this minor release. No engine emits the reserved fields - yet, so `code` is still the only key any Packvium result carries; + alongside it, and the 1.8.0 freeze added `lower_bound`: the lower bound on + `score`, in the result's own score order, through the first position the result misses + -- or all of it when none is missed, which proves the result optimal for its objective + under the rules this library models. `gap_key` requires `lower_bound`, and absence of + `lower_bound` makes no statement at all; it is never "attained". The pre-1.1.0 + extension point remains open: closing the object would make a previously valid + producer fail a validator, which is a breaking change. Every engine emits these fields + since, on the primary result and on each alternative, for the `default` and + `lowest_cost` objectives; a complete result that attains its bound reports `status: + optimal` and `code: proven_optimal`, and nothing else does; - legacy `status`: `optimal`, `feasible`, `best_found`, `time_limit`, `infeasible`, or `invalid_result`; - packed containers with exact coordinates, dimensions, rotations, and a `centre_of_mass_offset_ppm` — the weighted centre of mass's exact-integer Chebyshev @@ -238,7 +244,10 @@ objective vector. `algorithm.time_limit_reached` and `algorithm.effort_limit_reached` identify distinct, mutually exclusive causes. The former is only a wall-clock deadline. The latter means -one of the request's counted-work boundaries was reached. An unpacked `time_limit` or +the selected start was stopped by one of the request's counted-work boundaries: the search +had more work to do and the budget refused it. A start that finished having used exactly its +budget was not stopped, and reports neither flag. No counter in `algorithm.metrics` ends +above its limit: each counted step is checked before it is taken. An unpacked `time_limit` or `effort_limit` reason must match the corresponding flag. ## Loading and unloading sequences @@ -401,8 +410,9 @@ deadline: Each fact is an open object with a required string `code`. Consumers must preserve an unknown code and any additional fields, allowing future effort/node/cancellation limits without turning a minor release into a parsing break. `status` remains for one -compatibility cycle and is checked against the three facts. The alpha release emits no -`proven_optimal` claim without a future proof/certificate. +compatibility cycle and is checked against the three facts. `proven_optimal` is emitted only +on a complete result whose score attains its stated `optimality.lower_bound`: a proof that no +packing obeying the request's rules scores better on the result's own objective. `status: infeasible`, with `feasibility: infeasible` and `optimality: proven_infeasible`, is emitted only when the portfolio ranks no result at all. A request whose every item is proven @@ -452,6 +462,23 @@ The fields answer separate questions: | `winning_start_truncated` | The selected packing itself came from an interrupted start. | | `global_deadline_reached` | The portfolio-wide deadline expired, independently of individual start slices. | +**Under an `effort_budget` the clock is the request's fuse, and fires only as a whole.** +Without one, Python and PHP give each start an even share of the time left, so one slow start +cannot starve the rest. With one, every start may use all the time left: a request that +bounds its work by counting must get the same answer on any machine, and a start cut by its +own share of the clock would lose quietly, with no flag saying the answer depended on the +machine's speed. When the clock does fire, the portfolio-wide deadline is reached and +`time_limit_reached` is true. Rust and the JavaScript fallback always run their starts against +the one portfolio deadline. Each start keeps its own counters against the whole budget in +Python, PHP and Rust; the JavaScript fallback hands each later start what the earlier ones +left of one budget, so the same number buys less search there. + +**Read `any_start_truncated` when the answer must not depend on the budget.** The selected +start can finish while another start, one that might have won, was stopped by the budget; the +top-level flags then read `complete`, because they describe the selected start. +`any_start_truncated` is the only field that says a larger budget could change the answer. A +checkout that caches a quote refuses one on it (ECOMMERCE-CHECKOUT-INTEGRATIONS.md D3). + The aggregate fields are derived only from `starts`. Exactly one record is selected. `completed` and `truncated` are mutually exclusive and both require `started`. `termination.code` is `time_limit` when the selected start or portfolio hit the wall @@ -526,9 +553,10 @@ valid. A result without the documented shape is refused as `MalformedResult`, wh same as one JSON document for a binding. - PyO3 (`packvium_native_rust`): `validate_json(request, result)` over JSON text, a list of `{"code", "detail"}` dicts, `MalformedResult` (a `ValueError`) with `kind`. -- `@packvium/engine`: `validate(request, result)`, `MalformedResult`, and - `ValidatorUnavailableError` (`code: 'validator_unavailable'`) when the compiled addon is - absent, until the JavaScript fallback's port. +- `@packvium/engine`: `validate(request, result)` and `MalformedResult`; the compiled addon + answers when it is loaded and the package's JavaScript port otherwise (1.8.0). + `ValidatorUnavailableError` (`code: 'validator_unavailable'`) is still exported but + deprecated: nothing throws it since 1.8.0. - `@packvium/browser`: `validate(request, result)`, a promise, `MalformedResult`. - PHP: `Packvium\Validation\validate(array $request, array $result)`, a `list` @@ -727,3 +755,11 @@ depend on the exact shape. Python, PHP, Rust and the JavaScript fallback accept the same top-level keys: `units`, `configuration`, `items`, `containers`, `catalog_versions_used`, `policy`, `output` and `fixed_placements`. + +`units` holds one key, `length`: the unit of every length written without one, millimetres when +`units`, or `units.length`, is absent or `null`. There is no request-wide weight unit. A weight +takes its unit from the measure itself (`"2.5 kg"`, `{"value": "2.5", "unit": "kg"}`), and a +bare number is grams. `units.weight` is not a field: today every engine ignores it without an +error, so `{"length": "cm", "weight": "kg"}` still reads a bare weight `2` as 2 g. Write the unit +on each weight instead. A future release that changes the request schema will refuse unknown +keys in `units`, as it already refuses them in `configuration`. diff --git a/fallback.js b/fallback.js index d69fcb0..9280818 100644 --- a/fallback.js +++ b/fallback.js @@ -91,11 +91,11 @@ function parseRateTable(raw){ return {brackets,prices,minimum,fuel} } // A made-to-measure container's machine in ticks, or null for a stock container. -function parseMachine(raw,unit){ +function parseMachine(raw,unit,measures){ if(raw==null)return null; - const sides=value=>value==null?[0,0,0]:dims(value,unit); + const sides=value=>value==null?[0,0,0]:measures.sides(value,unit); return {minimum:sides(raw.minimum),increment:sides(raw.increment),price:raw.cost_per_square_metre_minor??0, - tarePerSquareMetre:raw.tare_per_square_metre==null?0:scalar(raw.tare_per_square_metre,'g',WT),allowance:sides(raw.outer_allowance)} + tarePerSquareMetre:raw.tare_per_square_metre==null?0:measures.weight(raw.tare_per_square_metre),allowance:sides(raw.outer_allowance)} } // Billed weight in whole grams, rounded up -- how a carrier reads a scale. A shipment // fractionally over a bracket is in the next bracket; rounding down would price it @@ -369,6 +369,29 @@ function scalar(v,def,table){let raw=v,unit=def;if(v&&typeof v==='object'){raw=v // output code ("Inches" is `in`); a unit no output code names, the internal tick, gives `mm`. function outputLengthUnit(unit){const multiplier=LEN[unit.trim().toLowerCase()];return ['mm','cm','m','in','ft'].find(code=>LEN[code]===multiplier)??'mm'} function dims(v,u){return [scalar(v.length,u,LEN),scalar(v.width,u,LEN),scalar(v.height,u,LEN)]} +/** + * The ticks of every measure one request spells, parsed once. The request check parses + * each stated measure to refuse a bad one, through `checker`; `close` ends the filling before the + * bound and the first start read it, so every start reads the same values and none writes what + * another reads. A spelling the check did not parse is parsed on the spot and not kept; a literal + * 0 is 0 ticks in any unit the check has accepted. `O(1)` per read, `O(distinct spellings)` + * memory, for one request only. + */ +function requestMeasures(){ + const tables=new Map([[LEN,new Map()],[WT,new Map()]]);let open=true; + const read=(table,value,unit)=>{ + if(value===0)return 0; + const byUnit=tables.get(table),known=byUnit.get(unit)?.get(value); + if(known!==undefined)return known; + const ticks=scalar(value,unit,table); + if(open){if(!byUnit.has(unit))byUnit.set(unit,new Map());byUnit.get(unit).set(value,ticks)} + return ticks}; + const length=(value,unit)=>read(LEN,value,unit),weight=value=>read(WT,value,'g'); + const sides=(value,unit)=>[length(value.length,unit),length(value.width,unit),length(value.height,unit)]; + return { + checker:Object.freeze({length:{units:LEN,parse:length},weight:{units:WT,parse:(value,unit)=>read(WT,value,unit)}}), + close(){open=false;return Object.freeze({length,weight,sides})}}; +} // Three-element literals, not `Array.prototype.map`: an inlined `map` in optimised code builds // a holey array where the baseline tiers build a packed one, so every consumer of a box's // sides met two array maps and deoptimised on the second -- the hot candidate sweep included, @@ -624,33 +647,51 @@ function occupiesLessThanItsBox(item){ return item.shapeType==='convex_hull'||item.shapeType==='compressible'; } const boundCeilDiv=(a,b)=>(a+b-1n)/b; -/** The largest n such that the n smallest values sum to at most the capacity. Smallest first - * is the whole soundness argument: the cheapest units maximise how many fit, so this - * over-estimates what any real packing achieves and the bound under-estimates. */ -function boundFit(ascending,capacity){ - if(capacity===null)return ascending.length; - let used=0n; - for(let taken=0;takencapacity)return taken; - } - return ascending.length; -} -/** Sum of limit*quantity, or null when any limit or inventory is undeclared. `zeroIsHarmless` - * is the volume rule: a container with no usable volume adds nothing however many there - * are, so an unlimited quantity only unbounds the total when the type holds something. */ -function boundCapacity(values,quantities,zeroIsHarmless){ +const bigAscending=(a,b)=>ab?1:0; +/** Values sorted ascending, each with how many instances carry it, as `[value, count]` BigInt + * pairs. Formed per item type from a request, so a quantity of a million is one entry. */ +function boundRuns(pairs){ + return pairs.filter(([,count])=>count>0n).sort((a,b)=>bigAscending(a[0],b[0])||bigAscending(a[1],b[1])); +} +const runCount=runs=>runs.reduce((total,[,count])=>total+count,0n); +/** The sum of the `count` values a walk over the runs meets first. */ +function runTake(runs,count){ let total=0n; - for(let i=0;irunTake(runs,count); +const runLargest=(runs,count)=>runTake([...runs].reverse(),count); +/** The largest n such that the n smallest values sum to at most the capacity; all of them + * when it is unbounded. Smallest first is the whole soundness argument: the cheapest units + * maximise how many fit, so this over-estimates what any real packing achieves and the + * bound under-estimates. */ +function boundFit(ascending,capacity){ + if(capacity===null)return runCount(ascending); + let taken=0n,used=0n; + for(const [value,count] of ascending){ + let room=count; + if(value!==0n){const fits=capacity>used?(capacity-used)/value:0n;if(fitslimit(c)===null||(c.quantity===null&&limit(c)>0n)))return null; + let total=0n; + for(const c of containers)if(c.quantity!==null)total+=limit(c)*c.quantity; + return boundGuard(total,'container capacity'); +} /** The largest declared limit, or null if any type declares none: one unlimited type makes * the maximum unbounded and every term conditioned on it vacuous. */ function boundFiniteMax(values){ @@ -661,91 +702,155 @@ function boundFiniteMax(values){ } return best; } -/** Every bound, from the numbers the formulas consume -- the shape the shared scene records, - * so this port is checked without reimplementing a request parser. `shrinks` is taken as - * given; whether this engine decides it correctly is asserted separately. */ -function objectiveBounds(instances,containers){ - const volumes=instances.map(i=>i.volume).sort((a,b)=>ab?1:0); - const weights=instances.map(i=>i.weight).sort((a,b)=>ab?1:0); - const shrinks=instances.some(i=>i.shrinks); - const count=instances.length; - const usable=containers.map(c=>c.usable),inner=containers.map(c=>c.inner); - const quantities=containers.map(c=>c.quantity); - - // 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. - boundGuard(volumes.reduce((a,b)=>a+b,0n),'instance volume'); - boundGuard(weights.reduce((a,b)=>a+b,0n),'instance weight'); - for(const container of containers){ - boundGuard(container.usable,'container capacity'); - boundGuard(container.costMinor,'opening cost'); - } +const bigMax=values=>values.reduce((a,b)=>b>a?b:a,0n); +/** Every bound, in the default objective's order, from the numbers the formulas consume. + * + * The one refusal predicate of docs/OPTIMALITY-CERTIFICATES.md first: the instance volumes, + * the instance weights and each finite capacity total past MAX_BOUND_SUM, or any bound past + * MAX_BOUND_VALUE, refuse. Every later product is bounded by those totals times PPM, which + * `BigInt` carries exactly. Key 2 sorts the types by cost once and takes quantities + * greedily, the sum of the L1 cheapest per-unit costs without building that multiset (F6). */ +function objectiveBoundsFromRuns(volumes,weights,shrinks,containers){ + boundGuard(volumes.reduce((total,[value,count])=>total+value*count,0n),'instance volume'); + boundGuard(weights.reduce((total,[value,count])=>total+value*count,0n),'instance weight'); + const volumeCapacity=boundCapacity(containers,c=>c.usable); + const payloadCapacity=boundCapacity(containers,c=>c.payload); + const slotCapacity=boundCapacity(containers,c=>c.maxItems); + const count=runCount(volumes); - let placeable=count; - if(!shrinks)placeable=Math.min(placeable,boundFit(volumes,boundCapacity(usable,quantities,true))); - placeable=Math.min(placeable,boundFit(weights,boundCapacity(containers.map(c=>c.payload),quantities,false))); - const slotCapacity=boundCapacity(containers.map(c=>c.maxItems),quantities,false); - if(slotCapacity!==null)placeable=Math.min(placeable,Number(slotCapacity)); - const unpacked=count-placeable,placed=placeable; + let placed=count; + const atMost=limit=>{if(limit0&&containers.length){ - opened=1; + let opened=0n; + if(placed>0n&&containers.length){ + opened=1n; + const atLeast=limit=>{if(limit>opened)opened=limit}; if(!shrinks){ - const largest=usable.reduce((a,b)=>b>a?b:a,0n); - if(largest>0n)opened=Math.max(opened,Number(boundCeilDiv(volumes.slice(0,placed).reduce((a,b)=>a+b,0n),largest))); + const largest=bigMax(containers.map(c=>c.usable)); + if(largest>0n)atLeast(boundCeilDiv(runSmallest(volumes,placed),largest)); } const payload=boundFiniteMax(containers.map(c=>c.payload)); - if(payload!==null&&payload>0n)opened=Math.max(opened,Number(boundCeilDiv(weights.slice(0,placed).reduce((a,b)=>a+b,0n),payload))); + if(payload!==null&&payload>0n)atLeast(boundCeilDiv(runSmallest(weights,placed),payload)); const slots=boundFiniteMax(containers.map(c=>c.maxItems)); - if(slots!==null&&slots>0n)opened=Math.max(opened,Number(boundCeilDiv(BigInt(placed),slots))); + if(slots!==null&&slots>0n)atLeast(boundCeilDiv(placed,slots)); } - let cost=0n; - if(opened>0){ - const available=[]; - for(const c of containers){ - const repeat=c.quantity===null?opened:Math.min(Number(c.quantity),opened); - for(let taken=0;takenab?1:0); - cost=available.slice(0,opened).reduce((a,b)=>a+b,0n); + let cost=0n,needed=opened; + for(const c of [...containers].sort((a,b)=>bigAscending(a.costMinor,b.costMinor))){ + if(needed<=0n)break; + const taken=c.quantity===null||c.quantity>needed?needed:c.quantity; + cost+=c.costMinor*taken;needed-=taken; } let unused=0n; - if(!shrinks&&opened>0&&containers.length){ - const smallest=inner.reduce((a,b)=>b0n&&containers.length){ + const smallest=containers.map(c=>c.inner).reduce((a,b)=>b0n){ - const largestPlaced=placed>0?volumes.slice(volumes.length-placed).reduce((a,b)=>a+b,0n):0n; - // In BigInt until it is clamped: `largestPlaced * PPM` can reach 10^36, which a + // In BigInt until it is clamped: the placed volume times PPM can reach 10^36, which a // `Number` would round rather than carry. - const filled=boundCeilDiv(largestPlaced*BOUND_PPM,smallest); - const raw=BigInt(opened)*BOUND_PPM-filled-BigInt(opened-1); + const raw=opened*BOUND_PPM-boundCeilDiv(runLargest(volumes,placed)*BOUND_PPM,smallest)-(opened-1n); unused=raw>0n?raw:0n; } } let height=0n; - if(!shrinks&&opened>0&&containers.length){ - const widest=containers.map(c=>c.baseArea).reduce((a,b)=>b>a?b:a,0n); - const tallest=containers.map(c=>c.height).reduce((a,b)=>b>a?b:a,0n); + if(!shrinks&&opened>0n&&containers.length){ + const widest=bigMax(containers.map(c=>c.baseArea)),tallest=bigMax(containers.map(c=>c.height)); if(widest>0n&&tallest>0n){ - const required=placed>0?boundCeilDiv(volumes.slice(0,placed).reduce((a,b)=>a+b,0n),widest):0n; - const raw=required*BOUND_PPM/tallest-BigInt(opened-1); + const raw=boundCeilDiv(runSmallest(volumes,placed),widest)*BOUND_PPM/tallest-(opened-1n); height=raw>0n?raw:0n; } } return [ - boundOutput(BigInt(unpacked),'unpacked count'), - boundOutput(BigInt(opened),'container count'), + boundOutput(unpacked,'unpacked count'), + boundOutput(opened,'container count'), boundOutput(boundGuard(cost,'opening cost'),'opening cost'), boundOutput(unused,'unused volume'), boundOutput(height,'stack height'), ]; } -/** Exposed for the cross-language scene test only, like `__inspectHullShapeForTests`: the - * bounds are internal until a contract freeze decides whether a caller ever sees a gap. */ +/** Every bound from reduced instances -- the shape the shared scene records, so this port is + * checked without reimplementing a request parser. `shrinks` is taken as given; whether this + * engine decides it correctly is asserted separately. */ +function objectiveBounds(instances,containers){ + return objectiveBoundsFromRuns(boundRuns(instances.map(i=>[i.volume,1n])), + boundRuns(instances.map(i=>[i.weight,1n])),instances.some(i=>i.shrinks),containers); +} +/** Where each covered objective reads the five bounds: the position in the default-order + * vector of each of the objective's own score keys. Any other objective has no bound. */ +const BOUND_ORDERS=new Map([['default',[0,1,2,3,4]],['lowest_cost',[0,2,1,3,4]]]); +/** The request's bound in its objective's own score order, or null where none is stated. + * + * Read from the request itself, as every engine reads it: every item type with its whole + * quantity (fixed placements are part of it), every container type, lengths in + * ticks and volumes formed in `BigInt` from them, never as a `Number` product converted + * afterwards (F3). Null for an objective with no bound, for a request with a made-to-measure + * container (D13), and for a request past a declared ceiling, which withholds the + * bound and nothing else (OPTIMALITY-GAP.md § 1.5). `O(t log t + c log c)` for `t` item + * types and `c` container types. */ +function requestBound(req,measures){ + const order=BOUND_ORDERS.get(req.configuration?.objective??'default'); + if(order===undefined)return null; + const rawContainers=req.containers??[]; + if(rawContainers.some(c=>c.made_to_measure!=null))return null; + const unit=req.units?.length??'mm'; + const items=(req.items??[]).map(raw=>({raw,count:BigInt(raw.quantity??1)})).filter(item=>item.count>0n); + const volumes=boundRuns(items.map(({raw,count})=>[volume(measures.sides(raw.dimensions,unit)),count])); + const weights=boundRuns(items.map(({raw,count})=>[BigInt(measures.weight(raw.weight??0)),count])); + const shrinks=items.some(({raw})=>occupiesLessThanItsBox({nestingHeight:raw.nesting_height??null,shapeType:raw.shape_type??'rigid_cuboid'})); + const containers=rawContainers.map(raw=>{ + const d=measures.sides(raw.inner_dimensions,unit),inner=volume(d); + return {usable:inner-inner*BigInt(scaledRatio(raw.void_fill_reserve_ratio??0))/BigInt(SUPPORT_SCALE),inner, + baseArea:BigInt(d[0])*BigInt(d[1]),height:BigInt(d[2]), + payload:raw.max_payload==null?null:BigInt(measures.weight(raw.max_payload)), + maxItems:raw.max_items==null?null:BigInt(raw.max_items), + quantity:raw.quantity==null?null:BigInt(raw.quantity),costMinor:BigInt(raw.cost_minor??0)}; + }); + let vector; + try{vector=objectiveBoundsFromRuns(volumes,weights,shrinks,containers)} + catch(error){if(error instanceof BoundOverflowError)return null;throw error} + return order.map(position=>vector[position]); +} +/** What one score states against `bound`, both in the result's own score order + * (OPTIMALITY-GAP.md § 1.1): the bound through the first missed position with the gap + * there, or the whole bound when nothing is missed -- the attained case, which carries no + * gap rather than a zero one. Null for a score below its bound, which shows the bound + * wrong: an engine must not publish a claim it has just refuted. At most five comparisons. */ +function boundStatement(score,bound){ + for(let index=0;indexleast){ + const difference=BigInt(achieved)-BigInt(least); + if(difference>MAX_BOUND_VALUE)return null; + const absolute=Number(difference),fields={lower_bound:bound.slice(0,index+1),gap_key:index,absolute_gap:absolute}; + // Unreduced, so the pair reads as (score - bound, bound) by eye. + if(least>0)fields.relative_gap={numerator:absolute,denominator:least}; + return fields; + } + } + return {lower_bound:[...bound]}; +} +/** States the request's bound on a result and, by the same rule, on each alternative + * (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. A result the + * bound does not speak for (§ 1.5) keeps the fact its status gave it. */ +function stateOptimality(result,bound){ + for(const alternative of result.alternatives??[])stateOptimality(alternative,bound); + if(bound===null||result.status==='infeasible'||result.status==='invalid_result')return; + const fields=boundStatement(result.score,bound); + if(fields===null)return; + const attained=!('gap_key' in fields); + if(result.complete&&attained)result.status='optimal'; + result.optimality={code:result.status==='optimal'?'proven_optimal':result.complete?'not_proven':'best_found',...fields}; +} +/** Exposed for the cross-language scene test only, like `__inspectHullShapeForTests`. */ export function __objectiveBoundsForTests(instances,containers){return objectiveBounds(instances,containers);} +export function __requestBoundForTests(req){return requestBound(req,requestMeasures().close());} export function __occupiesLessThanItsBoxForTests(item){return occupiesLessThanItsBox(item);} function boxShape(dx,dy,dz){ const v=[]; @@ -1065,6 +1170,14 @@ function axleOverloaded(container,placements,extra=null){const reaction=axleReac const [front,rear]=container.axleSpec; return (front.max!=null&&reaction.front>BigInt(front.max)*reaction.denominator) ||(rear.max!=null&&reaction.rear>BigInt(rear.max)*reaction.denominator)} +// On the measured Linux/Node 22 workloads, keeping the null guard inside this call avoided +// the larger TurboFan inlining graph observed with a call-site guard. +function axleOverloadedWithBase(container,baseLoad,extra){ + if(baseLoad===null)return false; + const [front,rear]=container.axleSpec,w=BigInt(extra.item.w),total=baseLoad.total+w,weighted=baseLoad.moment+w*BigInt(2*extra.x+extra.ed[0]); + const denominator=2n*BigInt(rear.position-front.position); + const frontLoad=2n*total*BigInt(rear.position)-weighted,rearLoad=weighted-2n*total*BigInt(front.position); + return (front.max!=null&&frontLoad>BigInt(front.max)*denominator)||(rear.max!=null&&rearLoad>BigInt(rear.max)*denominator)} // Whether one item, alone in an empty container, has an x-origin that overloads // neither axle. Each limit is linear in the item's doubled centre, so the feasible origins // form one interval whose ends are exact BigInt quotients; O(1). Not a proof: a @@ -1730,10 +1843,10 @@ function lessThan(left,right){ * and the compact lattice path admit exactly the same requests. Keeping this inside the * general path's own item-building loop is what let the two disagree. */ -function admitItem(raw,u){ +function admitItem(raw,u,measures){ if(!Number.isSafeInteger(raw.quantity??1)||(raw.quantity??1)<1)throw new RangeError('item quantity must be positive'); if(!isUnitRatio(raw.minimum_support_ratio??0))throw new RangeError('minimum_support_ratio must be between 0 and 1'); - const d=dims(raw.dimensions,u),nesting=raw.nesting_height==null?null:scalar(raw.nesting_height,u,LEN); + const d=measures.sides(raw.dimensions,u),nesting=raw.nesting_height==null?null:measures.length(raw.nesting_height,u); if(nesting!=null&&(nesting<0||nesting>=d[2]))throw new RangeError("nesting_height must be at least zero and strictly less than the item's own height"); if(raw.max_stacked_items!=null&&(!Number.isSafeInteger(raw.max_stacked_items)||raw.max_stacked_items<1))throw new RangeError('max_stacked_items must be a positive safe integer'); if(raw.stop_index!=null&&(!Number.isSafeInteger(raw.stop_index)||raw.stop_index<0))throw new RangeError('stop_index must be a non-negative safe integer'); @@ -1803,7 +1916,7 @@ function latticePlacements({best,count,firstIndex,raw,weight,clear,ou,ow}){ // Any `fixed_placements` but an empty list stands the closed-form path down; a malformed one // goes on to admission, which refuses it, rather than being answered as if it were absent. const hasFixedPlacements=req=>Array.isArray(req.fixed_placements)?req.fixed_placements.length>0:req.fixed_placements!=null; -function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortExceeded,effortRemaining,deadline}){ +function compactGridResult(req,{measures,u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortStops,effortStopped,effortRemaining,deadline}){ // Keep this path opt-in until its capacity-first container choice is // proven objective-equivalent to the general path. Admission, effort accounting, // injected deadlines and metrics now have direct parity tests; objective quality is @@ -1824,7 +1937,7 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver // search checks both per candidate. ||(raw.shape_type!=null&&raw.shape_type!=='rigid_cuboid') ||!['free',null,undefined].includes(raw.ground_contact_rule))return null; - const itemDimensions=dims(raw.dimensions,u),weight=scalar(raw.weight??0,'g',WT); + const itemDimensions=measures.sides(raw.dimensions,u),weight=measures.weight(raw.weight??0); const rotations=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)); if(!Array.isArray(rotations)||rotations.some(rotation=>ROT[rotation]===undefined))return null; const templates=[]; @@ -1836,9 +1949,9 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver ||container.deformable_boundary!=null // A lattice sized against the machine maximum describes no box cut to measure (D12). ||container.made_to_measure!=null)return null; - const inner=dims(container.inner_dimensions,u),outer=container.outer_dimensions?dims(container.outer_dimensions,u):inner; - templates.push({...container,d:inner,outerD:outer,max:container.max_payload==null?null:scalar(container.max_payload,'g',WT), - tare:scalar(container.tare_weight??0,'g',WT),rate:parseRateTable(container.rate_table)}) + const inner=measures.sides(container.inner_dimensions,u),outer=container.outer_dimensions?measures.sides(container.outer_dimensions,u):inner; + templates.push({...container,d:inner,outerD:outer,max:container.max_payload==null?null:measures.weight(container.max_payload), + tare:measures.weight(container.tare_weight??0),rate:parseRateTable(container.rate_table)}) } // `lowest_landed_cost` never reaches this path: packFallback forces `compact=null` // for that objective (see the exclusion beside the policy-rule gate), because this @@ -1874,7 +1987,7 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver // holds for the whole lattice exactly when the type carries itself. if(raw.must_be_on_floor||!carriesItself(raw))nz=Math.min(nz,1); if(raw.max_top_load!=null&&weight>0){ - const safeLayers=BigInt(scalar(raw.max_top_load,'g',WT))/BigInt(weight)+1n; + const safeLayers=BigInt(measures.weight(raw.max_top_load))/BigInt(weight)+1n; if(safeLayers0&&containers.length0&&containers.length{for(let i=0;ientry.p)[0]; if(plan==null)break; + // Asked only once another batch would be counted, so a budget used exactly is not a stop. + if(effortStops())break; plan.opened++; - const template=plan.template,best=plan.best; + const template=plan.template,best=plan.best;let cut=false; { // A compact result represents each placement without iterating over it, but each // placement still consumes the same three public effort counters as the general // path. Bound the closed-form batch by the smallest remaining allowance so this - // optimisation cannot jump across a caller's exact deterministic limit. - const count=Math.min(best.capacity,remaining,effortRemaining()); + // optimisation cannot jump across a caller's exact deterministic limit. A batch the + // budget cuts short is a stop: what it has room for is placed, and the search ends. + const wanted=Math.min(best.capacity,remaining),count=Math.min(wanted,effortRemaining()); if(count<=0)break; + cut=count=maxContainers)break } // Same reason vocabulary and precedence the general path uses: an item left behind // because the clock or the effort budget ran out did not fail to fit, and reporting it // as exhausted inventory would send a caller looking for a container that was never - // the problem. - const effortLimitReached=effortExceeded(); + // the problem. Only a batch the budget refused counts, not a budget used exactly. + const effortLimitReached=effortStopped(); // An item no template can hold under any permitted rotation did not run out of // inventory -- there was never a container for it. `plans` drops every zero-capacity // template before the loop starts, so an empty `plans` with nothing opened is exactly @@ -2064,7 +2182,9 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver const ORDER_INSENSITIVE_SOLVERS=new Set(['grid','layer','homogeneous_blocks']); function seededOrder(items,seed,startIndex){ const ordered=items.slice(); - let state=(seed^(startIndex*0x9e3779b1))>>>0||0x9e3779b1; + // Like every engine, interpret the caller's seed by its nonnegative residue. + const residue=((seed%0x80000000)+0x80000000)%0x80000000; + let state=(residue^(startIndex*0x9e3779b1))>>>0||0x9e3779b1; const next=()=>{state^=state<<13;state>>>=0;state^=state>>>17;state^=state<<5;state>>>=0;return state}; for(let i=ordered.length-1;i>0;i--){const j=next()%(i+1);[ordered[i],ordered[j]]=[ordered[j],ordered[i]]} return ordered @@ -2072,7 +2192,7 @@ function seededOrder(items,seed,startIndex){ // A portfolio's reported effort is what the whole portfolio spent, not what its winner // spent. Each run here is a separate `packFallback` call with its own metrics closure, so // without this the record understates the work by the number of runs. -// The counters each effort limit caps, as `effortExceeded` reads them. +// The counters each effort limit caps, as a frame's effort ceilings read them. const EFFORT_COUNTERS=[['max_search_nodes','search_nodes_expanded'],['max_candidates_evaluated','feasible_candidates'],['max_placement_attempts','orientations_considered']]; /** What the request's effort budget leaves after `runs`; a spent limit is 0. * @@ -2135,9 +2255,9 @@ function portfolioStarts(runs,winnerIndex,globalDeadlineReached){ global_deadline_reached:start.global_deadline_reached||globalDeadlineReached, }))) } -// Lattice nodes the block start added to `fast` and `balanced` may enumerate per mode -- -// the bound `quality` gives the same solver through `container_plan_node_limit`, which -// those two profiles leave at one: too few for the enumeration to reach its first block. +// Lattice nodes the block solver may enumerate per mode under every profile but `quality` +// -- the bound `quality` gives it through `container_plan_node_limit`, which the others +// leave at one: too few for the enumeration to reach its first block. const BLOCK_START_NODE_LIMIT=100000; // Lattice nodes between two clock reads of the block enumeration. const BLOCK_CLOCK_PERIOD=64; @@ -2298,9 +2418,16 @@ const MEASURES=Object.freeze({length:{units:LEN,parse:(value,unit)=>scalar(value export function packFallback(req,clock=monotonicNow,solverAlias=null,startIndex=null,sharedDeadline=null){ if(!isObject(req))throw new InvalidRequestError('wrong_type','','must be an object'); rejectUnsupported(req); - checkRequest(req,MEASURES); - const result=solveChecked(req,clock,solverAlias,startIndex,sharedDeadline); + const filling=requestMeasures(); + checkRequest(req,filling.checker); + const measures=filling.close(); + // Once per pack, from the request alone and before the first solver runs, so computing + // the bound never depends on which start won or how much of the deadline was left + // (OPTIMALITY-GAP.md F3). + const bound=requestBound(req,measures); + const result=solveChecked(req,clock,solverAlias,startIndex,sharedDeadline,measures); warnUnsupportedPlacements(result,req.configuration?.minimum_support_ratio??0); + stateOptimality(result,bound); return result; } // The default minimum_support_ratio of 0 is kept so that no packing answer changes, @@ -2327,9 +2454,9 @@ export function checkRequestShape(req){ if(!isObject(req))throw new InvalidRequestError('wrong_type','','must be an object'); checkRequest(req,MEASURES); } -function solveChecked(req,clock,solverAlias,startIndex,sharedDeadline){ +function solveChecked(req,clock,solverAlias,startIndex,sharedDeadline,measures){ const phase={solving:false}; - try{return solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,phase)} + try{return solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,measures,phase)} catch(error){throw phase.solving?error:asRequestError(error)} } function asRequestError(error){ @@ -2344,7 +2471,7 @@ function asRequestError(error){ // is a multi-start run's say over one start's ordering: with `seen`, the start answers null // for an item-type sequence already in it and otherwise reports its own; with `swapAt`, the // start runs its ordering with that item and the next swapped. -function solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,phase,addedBlockStart=false,startOrder=null){ +function solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,measures,phase,addedBlockStart=false,startOrder=null){ // Admit the doors here, beside the other request-admission checks, and not // where they are canonicalised. The container template is built after the // uniform-lattice fast path has already returned, so validating there let a request @@ -2394,7 +2521,7 @@ const addsBlockStart=solverAlias===null&&startIndex===null&&requestedSolvers.len // the deadline left no time for is recorded as never started, like any other restart. const withAddedBlockStart=own=>{ const late=deadline.expired(),left=effort==null?null:remainingEffort(effort,[own]); - const blocks=solveFrame(withEffort({...req,configuration:{...(req.configuration??{}),multi_start_orders:1}},left),clock,'homogeneous_blocks',null,deadline,phase,true); + const blocks=solveFrame(withEffort({...req,configuration:{...(req.configuration??{}),multi_start_orders:1}},left),clock,'homogeneous_blocks',null,deadline,measures,phase,true); if(blocks===null)return own; const runs=late?[own]:[own,blocks],winnerIndex=!late&&compareScore(blocks.score,own.score)<0?1:0,winner=runs[winnerIndex],globalDeadlineReached=deadline.expired(); const starts=portfolioStarts(runs,winnerIndex,globalDeadlineReached);if(late)starts.push(unstartedRecord('homogeneous_blocks',0,globalDeadlineReached)); @@ -2417,7 +2544,7 @@ const finalizeOutermost=answer=>{ }; if(solverAlias===null&&requestedSolvers.length===0&&profile==='quality'){ const child={...req,configuration:{...(req.configuration??{}),solvers:['homogeneous_blocks','extreme_points','maximal_spaces','layer']}}; - return solveFrame(child,clock,null,null,deadline,phase) + return solveFrame(child,clock,null,null,deadline,measures,phase) } if(solverAlias===null&&requestedSolvers.length){ let remainingStarts=restartLimit; @@ -2434,7 +2561,7 @@ if(solverAlias===null&&requestedSolvers.length){ if(runs.length&&deadline.expired())break; const left=effort==null?null:remainingEffort(effort,runs); const childRequest=withEffort({...req,configuration:{...(req.configuration??{}),solvers:[],multi_start_orders:plan.count}},left); - runs.push(solveFrame(childRequest,clock,plan.name,null,deadline,phase)); + runs.push(solveFrame(childRequest,clock,plan.name,null,deadline,measures,phase)); } let winnerIndex=0;for(let index=1;index1){ const child=stagedPlanSearch?{...req,configuration:{...(req.configuration??{}),max_candidates_per_item:1,container_plan_beam_width:1,container_plan_node_limit:1}}:req; const runStart=(request,index,startOrder,label)=>{ const left=effort==null?null:remainingEffort(effort,runs); - const run=solveFrame(withEffort(request,left),clock,solverAlias,index,deadline,phase,false,startOrder); + const run=solveFrame(withEffort(request,left),clock,solverAlias,index,deadline,measures,phase,false,startOrder); if(run!==null){runs.push(run);labels.push(label)} return run!==null}; // Instances of one item are interchangeable, so a start whose item-type sequence an earlier @@ -2499,7 +2626,7 @@ if(startIndex===null&&multiStartOrders>1){ if(suffix)winner.algorithm={...winner.algorithm,solver:`${winner.algorithm.solver}${suffix}`}; return finalizeOutermost(winner); } -const u=req.units?.length??'mm',ou=req.output?.length_unit??outputLengthUnit(u),ow=req.output?.weight_unit??'g',clear=scalar(req.configuration?.clearance??0,u,LEN); +const u=req.units?.length??'mm',ou=req.output?.length_unit??outputLengthUnit(u),ow=req.output?.weight_unit??'g',clear=measures.length(req.configuration?.clearance??0,u); const objective=req.configuration?.objective??'default';if(!['default','lowest_cost','shipping_cost','lowest_landed_cost','open_dimension_height','maximum_value','maximum_volume'].includes(objective))throw new RangeError(`unknown objective ${JSON.stringify(objective)}`); // `maximum_volume` fills space before it counts pieces, so every comparison below that // leads with a piece count leads with placed volume instead when this is set. @@ -2538,22 +2665,29 @@ let timeLimitReached=false; // one, and only where a wall-clock stop lands can move. let candidateClockTick=0; const metrics={candidate_points_considered:0,orientations_considered:0,feasible_candidates:0,collision_checks:0,support_checks:0,space_partitions:0,search_nodes_expanded:0}; -const candidateEffortExceeded=()=>effort!=null&&( - (effort.max_candidates_evaluated!=null&&metrics.feasible_candidates>=effort.max_candidates_evaluated) - ||(effort.max_placement_attempts!=null&&metrics.orientations_considered>=effort.max_placement_attempts)), - effortExceeded=()=>candidateEffortExceeded()||(effort!=null&&effort.max_search_nodes!=null&&metrics.search_nodes_expanded>=effort.max_search_nodes); -const effortRemaining=()=>effort==null?Number.MAX_SAFE_INTEGER:Math.min( - effort.max_candidates_evaluated==null?Number.MAX_SAFE_INTEGER:effort.max_candidates_evaluated-metrics.feasible_candidates, - effort.max_placement_attempts==null?Number.MAX_SAFE_INTEGER:effort.max_placement_attempts-metrics.orientations_considered, - effort.max_search_nodes==null?Number.MAX_SAFE_INTEGER:effort.max_search_nodes-metrics.search_nodes_expanded, -); +// The value of each budgeted counter at which the effort budget stops this frame's search; +// Infinity for a limit the request leaves out. `let`: the support replay lifts them, as +// Python runs that diagnosis outside the caller's budget. +let attemptCeiling=effort?.max_placement_attempts??Infinity,candidateCeiling=effort?.max_candidates_evaluated??Infinity,nodeCeiling=effort?.max_search_nodes??Infinity; +// Set when a guard found its counter at its ceiling just before counting a step, and +// refused it. The effort flag reads this, not the counters afterwards, so a start that +// finishes on exactly its budget is not truncated. Each counter is guarded where it moves -- +// a node before it is expanded, an attempt before it is tried, a candidate before it is +// counted -- so none passes its limit and none stops work another counter would count. Once +// set, every guard refuses, and the code between searches reads only this. +let effortStopped=false; +// The guard before a search node is counted. +const nodeStops=()=>effortStopped||(metrics.search_nodes_expanded>=nodeCeiling&&(effortStopped=true)); +// The guard before a step that moves all three counters at once, as a lattice batch does. +const effortStops=()=>effortStopped||((metrics.orientations_considered>=attemptCeiling||metrics.feasible_candidates>=candidateCeiling||metrics.search_nodes_expanded>=nodeCeiling)&&(effortStopped=true)); +const effortRemaining=()=>Math.min(candidateCeiling-metrics.feasible_candidates,attemptCeiling-metrics.orientations_considered,nodeCeiling-metrics.search_nodes_expanded); // Admission runs before either solver path, not inside the general one. // These checks used to live in the item-building loop below, which the compact lattice // path returns before -- so a request that reached that path was admitted under weaker // rules than every other request. A negative `Item.value` was accepted and ignored // whenever a caller set `require_placement_coordinates: false`, which the acceptance // rules forbid in as many words. -for(const raw of req.items??[])admitItem(raw,u); +for(const raw of req.items??[])admitItem(raw,u,measures); // Admission for the rule set too, and for the same reason: a malformed rule reaching a // solver path would be dropped there, and a request that packs in a way its own policy // forbids is exactly what the contract exists to prevent. @@ -2570,14 +2704,15 @@ const policyRules=parsePolicy(req.policy); // price, so this path can settle on an unpriceable container over a priced one before // pricing ever entered the decision -- the same reason it already stands down for a // registered policy rule. -const compact=(policyRules.length||objective==='lowest_landed_cost')?null:compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortExceeded,effortRemaining,deadline}); +const compact=(policyRules.length||objective==='lowest_landed_cost')?null:compactGridResult(req,{measures,u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortStops,effortStopped:()=>effortStopped,effortRemaining,deadline}); if(compact!==null)return compact; -const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scalar(raw.weight??0,'g',WT),rots=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)),nesting=raw.nesting_height==null?null:scalar(raw.nesting_height,u,LEN),accepts=acceptedStackingGroups(raw); +const items=[];for(const raw of req.items){const d=measures.sides(raw.dimensions,u),w=measures.weight(raw.weight??0),rots=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)),nesting=raw.nesting_height==null?null:measures.length(raw.nesting_height,u),accepts=acceptedStackingGroups(raw); + const maxTop=raw.max_top_load==null?null:measures.weight(raw.max_top_load); // The ordering keys below are functions of the item alone, computed once per type here // rather than as fresh BigInts on both sides of every comparison the sort makes. const vol=volume(d),longest=Math.max(...d),base=BigInt(d[0])*BigInt(d[1]); for(let i=1;i<=(raw.quantity??1);i++)items.push({raw,d,w,rots,vol,longest,base,id:`${raw.id}#${i}`, - stackable:raw.stackable!==false,maxTop:raw.max_top_load==null?null:scalar(raw.max_top_load,'g',WT), + stackable:raw.stackable!==false,maxTop, supportPpm:supportPpm(raw.minimum_support_ratio??0),priority:raw.priority??0, tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null, nesting,maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null, @@ -2648,12 +2783,12 @@ if(startOrder?.seen!=null){const typeOf=new Map(req.items.map((raw,index)=>[raw, if(startOrder.seen.has(key))return null; startOrder.seen.add(key);startOrder.sequence=sequence; for(let at=0;at+1{const d=dims(c.inner_dimensions,u),axleSpec=c.axles==null?null:c.axles.map(a=>({position:scalar(a.position,u,LEN),max:a.max_load==null?null:scalar(a.max_load,'g',WT)})); +const templates=req.containers.map(c=>{const d=measures.sides(c.inner_dimensions,u),axleSpec=c.axles==null?null:c.axles.map(a=>({position:measures.length(a.position,u),max:a.max_load==null?null:measures.weight(a.max_load)})); if(axleSpec&&(axleSpec.length!==2||axleSpec[0].position>=axleSpec[1].position||axleSpec[0].position<0||axleSpec[1].position>d[0]))throw new RangeError('axles must be [front, rear] inside the container'); const reservePpm=scaledRatio(c.void_fill_reserve_ratio??0); if(c.tag_limits!=null&&(typeof c.tag_limits!=='object'||Array.isArray(c.tag_limits)||Object.values(c.tag_limits).some(limit=>!Number.isSafeInteger(limit)||limit<1)))throw new RangeError('tag_limits must map strings to positive safe integers'); - const maxStackDensity=c.max_stack_density==null?null:scalar(c.max_stack_density,'g',WT);if(maxStackDensity!=null&&maxStackDensity<0)throw new RangeError('max_stack_density must be non-negative'); - const outerD=c.outer_dimensions?dims(c.outer_dimensions,u):d; + const maxStackDensity=c.max_stack_density==null?null:measures.weight(c.max_stack_density);if(maxStackDensity!=null&&maxStackDensity<0)throw new RangeError('max_stack_density must be non-negative'); + const outerD=c.outer_dimensions?measures.sides(c.outer_dimensions,u):d; // innerVolume/reserve are pure functions of the immutable template, hoisted out of // candidatesFor's innermost (point x rotation) loop where they were recomputed as // fresh BigInts per orientation. @@ -2663,10 +2798,10 @@ const templates=req.containers.map(c=>{const d=dims(c.inner_dimensions,u),axleSp // Python, PHP and Rust decoders apply, and the reason all four agree on the answer. validateDirections(c.access_directions??[]); const doors=Object.freeze(ALL_DIRECTIONS.filter(d=>(c.access_directions??[]).includes(d))); - return {...c,d,outerD,doors,bag:c.deformable_boundary!=null,machine:parseMachine(c.made_to_measure,u),max:c.max_payload==null?null:scalar(c.max_payload,'g',WT),tare:scalar(c.tare_weight??0,'g',WT),axleSpec,reservePpm, + return {...c,d,outerD,doors,bag:c.deformable_boundary!=null,machine:parseMachine(c.made_to_measure,u,measures),max:c.max_payload==null?null:measures.weight(c.max_payload),tare:measures.weight(c.tare_weight??0),axleSpec,reservePpm, innerVolume:volume(d),reserve:volume(d)*BigInt(reservePpm)/BigInt(SUPPORT_SCALE), rate:parseRateTable(c.rate_table),tagLimits:c.tag_limits??{},maxStackDensity, - obs:(c.obstacles??[]).flatMap(o=>[o,...(o.additional_boxes??[])]).map(o=>({x:scalar(o.origin?.x??0,u,LEN),y:scalar(o.origin?.y??0,u,LEN),z:scalar(o.origin?.z??0,u,LEN),d:dims(o.dimensions,u)}))}}).sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost'?(dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))):((a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d)))); + obs:(c.obstacles??[]).flatMap(o=>[o,...(o.additional_boxes??[])]).map(o=>({x:measures.length(o.origin?.x??0,u),y:measures.length(o.origin?.y??0,u),z:measures.length(o.origin?.z??0,u),d:measures.sides(o.dimensions,u)}))}}).sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost'?(dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))):((a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d)))); // Admission of fixed placements, before any search: the containers they name, each holding // only its fixed items, in opening order. Their instances leave the free list. const fixedGroups=admitFixedPlacements(req,{items,templates,u,clear}); @@ -2780,8 +2915,9 @@ const sweepCandidates=(tmpl,item,state,candidates,index,used,width,supportRatioP //: O(p) once per sweep, O(1) per candidate, and a verdict never flips later. // Read as scalars, not destructured, so a rigid container pays nothing for the rule. const bag=Boolean(tmpl.bag),bagExtents=bag?bagExtent(placements):null,bagX=bag?bagExtents[0]:0,bagY=bag?bagExtents[1]:0,bagZ=bag?bagExtents[2]:0; - candidatePoints:for(let pointIndex=0;pointIndex=attemptCeiling){effortStopped=true;break}metrics.candidate_points_considered++;const x=pt[0],y=pt[1],z=pt[2]; + for(let orientationIndex=0;orientationIndex=attemptCeiling){effortStopped=true;break candidatePoints}metrics.orientations_considered++;if((candidateClockTick++&63)===0&&deadline.expired()){timeLimitReached=true;break candidatePoints} const {r,pd,ed}=orientation,x2=x+ed[0],y2=y+ed[1],z2=z+ed[2]; if(x2>innerX||y2>innerY||z2>innerZ)continue; if(bag&&(Math.max(x2,bagX)+Math.max(z2,bagZ)>innerX||Math.max(y2,bagY)+Math.max(z2,bagZ)>innerY))continue; @@ -2823,7 +2959,7 @@ const sweepCandidates=(tmpl,item,state,candidates,index,used,width,supportRatioP } if(collision)continue; if(candidate===null)candidate={x,y,z,pd,ed,r,item}; - if(axleOverloaded(tmpl,placements,candidate))continue; + if(axleOverloadedWithBase(tmpl,baseAxle,candidate))continue; if(!allowed(candidate,placements,tmpl,supportRatioPpm,metrics,loadBase,accessBase,sweep))continue; // With zero load the candidate is at its largest, and appending it can only shrink // existing compressible supports. If that upper bound fits, the exact support-graph @@ -2832,6 +2968,9 @@ const sweepCandidates=(tmpl,item,state,candidates,index,used,width,supportRatioP if(compressionSensitive){const upperBound=used+occupiedVolume(candidate); if(upperBound+tmpl.reserve>tmpl.innerVolume &&usedVolume([...placements,candidate])+tmpl.reserve>tmpl.innerVolume)continue} + // Each counter is guarded where it moves: a spent candidate budget refuses this candidate, + // not the attempts before it that find nothing feasible. + if(metrics.feasible_candidates>=candidateCeiling){effortStopped=true;break candidatePoints} metrics.feasible_candidates++; const score=farCornerRanked?0:solverAlias==='grid' ?z*1e12+y*1e6+x @@ -2860,7 +2999,7 @@ const sweepCandidates=(tmpl,item,state,candidates,index,used,width,supportRatioP // already run. const candidatesFor=(tmpl,item,state,points,index,used,width,supportRatioPpm)=>{ const found=sweepCandidates(tmpl,item,state,points.length>maxCandidatePoints?points.slice(0,maxCandidatePoints):points,index,used,width,supportRatioPpm); - if(tmpl.axleSpec==null||item.w<=0||found.length||timeLimitReached||candidateEffortExceeded())return found; + if(tmpl.axleSpec==null||item.w<=0||found.length||timeLimitReached)return found; return sweepCandidates(tmpl,item,state,axleBalancedPoints(tmpl,state.placements,points,item,clear),index,used,width,supportRatioPpm)}; const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const seeded=seedState(tmpl),state=seeded.state;const next=[]; // Running `usedVolume` of `state.placements`, maintained incrementally rather than @@ -2876,7 +3015,7 @@ const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const seeded=seedState(tmpl),s const snapshotPoints=batch.length>1?points.slice():null,snapshotIndex=batch.length>1?copyIndex(index):null;let ok=true; for(const item of batch){ if(deadline.expired()){timeLimitReached=true;ok=false;break} - if(effortExceeded()){ok=false;break} + if(nodeStops()){ok=false;break} metrics.search_nodes_expanded++; const best=candidatesFor(tmpl,item,state,points,index,used,1,globalSupportPpm)[0]; if(!best){ok=false;break} @@ -2976,7 +3115,7 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ let pendingFrom=null; for(let position=0;position=containerPlanNodeLimit||deadline.expired()||effortExceeded()){exhausted=true;break}nodes++;metrics.search_nodes_expanded++; + for(const node of beam){if(nodes>=containerPlanNodeLimit||deadline.expired()||nodeStops()){exhausted=true;break}nodes++;metrics.search_nodes_expanded++; const children=[]; if(batch.length===1){for(const candidate of candidatesFor(tmpl,batch[0],node.state,node.points,node.index,node.used,maxCandidatesPerItem,globalSupportPpm)) children.push(lazyChild(node,candidate))}else{ @@ -3128,7 +3267,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ const completeLowerBound=optimisticCompletionScore(freshWork(),suffixItems[0]); const dfs=(depth,work,reachable)=>{ if(deadline.expired()){timeLimitReached=true;return} - if(effortExceeded())return; + if(nodeStops())return; metrics.search_nodes_expanded++; const unpackedHere=itemsRemaining.length-(work.state.placements.length-fixedCount); // Both count gates -- this one and the reachable-count cut below -- assume the vector @@ -3144,7 +3283,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ dfs(depth+1,child,rest); if(compareScore(bestRank,completeLowerBound)===0)return; if(deadline.expired()){timeLimitReached=true;return} - if(effortExceeded())return} + if(effortStopped)return} dfs(depth+1,work,rest)}; dfs(0,freshWork(),itemsRemaining.length); const placed=new Set(best.state.placements.map(p=>p.item.id)); @@ -3152,10 +3291,11 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ }; // Deterministic solid single-type block search. Candidate enumeration is // O(B*S*T*R*X*Y) time and O(S+n) space; the shared deadline/effort counters and -// containerPlanNodeLimit bound it exactly as in Python, PHP and Rust. The start added to -// `fast` and `balanced` carries its own node bound, and `fast`, being latency-bound, runs -// only the one enumeration its objective ranks by. -const blockNodeLimit=addedBlockStart?BLOCK_START_NODE_LIMIT:containerPlanNodeLimit; +// containerPlanNodeLimit bound it exactly as in Python, PHP and Rust. Outside `quality`, +// whether named in `solvers` or added to `fast` and `balanced`, it carries its own node +// bound, and `fast`'s added start, being latency-bound, runs only the one +// enumeration its objective ranks by. +const blockNodeLimit=qualityProfile?containerPlanNodeLimit:BLOCK_START_NODE_LIMIT; const blockModes=addedBlockStart&&profile==='fast'?[byVolume]:[false,true]; // A block set on a smaller one overhangs it, so a request that asks for support is left // to the per-item search. @@ -3185,11 +3325,11 @@ const blockFinish=(round,reached)=>({state:round.state,next:round.itemIds.flatMa // One round's `leaders` leading blocks, best first, under `nodeLimit` and the effort budget, // and whether a limit stopped the round (which then commits nothing). `counter.nodes` is the // mode's node count so far. -const blockLeading=(round,volumeFirst,leaders,nodeLimit,counter)=>{const {state,spaces,byType,itemIds,lattices}=round,tmpl=state.tmpl,nodes=counter.nodes; - // The counted limits cannot move during one round's enumeration: the candidate counters - // change only when a block is placed, and the search-node counter moves with `nodes`. - const stopAt=candidateEffortExceeded()?nodes:Math.min(nodeLimit, - effort?.max_search_nodes==null?Infinity:nodes+Math.max(0,effort.max_search_nodes-metrics.search_nodes_expanded)); +const blockLeading=(round,volumeFirst,leaders,nodeLimit,counter)=>{if(effortStopped)return {leading:[],reached:true}; + const {state,spaces,byType,itemIds,lattices}=round,tmpl=state.tmpl,nodes=counter.nodes; + // Only the search-node counter moves during one round's enumeration; the candidate + // counters move when a block is placed, and `blockCommit` guards them there. + const budgetStopAt=nodes+Math.max(0,nodeCeiling-metrics.search_nodes_expanded),stopAt=Math.min(nodeLimit,budgetStopAt); // A type's capacity and lattices do not depend on the space they are tried in. const types=[]; for(let slot=0;slot{const {state, // stopped round places nothing, so its incumbent and the nodes past the stop never count. const stop=blockStopNode(nodes,scan.nodes-nodes,stopAt,deadline),end=stop>=0?stop:scan.nodes; metrics.search_nodes_expanded+=end-nodes;counter.nodes=end; - if(stop>=0)return {leading:[],reached:true}; + // A clock stop lands before `stopAt`; one on it is the node budget's only where the solver's + // own node limit would not have stopped there anyway. + if(stop>=0){if(stop===budgetStopAt&&budgetStopAt1?scan.leading:scan.best==null?[]:[scan.best],reached:false}}; -// Place every member of `best`, bottom layer first, and carve it from the free spaces. -const blockCommit=(round,best)=>{const chosen=round.byType.get(best.itemId).splice(0,best.count),state=round.state;let index=0; - for(let z=0;z{const state=round.state,perLayer=best.nx*best.ny; + const members=Math.max(0,Math.min(best.count,attemptCeiling-metrics.orientations_considered,candidateCeiling-metrics.feasible_candidates)); + const chosen=round.byType.get(best.itemId).splice(0,members); + for(let index=0;index{let reached=false; while(blockOpen(round)){const step=blockLeading(round,volumeFirst,1,nodeLimit,counter); if(step.reached){reached=true;break} if(!step.leading.length)break; - blockCommit(round,step.leading[0])} + if(!blockCommit(round,step.leading[0])){reached=true;break}} return blockFinish(round,reached)}; const blockMode=(tmpl,itemsRemaining,volumeFirst)=>blockComplete(blockStart(tmpl,itemsRemaining),volumeFirst,blockNodeLimit,{nodes:0}); const blockModeKey=candidate=>{const placements=candidate.state.placements,used=usedVolume(placements),top=placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0); @@ -3251,13 +3401,15 @@ const blockLookahead=(tmpl,itemsRemaining,volumeFirst,greedy)=>{const round=bloc while(blockOpen(round)){const step=blockLeading(round,volumeFirst,BLOCK_LOOKAHEAD_WIDTH,nodeLimit,counter); if(step.reached||!step.leading.length)break; let chosen=known,chosenBlock=step.leading[0],reached=false; - for(let index=known==null?0:1;index{if(!homogeneousBlocksSupported()||tmpl.fixed)return tryPackIntoTemplate(tmpl,itemsRemaining); // Modes are ranked by (lead, top, placement signature). A single mode is never ranked, and @@ -3325,11 +3477,12 @@ const madeToMeasureTrial=(tmpl,itemsRemaining)=>{ const rungs=solvingEnvelopes(tmpl.machine,tmpl.d,itemsRemaining.map(item=>item.d.map(edge=>edge+2*clear))); let best=null,bestScore=null,last=null,trials=0; for(const rung of rungs){ + // Before a further rung, never after the last one; a rung's own guards refuse a spent budget. + if(trials>0&&effortStopped)break; const trial=packIntoTemplate({...tmpl,d:rung,innerVolume:volume(rung),reserve:0n},itemsRemaining);trials++;last=trial; if(trial.state.placements.length){const cut=fabricatedTrial(tmpl,trial);if(!trial.next.length)return {trial:cut,trials}; const score=roundScore(cut,itemsRemaining);if(bestScore==null||compareRound(score,bestScore)<0){best=cut;bestScore=score}} - if(deadline.expired()){timeLimitReached=true;break} - if(effortExceeded())break} + if(deadline.expired()){timeLimitReached=true;break}} return {trial:best??last,trials}}; // A rung trial as the box cut for it: the achieved extent of its placements, envelope far faces // with clearance, rounded up onto the machine grid (D14). O(p). @@ -3342,7 +3495,7 @@ const fabricatedTrial=(tmpl,trial)=>{const box=fabricate(tmpl.machine,tmpl.d,bag let fixedReached=false; for(const group of fixedGroups){const seeded={...group.tmpl,fixed:group.placements};let trial=null; if(deadline.expired())timeLimitReached=true;else if(!fixedReached)trial=packIntoTemplate(seeded,remaining); - if(trial===null||timeLimitReached||effortExceeded())fixedReached=true; + if(trial===null||timeLimitReached||effortStopped)fixedReached=true; inventory.set(seeded.id,inventory.get(seeded.id)-1);seq++; packed.push({...(trial===null?seedState(seeded).state:trial.state),seq}); if(trial!==null)remaining.splice(0,remaining.length,...trial.next)} @@ -3350,7 +3503,7 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ const initial={packed:packed.slice(),remaining:remaining.slice(),inventory:new Map(inventory),seq};let beam=fixedReached?[]:[initial],incumbent=initial,planNodes=0; while(beam.length&&planNodes=maxContainers){if(compareScore(planScore(plan),planScore(incumbent))<0)incumbent=plan;continue} - for(const tmpl of templates){if(planNodes>=containerPlanNodeLimit)break;if(deadline.expired()||effortExceeded()){exhausted=true;break}if(plan.inventory.get(tmpl.id)<=0)continue; + for(const tmpl of templates){if(planNodes>=containerPlanNodeLimit)break;if(deadline.expired()||effortStopped){exhausted=true;break}if(plan.inventory.get(tmpl.id)<=0)continue; // Each rung is one single-container solve, so the node limit keeps bounding the work // of a made-to-measure chain. const {trial,trials}=trialFor(tmpl,plan.remaining);planNodes+=trials;if(!trial.state.placements.length)continue;const nextInventory=new Map(plan.inventory);nextInventory.set(tmpl.id,nextInventory.get(tmpl.id)-1); @@ -3363,7 +3516,7 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ packed.splice(0,packed.length,...incumbent.packed);remaining.splice(0,remaining.length,...incumbent.remaining);seq=incumbent.seq; }else while(remaining.length&&packed.lengthinventory.get(c.id)>0&&remaining.some(i=>(!i.eligibleTags.length||i.eligibleTags.some(tag=>(c.tags??[]).includes(tag)))&&i.rots.some(r=>{const d=rotate(i.d,r).map(x=>x+2*clear);return d.every((x,k)=>x<=c.d[k])}))); if(!eligible.length)break; // Evaluate every eligible template against the same `remaining` items @@ -3405,8 +3558,12 @@ const supportIsTheBlocker=i=>{ if(supportVerdicts.has(i.raw.id))return supportVerdicts.get(i.raw.id); if(finishedScenes==null)finishedScenes=packed.map(c=>{const tmpl={...c.tmpl,fixed:c.placements};return {tmpl,...seedState(tmpl)}}); const unsupported={...i,supportPpm:0,groundRule:null},counters={...metrics},reached=timeLimitReached,tick=candidateClockTick; + // The replay diagnoses a finished search, so the effort budget neither bounds it nor can be + // stopped by it: a verdict must not depend on how close the search came to its limit. + const ceilings=[attemptCeiling,candidateCeiling,nodeCeiling];attemptCeiling=candidateCeiling=nodeCeiling=Infinity; const fits=(scene,item,ratio)=>candidatesFor(scene.tmpl,item,scene.state,scene.points,scene.index,scene.used,1,ratio).length>0; const blocked=finishedScenes.some(scene=>fits(scene,unsupported,0)&&!fits(scene,i,globalSupportPpm)); + [attemptCeiling,candidateCeiling,nodeCeiling]=ceilings; Object.assign(metrics,counters);timeLimitReached=reached;candidateClockTick=tick; supportVerdicts.set(i.raw.id,blocked);return blocked}; const unpacked=remaining.map(i=>{ @@ -3420,7 +3577,7 @@ const unpacked=remaining.map(i=>{ // whatever a policy says, and naming the policy first would send a caller to change the // wrong thing. const cited=policyRules.length?provesUnplaceable(policyRules,i.tags,templates):null; - const reason=!fitsAnyRotation?'no_compatible_container_dimensions':!dimensionFit?'rotation_restricted':!weightFit?'payload_exceeded':!eligibleFit?'no_eligible_container':cited!==null?'policy_rule':timeLimitReached?'time_limit':effortExceeded()?'effort_limit':i.group!==null?'group_cannot_fit_together':supportIsTheBlocker(i)?'insufficient_support':limitReason(i,templates,openedByType,maxContainers,clear)??'search_exhausted',details=reason==='policy_rule'?[cited]:[]; + const reason=!fitsAnyRotation?'no_compatible_container_dimensions':!dimensionFit?'rotation_restricted':!weightFit?'payload_exceeded':!eligibleFit?'no_eligible_container':cited!==null?'policy_rule':timeLimitReached?'time_limit':effortStopped?'effort_limit':i.group!==null?'group_cannot_fit_together':supportIsTheBlocker(i)?'insufficient_support':limitReason(i,templates,openedByType,maxContainers,clear)??'search_exhausted',details=reason==='policy_rule'?[cited]:[]; return {item_id:i.id,item_type:i.raw.id,reason,details,proof:proofForReason(reason,details)} }); // Canonical objective vector — see docs/OBJECTIVE.md. Five lexicographic keys, @@ -3455,7 +3612,7 @@ if(objective==='lowest_landed_cost')for(const c of packed){ unpriceableDetail={id:c.tmpl.id,grams,bound:table==null?0:table.brackets[table.brackets.length-1]}; break } -const status=unpacked.length?(timeLimitReached?'time_limit':'best_found'):'feasible',complete=!unpacked.length,effortLimitReached=effortExceeded(); +const status=unpacked.length?(timeLimitReached?'time_limit':'best_found'):'feasible',complete=!unpacked.length,effortLimitReached=effortStopped; const solverName=solverAlias?`${solverAlias}:javascript_fallback`:'javascript_fallback'; const starts=[{id:solverName,started:true,completed:!timeLimitReached&&!effortLimitReached,truncated:timeLimitReached||effortLimitReached,selected:true,global_deadline_reached:timeLimitReached}],termination=aggregateTermination(starts);if(effortLimitReached&&!timeLimitReached)termination.code='effort_limit'; const scoreValueForgone=remaining.reduce((sum,i)=>sum+(i.value??0),0); @@ -3714,8 +3871,8 @@ function sequenceInputs(boxes,container){if(!Array.isArray(boxes))throw new Type function validateDirections(directions){for(const direction of directions)if(!ALL_DIRECTIONS.includes(direction))throw new InvalidDirectionError(direction)} function overlapAreaXY(a,b){return Math.max(0,Math.min(a.x+a.d.length,b.x+b.d.length)-Math.max(a.x,b.x))*Math.max(0,Math.min(a.y+a.d.width,b.y+b.d.width)-Math.max(a.y,b.y))} function sequenceIntersects(a,b){return a.xb.x&&a.yb.y&&a.zb.z} -function loadingDependencies(boxes){return boxes.map((upper,upperIndex)=>boxes.flatMap((lower,lowerIndex)=> - lowerIndex!==upperIndex&&lower.z+lower.d.height===upper.z&&overlapAreaXY(lower,upper)>0?[lowerIndex]:[]))} +function loadingDependencies(boxes){return boxes.map((upper,upperIndex)=>{const supporters=[];for(let lowerIndex=0;lowerIndex0)supporters.push(lowerIndex)}return supporters})} function unloadingDependencies(boxes){const loading=loadingDependencies(boxes),result=boxes.map(()=>[]);loading.forEach((supporters,upper)=>supporters.forEach(lower=>result[lower].push(upper)));return result} function graphAcyclic(dependsOn){const visiting=new Set(),visited=new Set();function visit(node){if(visited.has(node))return true;if(visiting.has(node)||node<0||node>=dependsOn.length)return false;visiting.add(node);for(const dependency of dependsOn[node])if(!visit(dependency))return false;visiting.delete(node);visited.add(node);return true}return dependsOn.every((_,index)=>visit(index))} export class LoadingDependencyGraph{ @@ -3742,7 +3899,7 @@ function blockingIndices(index,boxes,present,container,direction){const [x1,y1,z return blocked} function validatePermutation(boxes,order){const sorted=[...order].sort((a,b)=>a-b);if(sorted.length!==boxes.length||sorted.some((value,index)=>value!==index))throw new SequenceReplayError(-1,-1,'order is not a permutation of every placement index exactly once')} function validateSequenceBox(index,step,boxes,present,container){const box=boxes[index];if(box.x<0||box.y<0||box.z<0||box.x+box.d.length>container.length||box.y+box.d.width>container.width||box.z+box.d.height>container.height)throw new SequenceReplayError(index,step,'placement is outside the container'); - if([...present].some(other=>other!==index&&sequenceIntersects(box,boxes[other])))throw new SequenceReplayError(index,step,'placement collides with an already present placement')} + for(const other of present)if(other!==index&&sequenceIntersects(box,boxes[other]))throw new SequenceReplayError(index,step,'placement collides with an already present placement')} function replayRemovalNormalized(boxes,container,order,directions){validatePermutation(boxes,order);const dependencies=unloadingDependencies(boxes),present=new Set(boxes.map((_,index)=>index)); order.forEach((index,step)=>{validateSequenceBox(index,step,boxes,present,container);if(dependencies[index].some(dependency=>present.has(dependency)))throw new SequenceReplayError(index,step,'something still resting on it has not been removed yet');if(clearDirection(index,boxes,present,container,directions)==null)throw new SequenceReplayError(index,step,'no allowed direction is clear of the remaining placements');present.delete(index)})} function replayLoadingNormalized(boxes,container,order,directions){validatePermutation(boxes,order);const dependencies=loadingDependencies(boxes),present=new Set(); diff --git a/index.d.ts b/index.d.ts index c741282..2737f8e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -78,7 +78,7 @@ export const commerce:{ export interface ResultIssue{code:string;detail:string} /** The result does not have the documented shape; `kind` says how the reference answers it. */ export class MalformedResult extends Error{readonly kind:'contract'|'shape';constructor(message:string,kind:'contract'|'shape')} -/** `validate` was called without the compiled addon; the fallback's validator is. */ +/** @deprecated Thrown by `validate` before 1.8.0 without the compiled addon; nothing throws it now. */ export class ValidatorUnavailableError extends Error{readonly code:'validator_unavailable'} -/** The reference validator's issues, identical across packages; empty when valid. Needs the compiled addon. */ +/** The reference validator's issues, identical across packages; empty when valid. The compiled addon answers when loaded, the JavaScript port otherwise. */ export function validate(request:PackingRequest,result:PackingResult):ResultIssue[]; diff --git a/index.js b/index.js index e7a7b13..f1511f2 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,7 @@ export { FixedPlacementError, InvalidRequestError, UnsupportedFeatureError }; import * as commerceFallback from './commerce.js'; export { CommerceInputError } from './commerce.js'; import { SUITE_VERSION } from './artifacts.js'; +import { validateDocuments } from './result-validation/index.js'; const require = createRequire(import.meta.url); /** The largest `maxMoves` the addon's `u32` parameter holds; napi wraps anything above it. */ @@ -147,7 +148,7 @@ export function rebalanceWeightWith(binding, request, result, { maxMoves = 64 } return rebalanceFallback(request, result, { maxMoves }); } -export const version = () => native?.version?.() ?? '1.7.0-js-fallback'; +export const version = () => native?.version?.() ?? '1.8.0-js-fallback'; /** * The result is not a document the reference validator can judge (docs/VALIDATION-CONTRACT.md): @@ -163,12 +164,15 @@ export class MalformedResult extends Error { } /** - * `validate` needs the compiled addon: the reference validator is ported to the Rust core - *, and the JavaScript fallback's own port is. + * Thrown by `validate` before 1.8.0 when the compiled addon was absent. The JavaScript fallback + * has its own port of the reference validator since then, so nothing throws it; it is + * still exported so code that imports or catches it keeps loading. + * + * @deprecated `validate` answers on every backend. */ export class ValidatorUnavailableError extends Error { constructor() { - super('validate() needs the compiled addon; the JavaScript fallback has no validator yet'); + super('validate() answers without the compiled addon since 1.8.0; nothing throws this error'); this.name = 'ValidatorUnavailableError'; this.code = 'validator_unavailable'; } @@ -177,18 +181,20 @@ export class ValidatorUnavailableError extends Error { /** * Every way `result` fails to be a sound answer to `request`, as `{code, detail}` objects * identical to `packvium.validate`'s in every package; an empty list means valid. Throws - * `MalformedResult` for a result that does not have the documented shape at all. + * `MalformedResult` for a result that does not have the documented shape at all. The compiled + * addon answers when it is loaded, the JavaScript port otherwise; both are ports of the same + * reference and return the same list. */ export function validate(request, result) { return validateWith(native, request, result); } -// Separate from `validate` so the native path can be exercised without a native build. +// Separate from `validate` so either path can be exercised whatever this host has built: a +// binding without `validateJson` (or none) is answered by the JavaScript port. export function validateWith(binding, request, result) { - if (!binding?.validateJson) { - throw new ValidatorUnavailableError(); - } - const outcome = JSON.parse(binding.validateJson(JSON.stringify(request), JSON.stringify(result))); + const outcome = binding?.validateJson + ? JSON.parse(binding.validateJson(JSON.stringify(request), JSON.stringify(result))) + : validateDocuments(request, result); if (outcome.refusal) { throw new MalformedResult(outcome.refusal.message, outcome.refusal.kind); } diff --git a/package.json b/package.json index 0190b07..66deec6 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name":"@packvium/engine", - "version":"1.7.0", + "version":"1.8.0", "description":"Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.", "author":{"name":"Packvium contributors","url":"https://packvium.com"}, - "keywords":["3d-bin-packing","bin-packing","3d-packing","cartonization","container-loading","pallet-packing","truck-loading","packing-algorithm","logistics","deterministic"], + "keywords":["3d-bin-packing","bin-packing","3d-packing","cartonization","container-loading","pallet-packing","truck-loading","packing-algorithm","logistics","deterministic","box-packing","load-planning"], "homepage":"https://packvium.com", "repository":{"type":"git","url":"git+https://github.com/toxakara/packvium-node.git"}, "bugs":{"url":"https://github.com/toxakara/packvium-node/issues"}, @@ -11,7 +11,7 @@ "main":"index.js", "types":"index.d.ts", "exports":{".":{"types":"./index.d.ts","import":"./index.js"},"./execution.js":{"types":"./execution.d.ts","import":"./execution.js"},"./artifacts.js":{"types":"./artifacts.d.ts","import":"./artifacts.js"},"./artifact-exports.js":{"types":"./artifact-exports.d.ts","import":"./artifact-exports.js"},"./revisions.js":{"types":"./revisions.d.ts","import":"./revisions.js"},"./scene.js":{"types":"./scene.d.ts","import":"./scene.js"}}, - "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","execution.js","canonical-json.js","artifacts.js","artifact-exports.js","revisions.js","scene.js","request-errors.js","made-to-measure.js","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","revisions.d.ts","scene.d.ts","README.md","SECURITY.md"], + "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","execution.js","canonical-json.js","artifacts.js","artifact-exports.js","revisions.js","scene.js","request-errors.js","made-to-measure.js","result-validation","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","revisions.d.ts","scene.d.ts","README.md","SECURITY.md"], "engines":{"node":">=16"}, "scripts":{ "test":"node test/run-tests.mjs", diff --git a/request-errors.js b/request-errors.js index 6dc7424..ffd9f74 100644 --- a/request-errors.js +++ b/request-errors.js @@ -88,6 +88,11 @@ function requireRatio(value, field, maximum = 1) { const requireUnbounded = (value, field) => requireRatio(value, field, null); +/** Only JSON `true`/`false`: a truthiness reading would pack `"no"` as yes. O(1). */ +function requireBoolean(value, field) { + if (typeof value !== 'boolean') throw new InvalidRequestError('wrong_type', field, 'must be a boolean'); +} + function requireOneOf(value, field, allowed) { if (typeof value !== 'string' || !allowed.includes(value)) { throw new InvalidRequestError('not_allowed', field, `must be one of ${jsonSpelling([...allowed])}`); @@ -166,6 +171,8 @@ const CONFIGURATION_FIELDS = ['alternatives', 'clearance', 'container_plan_beam_ 'max_candidates_per_item', 'max_containers', 'minimum_support_ratio', 'multi_start_orders', 'objective', 'require_placement_coordinates', 'seed', 'solver_profile', 'solvers', 'time_limit_ms']; const SIDES = ['length', 'width', 'height']; +/** The item fields the request schema types `boolean`, in schema order. */ +const ITEM_BOOLEANS = ['keep_upright', 'stackable', 'must_be_on_floor']; const AXES = ['x', 'y', 'z']; const atLeast = (minimum) => (value, field) => requireInteger(value, field, minimum); @@ -220,6 +227,7 @@ function checkConfiguration(raw, length) { for (const [name, minimum] of CONFIGURATION_INTEGERS) optional(configuration, name, '/configuration', atLeast(minimum)); optional(configuration, 'minimum_support_ratio', '/configuration', requireRatio); optional(configuration, 'clearance', '/configuration', length); + optional(configuration, 'require_placement_coordinates', '/configuration', requireBoolean); if (configuration.effort_budget == null) return; const budget = requireObject(configuration.effort_budget, '/configuration/effort_budget'); requireKnownFields(budget, '/configuration/effort_budget', EFFORT_LIMITS); @@ -246,6 +254,7 @@ function checkItem(raw, where, length, weight, lengthTicks) { optional(item, 'allowed_rotations', where, eachOneOf(ROTATIONS)); optional(item, 'ground_contact_rule', where, oneOf(GROUND_CONTACT_RULES)); optional(item, 'shape_type', where, oneOf(SHAPE_TYPES)); + for (const name of ITEM_BOOLEANS) optional(item, name, where, requireBoolean); } /** diff --git a/result-validation/accounting.js b/result-validation/accounting.js new file mode 100644 index 0000000..855a7a0 --- /dev/null +++ b/result-validation/accounting.js @@ -0,0 +1,237 @@ +/** The summary, the item accounting, and the proof behind every unpacked reason. */ + +import { ROTATIONS } from './geometry.js'; +import { + PyDict, at, getNull, getOr, has, hashKey, isDict, list, pyCmp, pyEq, pyIntInstance, text, truthy, +} from './json.js'; +import { issue, shape } from './outcome.js'; +import { pyRepr, pyStr, strRepr } from './pyfmt.js'; +import { holds, intersectsTags, rotationAxes } from './spec.js'; + +export function checkStructure(result, issues) { + const summary = at(result, 'summary'); + const containers = list(at(result, 'containers'), 'result.containers'); + const packed = containers.reduce((total, container) => total + list(at(container, 'placements'), 'placements').length, 0); + const unpacked = list(at(result, 'unpacked_items'), 'result.unpacked_items').length; + for (const [field, actual] of [['container_count', containers.length], ['packed_item_count', packed], + ['unpacked_item_count', unpacked]]) { + const reported = at(summary, field); + if (!pyEq(reported, actual)) issues.push(issue('summary_mismatch', `${field} ${pyStr(reported)} != ${actual}`)); + } + const complete = at(result, 'complete'); + if (!pyEq(complete, unpacked === 0)) { + issues.push(issue('complete_mismatch', `complete=${pyStr(complete)} with ${unpacked} unpacked`)); + } +} + +/** Whether a rule's `applies_to` facts all hold for the shipment. */ +function applies(rule, shipment) { + const appliesTo = truthy(getNull(rule, 'applies_to')) ? getNull(rule, 'applies_to') : {}; + if (!isDict(appliesTo)) throw shape('applies_to must be an object'); + return Object.keys(appliesTo).every((fact) => pyEq(getNull(shipment, fact), appliesTo[fact])); +} + +/** + * Participating `require_container_tag` rules, resolved from the request alone -- a second + * implementation of docs/POLICY-RULES.md, so a resolution bug shared with an engine cannot hide + * behind agreement with it. + */ +export function containerTagRules(request) { + const policy = getNull(request, 'policy'); + if (!isDict(policy)) return []; + const declared = truthy(getNull(policy, 'rules')) ? getNull(policy, 'rules') : []; + if (!Array.isArray(declared) || declared.length === 0) return []; + const asOf = pyIntInstance(getNull(policy, 'as_of')); + if (asOf === null) return []; + const shipment = truthy(getNull(policy, 'shipment')) ? getNull(policy, 'shipment') : {}; + const latest = new PyDict(); + for (const rule of declared) { + if (!has(rule, 'require_container_tag') || !applies(rule, shipment)) continue; + if (pyCmp(getOr(rule, 'effective_at', 0), asOf) > 0) continue; + const id = getNull(rule, 'id'); + const current = latest.get(id); + const newer = current === undefined || pyCmp( + [getOr(rule, 'effective_at', 0), getOr(rule, 'version', 0)], + [getOr(current, 'effective_at', 0), getOr(current, 'version', 0)], + ) > 0; + if (newer) latest.set(id, rule); + } + return latest.values(); +} + +export function checkAccounting(result, context, issues) { + const seen = new Set(); + const perType = new PyDict(); + const count = (itemType) => perType.set(itemType, (perType.get(itemType) ?? 0n) + 1n); + const see = (itemId) => { + const key = hashKey(itemId); + const repeated = seen.has(key); + seen.add(key); + return repeated; + }; + for (const container of list(at(result, 'containers'), 'result.containers')) { + for (const placement of list(at(container, 'placements'), 'placements')) { + const itemId = at(placement, 'item_id'); + if (see(itemId)) issues.push(issue('duplicate_item', pyStr(itemId))); + count(at(placement, 'item_type')); + } + } + const algorithm = at(result, 'algorithm'); + const timedOut = truthy(at(algorithm, 'time_limit_reached')); + const effortLimited = truthy(at(algorithm, 'effort_limit_reached')); + for (const unpacked of list(at(result, 'unpacked_items'), 'result.unpacked_items')) { + const itemId = at(unpacked, 'item_id'); + if (see(itemId)) issues.push(issue('duplicate_item', `${pyStr(itemId)} is both packed and unpacked`)); + count(at(unpacked, 'item_type')); + if (!truthy(getNull(unpacked, 'reason'))) issues.push(issue('missing_reason', pyStr(itemId))); + const spec = context.items.get(at(unpacked, 'item_type')); + checkReasonProof(unpacked, spec, context, timedOut, effortLimited, issues); + } + for (const [itemType, counted] of perType.items()) { + const spec = context.items.get(itemType); + if (spec === undefined) { + issues.push(issue('unknown_item', pyStr(itemType))); + } else if (!pyEq(spec.quantity, counted)) { + issues.push(issue('quantity_mismatch', `${pyStr(itemType)}: ${counted} accounted for, ${pyStr(spec.quantity)} requested`)); + } + } + for (const [itemType, spec] of context.items.items()) { + if (!perType.has(itemType)) { + issues.push(issue('quantity_mismatch', `${pyStr(itemType)}: 0 accounted for, ${pyStr(spec.quantity)} requested`)); + } + } +} + +/** + * Whether one envelope of this size fits the empty container: its frame and, for a flat bag, + * both girth sums with the envelope alone at the origin. + */ +export function fitsEmpty(container, size) { + if (size.some((side, axis) => side > container.inner[axis])) return false; + return !container.flatBag || (size[0] + size[2] <= container.inner[0] && size[1] + size[2] <= container.inner[1]); +} + +/** + * `any(_fits_empty(container, rotated envelope) for container ... for rotation ...)`, stopping at + * the first fit as Python's generator does, so an unknown rotation is only an error when reached. + */ +function fitsSome(spec, context, rotations) { + const margin = 2n * context.clearance; + for (const container of context.containers.values()) { + for (const rotation of rotations) { + const axes = rotationAxes(text(rotation, 'a rotation')); + if (fitsEmpty(container, axes.map((axis) => spec.dimensions[axis] + margin))) return true; + } + } + return false; +} + +const PROVEN = ['no_compatible_container_dimensions', 'payload_exceeded', 'no_eligible_container', + 'rotation_restricted', 'policy_rule']; +const LIMITED = ['time_limit', 'effort_limit']; +const OBSERVED = ['no_feasible_placement', 'search_exhausted', 'exact_search_incomplete', 'insufficient_support']; + +function expectedLevel(reason) { + hashKey(reason); + const is = (names) => names.includes(reason); + if (is(PROVEN)) return 'proven'; + if (is(LIMITED)) return 'unknown_due_to_limit'; + return is(OBSERVED) ? 'observed' : 'inferred'; +} + +function checkReasonProof(unpacked, spec, context, timedOut, effortLimited, issues) { + const itemId = pyStr(getOr(unpacked, 'item_id', '?')); + const reason = getOr(unpacked, 'reason', ''); + const proof = getNull(unpacked, 'proof'); + if (!isDict(proof)) { + issues.push(issue('missing_reason_proof', itemId)); + return; + } + const expected = expectedLevel(reason); + const level = getNull(proof, 'level'); + if (level !== expected) { + issues.push(issue('reason_proof_mismatch', + `${itemId}: ${pyRepr(reason)} has proof ${pyRepr(level)}, expected ${strRepr(expected)}`)); + } + if (reason === 'time_limit' && !timedOut) { + issues.push(issue('reason_limit_mismatch', `${itemId}: time_limit reason without a reached time limit`)); + } + if (reason === 'effort_limit' && !effortLimited) { + issues.push(issue('reason_limit_mismatch', `${itemId}: effort_limit reason without a reached effort limit`)); + } + if (spec !== undefined) checkProvenReason(unpacked, reason, spec, context, itemId, issues); + checkObservations(proof, reason, itemId, issues); +} + +const EVERY_ROTATION = ROTATIONS.map(([name]) => name); + +function checkProvenReason(unpacked, reason, spec, context, itemId, issues) { + const invalid = (detail) => issues.push(issue('invalid_proven_reason', `${itemId}: ${detail}`)); + const own = spec.allowedRotations; + if (reason === 'no_compatible_container_dimensions') { + if (fitsSome(spec, context, own)) invalid('at least one allowed orientation fits a container'); + if (fitsSome(spec, context, EVERY_ROTATION)) { + invalid('fits at least one container once every rotation is tried, not only its own allowed set -- ' + + 'should be rotation_restricted'); + } + } + if (reason === 'rotation_restricted') { + if (fitsSome(spec, context, own)) invalid('already fits a container within its own allowed rotations'); + if (!fitsSome(spec, context, EVERY_ROTATION)) { + invalid('does not fit any container even with every rotation tried -- should be no_compatible_container_dimensions'); + } + } + const containers = context.containers.values(); + if (reason === 'payload_exceeded' + && containers.some((container) => container.maxPayload === null || spec.weight <= container.maxPayload)) { + invalid('its weight is within at least one container payload limit'); + } + if (reason === 'no_eligible_container' && containers.some((container) => spec.eligibleContainerTags.size === 0 + || intersectsTags(spec.eligibleContainerTags, container.tags))) { + invalid('at least one container satisfies its eligible tags'); + } + if (reason === 'policy_rule') checkPolicyCitation(unpacked, spec, context, invalid); +} + +/** + * Two claims, both checked: that some rule rules this item out of every container, and that the + * rule the result *names* is that rule. + */ +function checkPolicyCitation(unpacked, spec, context, invalid) { + const blocking = context.rules.filter((rule) => holds(spec.tags, getNull(at(rule, 'require_container_tag'), 'item_tag')) + && !context.containers.values().some((container) => holds( + container.tags, getNull(at(rule, 'require_container_tag'), 'container_tag'), + ))); + if (blocking.length === 0) { + invalid('no participating policy rule rules it out of every container'); + return; + } + const details = getNull(unpacked, 'details'); + const cited = truthy(details) ? list(details, 'details').map((detail) => text(detail, 'details')).join(' ') : ''; + const named = blocking.some((rule) => cited.includes(`${pyStr(at(rule, 'id'))}@${pyStr(at(rule, 'version'))}`)); + if (!named) invalid(`cites ${strRepr(cited)}, which names no rule that actually blocks it`); +} + +function wellFormedObservation(observation) { + const code = getNull(observation, 'code'); + if (typeof code !== 'string' || code === '') return false; + const count = pyIntInstance(getNull(observation, 'count')); + return count !== null && count >= 1n && Array.isArray(getNull(observation, 'details')); +} + +function checkObservations(proof, reason, itemId, issues) { + const observations = getNull(proof, 'observations'); + if (!Array.isArray(observations) || observations.length === 0) { + issues.push(issue('missing_reason_observations', itemId)); + return; + } + let observedReason = false; + for (const observation of observations) { + if (!isDict(observation) || !wellFormedObservation(observation)) { + issues.push(issue('malformed_reason_observation', itemId)); + continue; + } + if (pyEq(getNull(observation, 'code'), reason)) observedReason = true; + } + if (!observedReason) issues.push(issue('reason_observation_mismatch', `${itemId}: ${pyStr(reason)}`)); +} diff --git a/result-validation/bound.js b/result-validation/bound.js new file mode 100644 index 0000000..68696b2 --- /dev/null +++ b/result-validation/bound.js @@ -0,0 +1,324 @@ +/** + * The optimality bound and gap of docs/OPTIMALITY-GAP.md § 6.2, recomputed from the request + *: `optimality.lower_bound` is the recomputed prefix, the gap agrees with it and the + * score, no score falls below it, and `optimal` / `proven_optimal` are claimed only by + * attainment. The reference's own copy of docs/OPTIMALITY-CERTIFICATES.md's formulas and its one + * refusal predicate, shared with nothing in the fallback solver. + * + * Every quantity is a `BigInt`: volumes are products of three tick lengths and the guarded sums + * reach 10^30, so no `Number` product is ever formed. O(t log t + c log c) for `t` item types and + * `c` container types, O(t + c) memory, once per result. + */ + +import { + at, getNull, getOr, int, isDict, strictInt, truthy, +} from './json.js'; +import { ceilDiv, floorDiv, max, min, pyMod, ratioPpm } from './num.js'; +import { issue } from './outcome.js'; +import { intList, pyRepr } from './pyfmt.js'; +import { SCORE_SCALE } from './stacking.js'; + +/** Where each covered objective reads `(L0, L1, L2, L3, L4)` in its own score order. */ +const BOUND_ORDERS = new Map([['default', [0, 1, 2, 3, 4]], ['lowest_cost', [0, 2, 1, 3, 4]]]); + +const MAX_BOUND_SUM = 10n ** 30n; +const MAX_BOUND_VALUE = 2n ** 53n - 1n; +const GAP_FIELDS = ['gap_key', 'absolute_gap', 'relative_gap']; +const SHRINKING_SHAPES = ['convex_hull', 'compressible']; + +const ascending = (left, right) => (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0); + +/** `[value, count]` runs, sorted by value: one entry per item type, however large its quantity. */ +const runsOf = (pairs) => pairs.slice().sort(ascending); + +const runCount = (runs) => runs.reduce((total, [, count]) => total + count, 0n); + +const largestOf = (values) => values.reduce((largest, value) => max(largest, value)); + +/** How many of the smallest values sum to at most `capacity`; all of them when it is unlimited. */ +function fit(runs, capacity) { + if (capacity === null) return runCount(runs); + if (capacity < 0n) return 0n; + let taken = 0n; + let used = 0n; + for (const [value, count] of runs) { + const room = value === 0n ? count : min(count, floorDiv(capacity - used, value)); + taken += room; + used += value * room; + if (room < count) break; + } + return taken; +} + +/** The sum of the `count` smallest values. */ +function smallest(runs, count) { + let total = 0n; + let remaining = count; + for (const [value, available] of runs) { + if (remaining <= 0n) break; + const taken = min(available, remaining); + total += value * taken; + remaining -= taken; + } + return total; +} + +const largest = (runs, count) => smallest(runs.slice().reverse(), count); + +/** `Σ value × quantity`, or `null` when unbounded; a type that holds nothing adds 0. */ +function capacityTotal(values, quantities) { + let total = 0n; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + const quantity = quantities[index]; + if (value === null) return null; + if (quantity === null) { + if (value > 0n) return null; + } else { + total += value * quantity; + } + } + return total; +} + +/** `L1`, given `L0`. */ +function containerBound(volumes, weights, shrinks, placed, containers) { + if (placed <= 0n || containers.length === 0) return 0n; + let bound = 1n; + const largestVolume = largestOf(containers.map((container) => container.usable)); + if (!shrinks && largestVolume > 0n) bound = max(bound, ceilDiv(smallest(volumes, placed), largestVolume)); + const payloads = containers.map((container) => container.payload); + if (payloads.every((payload) => payload !== null)) { + const largestPayload = largestOf(payloads); + if (largestPayload > 0n) bound = max(bound, ceilDiv(smallest(weights, placed), largestPayload)); + } + const slots = containers.map((container) => container.maxItems); + if (slots.every((slot) => slot !== null)) { + const largestSlots = largestOf(slots); + if (largestSlots > 0n) bound = max(bound, ceilDiv(placed, largestSlots)); + } + return bound; +} + +/** `L2`: the `opened` cheapest containers the inventory holds, greedily by cost (stable). */ +function costBound(containers, opened) { + const byCost = containers.map((container, index) => [container.costMinor, index]) + .sort((left, right) => ascending(left, right) || left[1] - right[1]); + let total = 0n; + let remaining = opened; + for (const [cost, index] of byCost) { + if (remaining <= 0n) break; + const { quantity } = containers[index]; + const taken = quantity === null ? remaining : min(quantity, remaining); + total += cost * taken; + remaining -= taken; + } + return total; +} + +/** `L3`. */ +function unusedBound(volumes, shrinks, placed, containers, opened) { + const inner = containers.map((container) => container.innerVolume).filter((volume) => volume > 0n); + if (opened <= 0n || shrinks || inner.length === 0) return 0n; + const smallestInner = inner.reduce((least, volume) => min(least, volume)); + const filled = ceilDiv(largest(volumes, placed) * SCORE_SCALE, smallestInner); + return max(0n, opened * SCORE_SCALE - filled - (opened - 1n)); +} + +/** `L4`. */ +function heightBound(volumes, shrinks, placed, containers, opened) { + const floors = containers.map((container) => container.baseArea).filter((area) => area > 0n); + const heights = containers.map((container) => container.height).filter((height) => height > 0n); + if (opened <= 0n || shrinks || floors.length === 0 || heights.length === 0) return 0n; + const required = ceilDiv(smallest(volumes, placed), largestOf(floors)); + return max(0n, floorDiv(required * SCORE_SCALE, largestOf(heights)) - (opened - 1n)); +} + +/** + * `[L0, L1, L2, L3, L4]` of docs/OPTIMALITY-CERTIFICATES.md, or `null` where its one refusal + * predicate withholds the bound. Exported so the shared scene can hold it to the oracle. + */ +export function lowerBound(volumes, weights, shrinks, containers) { + const quantities = containers.map((container) => container.quantity); + const sumOf = (runs) => runs.reduce((total, [value, count]) => total + value * count, 0n); + const guarded = [ + sumOf(volumes), + sumOf(weights), + capacityTotal(containers.map((container) => container.usable), quantities), + capacityTotal(containers.map((container) => container.payload), quantities), + capacityTotal(containers.map((container) => container.maxItems), quantities), + ]; + if (guarded.some((total) => total !== null && total > MAX_BOUND_SUM)) return null; + const count = runCount(volumes); + let placeable = 0n; + if (containers.length > 0) { + placeable = count; + if (!shrinks) placeable = min(placeable, fit(volumes, guarded[2])); + placeable = min(placeable, fit(weights, guarded[3])); + if (guarded[4] !== null) placeable = min(placeable, guarded[4]); + } + const opened = containerBound(volumes, weights, shrinks, placeable, containers); + const bound = [ + count - placeable, + opened, + costBound(containers, opened), + unusedBound(volumes, shrinks, placeable, containers, opened), + heightBound(volumes, shrinks, placeable, containers, opened), + ]; + return bound.some((value) => value > MAX_BOUND_VALUE) ? null : bound; +} + +/** The request as the bound reads it: every instance, fixed ones included, and every type. */ +function boundInputs(items, containers) { + const specs = items.values(); + const quantityOf = (spec) => int(spec.quantity, 'quantity'); + const volumes = runsOf(specs.map((spec) => [spec.dimensions[0] * spec.dimensions[1] * spec.dimensions[2], quantityOf(spec)])); + const weights = runsOf(specs.map((spec) => [spec.weight, quantityOf(spec)])); + const shrinks = specs.some((spec) => quantityOf(spec) > 0n + && (spec.nestingHeight !== null || SHRINKING_SHAPES.includes(spec.shapeType))); + const types = containers.values().map((spec) => { + const volume = spec.inner[0] * spec.inner[1] * spec.inner[2]; + const scaled = ratioPpm(spec.voidFillReserveRatio); + const reserved = floorDiv(volume, SCORE_SCALE) * scaled + floorDiv(pyMod(volume, SCORE_SCALE) * scaled, SCORE_SCALE); + return { + innerVolume: volume, + baseArea: spec.inner[0] * spec.inner[1], + height: spec.inner[2], + usable: volume - reserved, + costMinor: spec.costMinor, + payload: spec.maxPayload, + maxItems: spec.maxItems, + quantity: spec.quantity, + }; + }); + return [volumes, weights, shrinks, types]; +} + +/** The bound in the objective's score order, or `null` with the reason none is stated (§ 1.5). */ +function statedBound(request, status, items, containers) { + const name = getOr(getOr(request, 'configuration', {}), 'objective', 'default'); + const order = typeof name === 'string' ? BOUND_ORDERS.get(name) : undefined; + if (order === undefined) return { bound: null, withheld: `objective ${pyRepr(name)} has no bound` }; + if (containers.values().some((spec) => spec.madeToMeasure !== null)) { + return { bound: null, withheld: 'a container is made to measure' }; + } + if (status === 'infeasible') return { bound: null, withheld: "status is 'infeasible'" }; + if (status === 'invalid_result') return { bound: null, withheld: "status is 'invalid_result'" }; + const bound = lowerBound(...boundInputs(items, containers)); + if (bound === null) return { bound: null, withheld: 'the request is past a declared ceiling' }; + return { bound: order.map((position) => bound[position]), withheld: '' }; +} + +/** The first position where `score` leaves `bound`, and whether it is above (1) or below (-1). */ +function departureOf(score, bound) { + const shared = Math.min(score.length, bound.length); + for (let index = 0; index < shared; index += 1) { + if (score[index] !== bound[index]) return { index, sign: score[index] > bound[index] ? 1 : -1 }; + } + return null; +} + +const isCount = (value) => { + const integer = strictInt(value); + return integer !== null && integer >= 0n; +}; + +/** The prefix length and the three gap fields a correct result carries (§ 1.1). */ +function expectedGap(score, bound, departure) { + if (departure === null) return { length: bound.length, gap: { gap_key: null, absolute_gap: null, relative_gap: null } }; + const key = departure.index; + const absolute = score[key] - bound[key]; + return { + length: key + 1, + gap: { gap_key: BigInt(key), absolute_gap: absolute, relative_gap: bound[key] === 0n ? null : [absolute, bound[key]] }, + }; +} + +function gapFieldMatches(name, actual, expected) { + if (expected === null) return actual === null; + if (name !== 'relative_gap') return strictInt(actual) === expected; + return isDict(actual) && Object.keys(actual).length === 2 + && strictInt(getNull(actual, 'numerator')) === expected[0] + && strictInt(getNull(actual, 'denominator')) === expected[1]; +} + +function gapText(name, expected) { + if (expected === null) return 'absent'; + return name === 'relative_gap' ? `${expected[0]}/${expected[1]}` : String(expected); +} + +const sameInts = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]); + +function checkReportedBound(emitted, score, bound, departure, fact, issues) { + const prefix = bound.slice(0, emitted.length); + if (!sameInts(emitted, prefix)) { + issues.push(issue('optimality_bound_mismatch', `reported ${intList(emitted)}, recomputed ${intList(prefix)}`)); + } + const { length, gap } = expectedGap(score, bound, departure); + if (emitted.length !== length) { + issues.push(issue('optimality_gap_mismatch', `lower_bound has ${emitted.length} entries, expected ${length}`)); + } + for (const name of GAP_FIELDS) { + if (!gapFieldMatches(name, getNull(fact, name), gap[name])) { + issues.push(issue('optimality_gap_mismatch', `${name} should be ${gapText(name, gap[name])}`)); + } + } +} + +/** `lower_bound` as a list of counts when it is one of 1 to 5 non-negative integers, else `null`. */ +function wellFormed(emitted) { + if (!Array.isArray(emitted) || emitted.length < 1 || emitted.length > 5 || !emitted.every(isCount)) return null; + return emitted.map(strictInt); +} + +function checkEmitted(fact, emitted, counts, stated, score, departure, below, issues) { + if (emitted === null) { + if (GAP_FIELDS.some((name) => getNull(fact, name) !== null)) { + issues.push(issue('optimality_gap_mismatch', 'a gap is reported without lower_bound')); + } + } else if (counts === null) { + issues.push(issue('optimality_bound_mismatch', 'lower_bound must be a list of 1 to 5 non-negative integers')); + } else if (stated.bound === null) { + issues.push(issue('optimality_bound_mismatch', `lower_bound is reported where no bound is stated: ${stated.withheld}`)); + } else if (below) { + issues.push(issue('optimality_bound_mismatch', 'lower_bound is reported for a score below the recomputed bound')); + } else { + checkReportedBound(counts, score, stated.bound, departure, fact, issues); + } +} + +function checkClaims(result, fact, attained, claimsAttainment, issues) { + const status = at(result, 'status'); + const complete = truthy(at(result, 'complete')); + if (status === 'optimal' && !(complete && attained)) { + issues.push(issue('result_fact_mismatch', + "status 'optimal' requires a complete result that attains the recomputed lower bound")); + } + if (status === 'feasible' && complete && claimsAttainment) { + issues.push(issue('result_fact_mismatch', + "status is 'feasible', but the result attains its lower_bound; expected 'optimal'")); + } + if (getNull(fact, 'code') !== 'proven_optimal' || claimsAttainment) return; + issues.push(issue('unproven_optimality_claim', "optimality.code is 'proven_optimal' without an attained lower_bound")); +} + +/**: the reference's `_check_optimality_bound`. Absence of the field is never an issue. */ +export function checkOptimalityBound(request, result, items, containers, score, issues) { + const fact = at(result, 'optimality'); + const stated = statedBound(request, at(result, 'status'), items, containers); + const departure = stated.bound === null ? null : departureOf(score, stated.bound); + const below = departure !== null && departure.sign < 0; + if (below) { + const key = departure.index; + issues.push(issue('score_below_bound', `score[${key}] is ${score[key]}, below its lower bound ${stated.bound[key]}`)); + } + const emitted = getNull(fact, 'lower_bound'); + if (emitted !== null && stated.bound !== null && departure !== null + && score[departure.index] - stated.bound[departure.index] > MAX_BOUND_VALUE) { + issues.push(issue('optimality_gap_mismatch', 'the gap exceeds the portable result ceiling')); + } + const counts = emitted === null ? null : wellFormed(emitted); + checkEmitted(fact, emitted, counts, stated, score, departure, below, issues); + const attained = stated.bound !== null && departure === null; + checkClaims(result, fact, attained, counts !== null && sameInts(counts, score), issues); +} diff --git a/result-validation/container.js b/result-validation/container.js new file mode 100644 index 0000000..13451aa --- /dev/null +++ b/result-validation/container.js @@ -0,0 +1,371 @@ +/** + * One packed container: its geometry, its loads, its accounting figures, and the box a + * made-to-measure machine would have cut for it. + */ + +import { contains, cuboid, intersects, vertices, solidsOverlap } from './geometry.js'; +import { + PyDict, at, get, getNull, getOr, has, int, isDict, list, pyEq, pyInt, truthy, +} from './json.js'; +import { Refusal, contract, issue, shape } from './outcome.js'; +import { abs, ceilDiv, floorDiv, max, pyMod, ratioPpm } from './num.js'; +import { intTuple, list as listRepr, pyRepr, pyStr, strRepr, valueTuple } from './pyfmt.js'; +import { allows, intersectsTags, isRotation, rotated, sortedTags, volumeOf } from './spec.js'; +import { + SCORE_SCALE, SQUARE_METRE_TICKS, checkCrush, checkRouteRemovalOrder, checkStackDensity, checkSupportAndLoad, + occupiedVolume, placedHull, propagateLoad, supportGraph, validNesting, +} from './stacking.js'; + +const SIDES = ['length', 'width', 'height']; + +/** + * A packed container's id as an issue prints it. The reference reads `packed["id"]` only while + * formatting an issue, so a missing id fails only once an issue needs it; `verify` reports that + * after the check, which aborts the run exactly as the reference's `KeyError` does. + */ +export class Label { + constructor(packed) { + const id = get(packed, 'id'); + this.text = id === undefined ? null : pyStr(id); + this.missingUsed = false; + } + + toString() { + if (this.text !== null) return this.text; + this.missingUsed = true; + return '?'; + } + + verify() { + if (this.missingUsed) throw shape('a packed container with an issue has no id'); + } +} + +/** The physical box grown by the clearance margin; without integer ticks, the reference's contract refusal. */ +export function envelopeOf(placement, clearance) { + try { + const position = at(placement, 'position'); + const dimensions = at(placement, 'dimensions'); + const tick = (block, key) => int(at(at(block, key), 'ticks'), 'placement ticks'); + const margin = 2n * clearance; + return cuboid( + tick(position, 'x') - clearance, tick(position, 'y') - clearance, tick(position, 'z') - clearance, + tick(dimensions, 'length') + margin, tick(dimensions, 'width') + margin, tick(dimensions, 'height') + margin, + ); + } catch (error) { + if (!(error instanceof Refusal)) throw error; + throw contract(`placement is missing tick coordinates: ${error.message}`); + } +} + +/** The largest envelope x2, y2 and z2 over a packed container's placements. */ +export function achievedExtent(packed, clearance) { + const boxes = list(at(packed, 'placements'), 'placements').map((placement) => envelopeOf(placement, clearance)); + if (boxes.length === 0) return [0n, 0n, 0n]; + return boxes.slice(1).reduce( + (extent, box) => [max(extent[0], box.x2), max(extent[1], box.y2), max(extent[2], box.z2)], + [boxes[0].x2, boxes[0].y2, boxes[0].z2], + ); +} + +const matchesInts = (values, expected) => values !== null && values.length === expected.length + && values.every((value, index) => pyEq(value, expected[index])); + +const optionalTuple = (values) => (values === null ? 'None' : valueTuple(values)); + +/** + * `achieved_dimensions` is present exactly when the container declares a soft size, and equals + * the tight extent; a flat bag's extent must pass the girth rule. + */ +export function checkAchievedExtent(packed, spec, clearance, issues) { + const reported = getNull(packed, 'achieved_dimensions'); + const packedId = new Label(packed); + if (!spec.reportsAchievedExtent) { + if (reported !== null) issues.push(issue('achieved_dimensions_mismatch', `${packedId}: reported for a container of fixed size`)); + packedId.verify(); + return; + } + const [a, b, c] = achievedExtent(packed, clearance); + const stated = isDict(reported) ? SIDES.map((side) => getNull(getOr(reported, side, {}), 'ticks')) : null; + if (!matchesInts(stated, [a, b, c])) { + issues.push(issue('achieved_dimensions_mismatch', `${packedId}: reported ${optionalTuple(stated)}, computed ${intTuple([a, b, c])}`)); + } + if (spec.flatBag && (a + c > spec.inner[0] || b + c > spec.inner[1])) { + issues.push(issue('bag_girth_exceeded', + `${packedId}: a + c = ${a + c}, b + c = ${b + c}, flat size ${spec.inner[0]} x ${spec.inner[1]}`)); + } + packedId.verify(); +} + +/** The smallest size on the machine grid `minimum + k * increment` that is at least `achieved`. */ +const fabricatedSide = (achieved, minimum, increment) => minimum + ceilDiv(max(achieved, minimum) - minimum, increment) * increment; + +/** `ceil(rate * 2(LW + LH + WH) / 1 m^2)`, the material and board rounding. */ +function perSquareMetreCeiling(rate, [length, width, height]) { + return ceilDiv(rate * 2n * (length * width + length * height + width * height), SQUARE_METRE_TICKS); +} + +/** `(block.get(key) or {}).get("ticks")`. */ +function nestedTicks(block, key) { + const value = getNull(block, key); + return truthy(value) ? getNull(value, 'ticks') : null; +} + +/** `(value.get(key) or {}).get("ticks")` per side, or `null` when `value` is not an object. */ +const reportedTicks = (value) => (isDict(value) ? SIDES.map((side) => nestedTicks(value, side)) : null); + +/** The summed weight of every placement whose type the request declares. */ +function payloadOf(placements, items) { + return placements.reduce((total, placement) => { + const spec = items.get(at(placement, 'item_type')); + return spec === undefined ? total : total + spec.weight; + }, 0n); +} + +/** + * Recompute a made-to-measure container's box from its placements alone and return the spec + * every later check uses: the recomputed box, never the reported one. O(p). + */ +export function checkMadeToMeasure(packed, compact, spec, items, clearance, issues) { + const containerId = pyStr(getOr(packed, 'id', '?')); + const reportedBlock = getNull(packed, 'made_to_measure'); + const machine = spec.madeToMeasure; + if (machine === null) { + if (reportedBlock !== null) issues.push(issue('made_to_measure_unexpected', containerId)); + return spec; + } + if (!isDict(reportedBlock)) issues.push(issue('made_to_measure_missing', containerId)); + const block = isDict(reportedBlock) ? reportedBlock : {}; + if (compact) issues.push(issue('lattice_summary_on_made_to_measure', containerId)); + const achieved = achievedExtent(packed, clearance); + const fabricated = [0, 1, 2].map((axis) => fabricatedSide(achieved[axis], machine.minimum[axis], machine.increment[axis])); + if (fabricated.some((side, axis) => side > spec.inner[axis])) { + issues.push(issue('fabricated_dimensions_exceed_machine', `${containerId}: ${intTuple(fabricated)} beyond ${intTuple(spec.inner)}`)); + } + const reportedInner = reportedTicks(getNull(packed, 'inner_dimensions')); + if (!matchesInts(reportedInner, fabricated)) { + issues.push(issue('fabricated_dimensions_mismatch', + `${containerId}: reported ${optionalTuple(reportedInner)}, computed ${intTuple(fabricated)}`)); + } + const outer = fabricated.map((side, axis) => side + machine.allowance[axis]); + const reportedOuter = reportedTicks(getNull(packed, 'outer_dimensions')); + if (!matchesInts(reportedOuter, outer)) { + issues.push(issue('outer_dimensions_mismatch', `${containerId}: reported ${optionalTuple(reportedOuter)}, computed ${intTuple(outer)}`)); + } + const cost = spec.costMinor + perSquareMetreCeiling(machine.pricePerSquareMetreMinor, fabricated); + const reportedCost = getNull(block, 'cost_minor'); + if (!pyEq(reportedCost, cost)) { + issues.push(issue('made_to_measure_cost_mismatch', `${containerId}: reported ${pyStr(reportedCost)}, computed ${cost}`)); + } + const tare = spec.tareWeight + perSquareMetreCeiling(machine.tarePerSquareMetre, fabricated); + const reportedTare = nestedTicks(block, 'tare_weight'); + if (!pyEq(reportedTare, tare)) { + issues.push(issue('made_to_measure_tare_mismatch', `${containerId}: reported ${pyStr(reportedTare)}, computed ${tare}`)); + } + const gross = tare + payloadOf(list(at(packed, 'placements'), 'placements'), items); + const reportedGross = nestedTicks(packed, 'gross_weight'); + if (!pyEq(reportedGross, gross)) { + issues.push(issue('gross_weight_mismatch', `${containerId}: reported ${pyStr(reportedGross)}, computed ${gross}`)); + } + return Object.freeze({ ...spec, inner: fabricated, outer, costMinor: cost, tareWeight: tare }); +} + +/** Every check of one packed container against its (possibly recomputed) type. */ +export function checkContainer(packed, spec, items, clearance, globalSupport, issues) { + const packedId = new Label(packed); + const placements = list(at(packed, 'placements'), 'placements'); + const envelopes = new PyDict(); + const scope = { + packed, packedId, spec, items, clearance, envelopes, issues, boundary: cuboid(0n, 0n, 0n, ...spec.inner), + }; + let payload = 0n; + for (const placement of placements) { + const itemSpec = checkPlacement(scope, placement); + if (itemSpec !== undefined) payload += itemSpec.weight; + } + const entries = envelopes.items(); + checkPairs(entries, packedId, issues); + const graph = supportGraph(entries); + checkSupportAndLoad(entries, graph, globalSupport, issues); + const loads = propagateLoad(entries, graph); + if (spec.maxStackDensity !== null) checkStackDensity(packedId, entries, loads, spec.maxStackDensity, issues); + checkCrush(packedId, entries, loads, issues); + checkRouteRemovalOrder(packedId, entries, graph, spec.inner, spec.accessDirections, issues); + checkLimits(packed, packedId, placements, spec, items, payload, issues); + checkVolumes(packed, packedId, placements, spec, items, envelopes, loads, issues); + const totalWeight = payloadOf(placements, items); + checkCentreOfMass(packed, packedId, placements, spec, items, totalWeight, issues); + checkAxles(packed, packedId, placements, spec, items, totalWeight, issues); + packedId.verify(); +} + +/** One placement's own checks; records its envelope and returns its item type's spec. */ +function checkPlacement(scope, placement) { + const { packed, packedId, spec, items, clearance, envelopes, issues } = scope; + const rawId = at(placement, 'item_id'); + const itemSpec = items.get(at(placement, 'item_type')); + const envelope = envelopeOf(placement, clearance); + envelopes.set(rawId, { envelope, placement, spec: itemSpec }); + const itemId = pyStr(rawId); + if (!contains(scope.boundary, envelope)) issues.push(issue('outside_container', `${itemId} in ${packedId}`)); + for (const obstacle of spec.obstacles) { + if (intersects(envelope, obstacle) && solidsOverlap(placedHull(envelope, placement, itemSpec), vertices(obstacle))) { + issues.push(issue('obstacle_collision', `${itemId} in ${packedId}`)); + } + } + if (itemSpec === undefined) return undefined; + checkRotation(placement, itemSpec, itemId, issues); + if (itemSpec.eligibleContainerTags.size > 0 && !intersectsTags(itemSpec.eligibleContainerTags, spec.tags)) { + const tags = listRepr(sortedTags(itemSpec.eligibleContainerTags).map(strRepr)); + issues.push(issue('container_ineligible', `${itemId}: ${pyStr(at(packed, 'container_type'))} has none of ${tags}`)); + } + if (itemSpec.mustBeOnFloor && envelope.z !== 0n) issues.push(issue('floor_violation', `${itemId} at z=${envelope.z}`)); + return itemSpec; +} + +function checkRotation(placement, spec, itemId, issues) { + const orientation = at(placement, 'orientation'); + if (!isRotation(orientation)) { + issues.push(issue('unknown_orientation', `${itemId}: ${pyStr(orientation)}`)); + return; + } + if (!allows(spec, orientation)) issues.push(issue('forbidden_rotation', `${itemId}: ${orientation}`)); + const expected = rotated(spec, orientation); + const dimensions = at(placement, 'dimensions'); + const actual = SIDES.map((side) => at(at(dimensions, side), 'ticks')); + if (!matchesInts(actual, expected)) { + issues.push(issue('dimension_mismatch', + `${itemId}: ${orientation} of ${intTuple(spec.dimensions)} is ${intTuple(expected)}, got ${valueTuple(actual)}`)); + } +} + +/** Every pair once, in the reference's `combinations` order: collisions and incompatible tags. O(p^2). */ +function checkPairs(entries, packedId, issues) { + for (let first = 0; first < entries.length; first += 1) { + const [leftId, left] = entries[first]; + for (let second = first + 1; second < entries.length; second += 1) { + const [rightId, right] = entries[second]; + if (intersects(left.envelope, right.envelope) + && !validNesting(left, right) + && solidsOverlap(placedHull(left.envelope, left.placement, left.spec), placedHull(right.envelope, right.placement, right.spec))) { + issues.push(issue('collision', `${pyStr(leftId)} with ${pyStr(rightId)} in ${packedId}`)); + } + if (left.spec !== undefined && right.spec !== undefined + && (intersectsTags(left.spec.incompatibleTags, right.spec.tags) || intersectsTags(right.spec.incompatibleTags, left.spec.tags))) { + issues.push(issue('incompatible_items', `${pyStr(leftId)} is incompatible with ${pyStr(rightId)} in ${packedId}`)); + } + } + } +} + +function checkLimits(packed, packedId, placements, spec, items, payload, issues) { + if (spec.maxPayload !== null && payload > spec.maxPayload) { + issues.push(issue('payload_exceeded', `${packedId}: ${payload} > ${spec.maxPayload} ticks`)); + } + const reported = getNull(getOr(packed, 'payload_weight', {}), 'ticks'); + if (reported !== null && !pyEq(reported, payload)) { + issues.push(issue('payload_weight_mismatch', `${packedId}: reported ${pyStr(reported)}, computed ${payload}`)); + } + if (spec.maxItems !== null && BigInt(placements.length) > spec.maxItems) { + issues.push(issue('max_items_exceeded', `${packedId}: ${placements.length} > ${spec.maxItems}`)); + } + for (const [tag, limit] of spec.tagLimits) { + const count = placements.filter((placement) => { + const item = items.get(at(placement, 'item_type')); + return item !== undefined && item.tags.has(tag); + }).length; + if (BigInt(count) > limit) issues.push(issue('tag_count_exceeded', `${packedId}: ${tag} count ${count} > limit ${limit}`)); + } +} + +/** Python's `int(value) != computed` for a reported figure that is present. */ +const differs = (reported, computed) => reported !== null && pyInt(reported, 'a reported volume') !== computed; + +function checkVolumes(packed, packedId, placements, spec, items, envelopes, loads, issues) { + const entries = envelopes.items(); + // Every placement counts, through the envelope its item id resolves to -- a repeated id + // resolves to its last placement, as the reference's dict does. + let used = placements.reduce((total, placement) => { + const index = envelopes.position(at(placement, 'item_id')); + return total + occupiedVolume(entries[index][1].envelope, placement, items.get(at(placement, 'item_type')), loads[index]); + }, 0n); + for (let first = 0; first < entries.length; first += 1) { + const left = entries[first][1]; + if (left.spec === undefined || left.spec.nestingHeight === null) continue; + for (let second = first + 1; second < entries.length; second += 1) { + if (validNesting(left, entries[second][1])) used -= left.spec.nestingHeight * left.envelope.length * left.envelope.width; + } + } + const reportedVolume = getNull(packed, 'used_volume_ticks3'); + if (differs(reportedVolume, used)) { + issues.push(issue('used_volume_mismatch', `${packedId}: reported ${pyStr(reportedVolume)}, computed ${used}`)); + } + const volume = volumeOf(spec); + const scaledReserve = ratioPpm(spec.voidFillReserveRatio); + const reserved = floorDiv(volume, SCORE_SCALE) * scaledReserve + floorDiv(pyMod(volume, SCORE_SCALE) * scaledReserve, SCORE_SCALE); + const reportedReserve = getNull(packed, 'void_fill_reserve_ticks3'); + if (differs(reportedReserve, reserved)) { + issues.push(issue('void_fill_reserve_mismatch', `${packedId}: reported ${pyStr(reportedReserve)}, computed ${reserved}`)); + } else if (reserved !== 0n && reportedReserve === null) { + issues.push(issue('void_fill_reserve_mismatch', `${packedId}: non-zero reserve ${reserved} is not reported`)); + } + if (used > volume - reserved) { + issues.push(issue('void_fill_reserve_exceeded', `${packedId}: used ${used} > usable ${volume - reserved}`)); + } +} + +/** `sum(weight * (2 * position + extent))` along one axis, over declared item types. */ +function doubledMoment(placements, items, axis, side) { + return placements.reduce((total, placement) => { + const spec = items.get(at(placement, 'item_type')); + if (spec === undefined) return total; + const position = int(at(at(at(placement, 'position'), axis), 'ticks'), 'placement ticks'); + const extent = int(at(at(at(placement, 'dimensions'), side), 'ticks'), 'placement ticks'); + return total + spec.weight * (2n * position + extent); + }, 0n); +} + +function checkCentreOfMass(packed, packedId, placements, spec, items, totalWeight, issues) { + let expected = 0n; + if (totalWeight !== 0n) { + const offset = (doubled, side) => floorDiv(abs(doubled - totalWeight * side) * SCORE_SCALE, totalWeight * side); + expected = max( + offset(doubledMoment(placements, items, 'x', 'length'), spec.inner[0]), + offset(doubledMoment(placements, items, 'y', 'width'), spec.inner[1]), + ); + } + const reported = getNull(packed, 'centre_of_mass_offset_ppm'); + if (!pyEq(reported, expected)) { + issues.push(issue('centre_of_mass_mismatch', `${packedId}: reported ${pyStr(reported)}, computed ${expected}`)); + } +} + +function checkAxles(packed, packedId, placements, spec, items, totalWeight, issues) { + if (spec.axles === null) { + if (has(packed, 'axle_reactions')) { + issues.push(issue('axle_reaction_mismatch', `${packedId}: reactions reported without configured axles`)); + } + return; + } + if (spec.axles.length !== 2) throw shape(`${packedId}: axles must name exactly a front and a rear axle`); + const [front, rear] = spec.axles; + const denominator = 2n * (rear.position - front.position); + const gross = spec.tareWeight + totalWeight; + const doubledWeightedX = spec.tareWeight * spec.inner[0] + doubledMoment(placements, items, 'x', 'length'); + const frontNumerator = 2n * gross * rear.position - doubledWeightedX; + const rearNumerator = doubledWeightedX - 2n * gross * front.position; + const expected = { + basis: 'gross', + denominator: String(denominator), + front_numerator: String(frontNumerator), + rear_numerator: String(rearNumerator), + }; + const reported = getNull(packed, 'axle_reactions'); + if (!pyEq(reported, expected)) { + issues.push(issue('axle_reaction_mismatch', `${packedId}: reported ${pyStr(reported)}, computed ${pyRepr(expected)}`)); + } + const over = (limit, numerator) => limit !== null && numerator > limit * denominator; + if (over(front.maxLoad, frontNumerator) || over(rear.maxLoad, rearNumerator)) { + issues.push(issue('axle_overloaded', String(packedId))); + } +} diff --git a/result-validation/facts.js b/result-validation/facts.js new file mode 100644 index 0000000..fb3fcfd --- /dev/null +++ b/result-validation/facts.js @@ -0,0 +1,191 @@ +/** + * The result's own claims about the search: counters, counted-work limits, start records and the + * feasibility, termination and optimality facts. + */ + +import { + at, getNull, getOr, isDict, pyCmp, pyIntInstance, strictInt, truthy, +} from './json.js'; +import { contract, issue, shape } from './outcome.js'; +import { boolRepr, pyRepr, pyStr, strRepr } from './pyfmt.js'; + +const METRICS = ['candidate_points_considered', 'orientations_considered', 'feasible_candidates', + 'collision_checks', 'support_checks', 'space_partitions', 'search_nodes_expanded']; + +function algorithmOf(result) { + const algorithm = at(result, 'algorithm'); + if (!isDict(algorithm)) throw contract('algorithm must be an object'); + return algorithm; +} + +const isCounter = (value) => { + const count = strictInt(value); + return count !== null && count >= 0n; +}; + +export function checkAlgorithmMetrics(result, issues) { + const algorithm = algorithmOf(result); + const metrics = getNull(algorithm, 'metrics'); + if (!isDict(metrics) || !METRICS.every((field) => isCounter(getNull(metrics, field)))) { + issues.push(issue('solver_metrics_mismatch', 'algorithm.metrics must contain every non-negative integer counter')); + return; + } + const candidates = strictInt(getNull(algorithm, 'candidates_evaluated')); + const attempts = strictInt(getNull(algorithm, 'placements_attempted')); + const mirrors = candidates !== null && attempts !== null + && candidates === strictInt(at(metrics, 'feasible_candidates')) + && attempts === strictInt(at(metrics, 'orientations_considered')); + if (!mirrors) issues.push(issue('solver_metrics_mismatch', 'legacy candidate counters must mirror algorithm.metrics')); +} + +/** Counted-work limits are respected, and the stop flag is evidenced by one of them. */ +export function checkEffortBudget(request, result, issues) { + const algorithm = at(result, 'algorithm'); + const effortLimited = getNull(algorithm, 'effort_limit_reached'); + if (typeof effortLimited !== 'boolean') throw contract('algorithm.effort_limit_reached must be a boolean'); + const budget = getNull(getOr(request, 'configuration', {}), 'effort_budget'); + if (budget === null) { + if (effortLimited) { + issues.push(issue('effort_budget_mismatch', 'effort_limit_reached is true but the request has no effort_budget')); + } + return; + } + if (!isDict(budget)) throw contract('config.effort_budget must be an object'); + const metrics = getNull(algorithm, 'metrics'); + if (!isDict(metrics)) return; + let reached = false; + for (const [budgetName, metricName] of [['max_candidates_evaluated', 'feasible_candidates'], + ['max_placement_attempts', 'orientations_considered'], ['max_search_nodes', 'search_nodes_expanded']]) { + const limit = getNull(budget, budgetName); + const value = strictInt(getNull(metrics, metricName)); + if (limit === null || value === null) continue; + const order = pyCmp(value, limit); + if (order > 0) { + issues.push(issue('effort_budget_exceeded', `${metricName} is ${value}, above ${budgetName}=${pyStr(limit)}`)); + } + reached = reached || order >= 0; + } + const restartLimit = getNull(budget, 'max_restarts'); + const starts = getOr(getOr(result, 'termination', {}), 'starts', []); + const limit = pyIntInstance(restartLimit); + if (limit !== null && Array.isArray(starts)) { + const started = BigInt(starts.filter((start) => isDict(start) && getNull(start, 'started') === true).length); + if (started > limit) { + issues.push(issue('effort_budget_exceeded', `started ${started} solver runs, above max_restarts=${pyStr(restartLimit)}`)); + } + reached = reached || started >= limit; + } + if (effortLimited && !reached) { + issues.push(issue('effort_budget_mismatch', + 'effort_limit_reached is true but no configured counted-work boundary was reached')); + } +} + +function flag(algorithm, name) { + const value = getNull(algorithm, name); + if (typeof value !== 'boolean') throw contract(`algorithm.${name} must be a boolean`); + return value; +} + +function factCode(result, name) { + const fact = at(result, name); + if (!isDict(fact)) throw contract(`${name} must be an object`); + const code = getNull(fact, 'code'); + if (typeof code !== 'string' || code === '') throw contract(`${name}.code must be a non-empty string`); + return code; +} + +export function checkResultFacts(result, issues) { + const algorithm = algorithmOf(result); + const timedOut = flag(algorithm, 'time_limit_reached'); + const effortLimited = flag(algorithm, 'effort_limit_reached'); + if (timedOut && effortLimited) { + issues.push(issue('result_fact_mismatch', 'time_limit_reached and effort_limit_reached cannot both be true')); + } + const termination = at(result, 'termination'); + if (!isDict(termination)) throw contract('termination must be an object'); + checkStarts(termination, algorithm, timedOut, effortLimited, issues); + + const status = at(result, 'status'); + const complete = truthy(at(result, 'complete')); + const invalid = status === 'invalid_result'; + const expected = { + feasibility: status === 'infeasible' ? 'infeasible' : complete && !invalid ? 'feasible' : 'unknown', + termination: invalid ? 'error' : timedOut ? 'time_limit' : effortLimited ? 'effort_limit' : 'complete', + optimality: status === 'optimal' ? 'proven_optimal' + : status === 'infeasible' ? 'proven_infeasible' + : !complete && !invalid ? 'best_found' : 'not_proven', + }; + for (const name of ['feasibility', 'termination', 'optimality']) { + const actual = factCode(result, name); + if (actual !== expected[name]) { + issues.push(issue('result_fact_mismatch', `${name}.code is ${strRepr(actual)}, expected ${strRepr(expected[name])}`)); + } + } +} + +const LIFECYCLE = ['started', 'completed', 'truncated', 'selected', 'global_deadline_reached']; + +/** Whether one start record is well formed, recording an inconsistent one on the way. */ +function checkStart(start, issues) { + if (!isDict(start) || typeof getNull(start, 'id') !== 'string') return false; + const flags = LIFECYCLE.map((name) => getNull(start, name)); + if (!flags.every((value) => typeof value === 'boolean')) return false; + const [started, completed, truncated] = flags; + if ((completed && (!started || truncated)) || (truncated && (!started || completed)) || (!started && (completed || truncated))) { + issues.push(issue('start_record_mismatch', `${pyStr(at(start, 'id'))}: inconsistent lifecycle flags`)); + } + return true; +} + +const isTrue = (start, name) => isDict(start) && getNull(start, name) === true; + +function checkStarts(termination, algorithm, timedOut, effortLimited, issues) { + const starts = getNull(termination, 'starts'); + if (!Array.isArray(starts) || starts.length === 0) { + issues.push(issue('termination_aggregate_mismatch', 'termination.starts must contain at least one start')); + return; + } + const wellFormed = starts.map((start) => checkStart(start, issues)); + if (wellFormed.includes(false)) { + issues.push(issue('start_record_mismatch', 'a start record is missing its id or boolean lifecycle fields')); + } + const selected = starts.filter((start) => isTrue(start, 'selected')); + if (selected.length !== 1) { + issues.push(issue('termination_aggregate_mismatch', `expected one selected start, found ${selected.length}`)); + return; + } + if (!starts.every(isDict)) return; + const winner = selected[0]; + const aggregate = [ + ['any_start_truncated', starts.some((start) => isTrue(start, 'truncated'))], + ['all_required_starts_completed', starts.every((start) => isTrue(start, 'completed'))], + ['winning_start_truncated', isTrue(winner, 'truncated')], + ['global_deadline_reached', starts.some((start) => isTrue(start, 'global_deadline_reached'))], + ]; + for (const [name, expected] of aggregate) { + const reported = getNull(termination, name); + if (reported !== expected) { + issues.push(issue('termination_aggregate_mismatch', `${name} is ${pyRepr(reported)}, expected ${boolRepr(expected)}`)); + } + } + checkWinningSolver(winner, getNull(algorithm, 'solver'), issues); + const globalDeadline = aggregate[3][1]; + const winningTruncated = aggregate[2][1]; + if (globalDeadline && !timedOut) { + issues.push(issue('legacy_time_limit_mismatch', 'global_deadline_reached requires time_limit_reached')); + } + if ((globalDeadline || winningTruncated) !== (timedOut || effortLimited)) { + issues.push(issue('termination_limit_mismatch', 'selected-start truncation must identify exactly one reached limit')); + } +} + +function checkWinningSolver(winner, solver, issues) { + if (typeof solver !== 'string') return; + const selectedId = getOr(winner, 'id', ''); + if (selectedId === solver) return; + if (typeof selectedId !== 'string') throw shape(`a start id must be a string to name solver ${JSON.stringify(solver)}`); + if (selectedId.startsWith(`${solver}#`)) return; + issues.push(issue('winning_start_mismatch', + `selected start ${pyRepr(selectedId)} does not identify solver ${strRepr(solver)}`)); +} diff --git a/result-validation/geometry.js b/result-validation/geometry.js new file mode 100644 index 0000000..1edeaab --- /dev/null +++ b/result-validation/geometry.js @@ -0,0 +1,200 @@ +/** + * Boxes and convex hulls in ticks, written the slow, obvious way the reference writes them: a + * validator that took the engine's shortcuts would stop being a second opinion. + */ + +import { abs, gcd, max, min } from './num.js'; +import { shape } from './outcome.js'; + +/** Axis permutation each orientation code applies to (length, width, height), in order. */ +export const ROTATIONS = Object.freeze([ + ['LWH', [0, 1, 2]], ['LHW', [0, 2, 1]], ['WLH', [1, 0, 2]], + ['WHL', [1, 2, 0]], ['HLW', [2, 0, 1]], ['HWL', [2, 1, 0]], +]); + +/** An axis-aligned box; its far corner and base area are computed once. */ +export function cuboid(x, y, z, length, width, height) { + return Object.freeze({ + x, y, z, length, width, height, + x2: x + length, y2: y + width, z2: z + height, baseArea: length * width, + }); +} + +export const intersects = (a, b) => a.x < b.x2 && b.x < a.x2 && a.y < b.y2 && b.y < a.y2 && a.z < b.z2 && b.z < a.z2; + +export const contains = (outer, inner) => outer.x <= inner.x && outer.y <= inner.y && outer.z <= inner.z + && inner.x2 <= outer.x2 && inner.y2 <= outer.y2 && inner.z2 <= outer.z2; + +export function overlapAreaXY(a, b) { + const dx = max(0n, min(a.x2, b.x2) - max(a.x, b.x)); + const dy = max(0n, min(a.y2, b.y2) - max(a.y, b.y)); + return dx * dy; +} + +/** The eight corners, in the reference's `dx, dy, dz` order. */ +export function vertices(box) { + const corners = []; + for (const x of [box.x, box.x2]) { + for (const y of [box.y, box.y2]) { + for (const z of [box.z, box.z2]) corners.push([x, y, z]); + } + } + return corners; +} + +const difference = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +/** -1, 0 or 1 by Python's tuple order. */ +function compareVertices(a, b) { + for (let axis = 0; axis < 3; axis += 1) { + if (a[axis] !== b[axis]) return a[axis] < b[axis] ? -1 : 1; + } + return 0; +} + +/** The axis reduced by its gcd and signed so its first non-zero component is positive. */ +function primitiveAxis(axis) { + const divisor = gcd(gcd(abs(axis[0]), abs(axis[1])), abs(axis[2])); + if (divisor === 0n) return [0n, 0n, 0n]; + const reduced = axis.map((value) => value / divisor); + const first = reduced.find((value) => value !== 0n); + return first < 0n ? reduced.map((value) => -value) : reduced; +} + +/** + * Reorient a hull the way `ROTATIONS` reorients its box, flipping one axis on an odd + * permutation so it stays a proper rotation, then translated so its bounds start at 0. + */ +export function rotateHull(points, axes) { + if (points.length === 0) throw shape('min() of an empty hull'); + let inversions = 0; + for (let first = 0; first < 3; first += 1) { + for (let second = first + 1; second < 3; second += 1) { + if (axes[first] > axes[second]) inversions += 1; + } + } + const sign = inversions % 2 === 1 ? -1n : 1n; + const turned = points.map((vertex) => [sign * vertex[axes[0]], vertex[axes[1]], vertex[axes[2]]]); + const low = [0, 1, 2].map((axis) => turned.reduce((least, vertex) => min(least, vertex[axis]), turned[0][axis])); + return turned.map((vertex) => difference(vertex, low)); +} + +/** Every 3-combination of `points`, in `itertools.combinations` order. */ +function* triples(points) { + for (let first = 0; first < points.length; first += 1) { + for (let second = first + 1; second < points.length; second += 1) { + for (let third = second + 1; third < points.length; third += 1) { + yield [points[first], points[second], points[third]]; + } + } + } +} + +function pairDirections(points) { + const directions = []; + for (let first = 0; first < points.length; first += 1) { + for (let second = first + 1; second < points.length; second += 1) { + directions.push(difference(points[second], points[first])); + } + } + return directions; +} + +/** A set of axes keyed by their components, in insertion order. */ +function addAxis(axes, axis) { + axes.set(axis.join(','), axis); +} + +/** + * The complete separating-axis superset: every face normal of each hull and every cross product + * of an edge direction from one with one from the other. + */ +function hullAxes(left, right) { + const axes = new Map(); + for (const hull of [left, right]) { + for (const [first, second, third] of triples(hull)) { + addAxis(axes, primitiveAxis(cross(difference(second, first), difference(third, first)))); + } + } + const rightDirections = pairDirections(right); + for (const one of pairDirections(left)) { + for (const other of rightDirections) addAxis(axes, primitiveAxis(cross(one, other))); + } + axes.delete('0,0,0'); + return axes.values(); +} + +function extent(points, axis) { + let low = dot(points[0], axis); + let high = low; + for (const point of points) { + const value = dot(point, axis); + low = min(low, value); + high = max(high, value); + } + return [low, high]; +} + +/** Whether two convex solids share interior volume. Touching is contact, not collision. */ +export function solidsOverlap(left, right) { + for (const axis of hullAxes(left, right)) { + const [leftLow, leftHigh] = extent(left, axis); + const [rightLow, rightHigh] = extent(right, axis); + if (leftHigh <= rightLow || rightHigh <= leftLow) return false; + } + return true; +} + +/** + * Corners of one planar convex face in cyclic order seen from outside, by gift-wrapping from the + * lexicographically smallest vertex; collinear candidates resolve to the farthest. + */ +function windFace(face, outward) { + const start = face.reduce((least, vertex) => (compareVertices(vertex, least) < 0 ? vertex : least)); + const ordered = [start]; + let current = start; + for (let step = 0; step < face.length; step += 1) { + let following = null; + for (const candidate of face) { + if (compareVertices(candidate, current) === 0) continue; + if (following === null) { + following = candidate; + continue; + } + const turn = dot(cross(difference(following, current), difference(candidate, current)), outward); + const reach = dot(difference(candidate, current), difference(candidate, current)); + const held = dot(difference(following, current), difference(following, current)); + if (turn < 0n || (turn === 0n && reach > held)) following = candidate; + } + if (following === null || compareVertices(following, start) === 0) break; + ordered.push(following); + current = following; + } + return ordered; +} + +/** Exact volume of a convex hull, `6V = sum of a . (b x c)` over its outward faces. */ +export function hullVolume(points) { + const normals = new Map(); + for (const [first, second, third] of triples(points)) { + const axis = primitiveAxis(cross(difference(second, first), difference(third, first))); + if (axis.some((value) => value !== 0n)) addAxis(normals, axis); + } + let sixVolumes = 0n; + for (const axis of normals.values()) { + for (const outward of [axis, axis.map((value) => -value)]) { + const [, highest] = extent(points, outward); + const face = points.filter((vertex) => dot(vertex, outward) === highest); + if (face.length < 3) continue; + const ordered = windFace(face, outward); + for (let index = 1; index + 1 < ordered.length; index += 1) { + sixVolumes += dot(ordered[0], cross(ordered[index], ordered[index + 1])); + } + } + } + return abs(sixVolumes) / 6n; +} diff --git a/result-validation/index.js b/result-validation/index.js new file mode 100644 index 0000000..266fa1a --- /dev/null +++ b/result-validation/index.js @@ -0,0 +1,135 @@ +/** + * `validate` without the compiled addon: the JavaScript port of the reference validator. + * + * A port of `packvium.validate` (packvium-python's `result_validation.py`) over the JSON + * documents rather than this package's model. It shares no code with the fallback solver or + * with anything else in this package: units, geometry, support and load are all recomputed here + * from the request and the reported placements, because a validator built on the engine's own + * code would agree with the engine's bugs. Every issue code and every detail string is the + * reference's, Python spelling included, so one result gets the same issue list from every + * package (docs/VALIDATION-CONTRACT.md). It follows the Rust core's port wherever the + * reference's own behaviour leaves a choice, so the addon and this port answer alike. + * + * Cost is the reference's: per container O(p^2) pair checks over its `p` placements (each pair + * of exact hulls a separating-axis test over O(v^4) candidate axes), O(p log p + q + e) to build + * the support graph and propagate load, and O(s * p^2) to replay `s` route stops; O(p + e) + * memory. Integers are `BigInt`, so nothing is bounded by 2^53. + */ + +import { checkAccounting, checkStructure, containerTagRules } from './accounting.js'; +import { checkOptimalityBound } from './bound.js'; +import { checkAchievedExtent, checkContainer, checkMadeToMeasure } from './container.js'; +import { checkAlgorithmMetrics, checkEffortBudget, checkResultFacts } from './facts.js'; +import { + PyDict, at, flag, getNull, getOr, has, list, optInt, +} from './json.js'; +import { materialize } from './lattice.js'; +import { Refusal, contract, issue, shape } from './outcome.js'; +import { pyStr, strRepr } from './pyfmt.js'; +import { checkFixedPlacements, checkGroups, checkObjective, checkSolverSelection } from './objective.js'; +import { parseContainers, parseItems } from './spec.js'; +import { LENGTH, lengthUnit, ratioOf, toTicks } from './units.js'; + +const REQUIRED_KEYS = ['status', 'feasibility', 'termination', 'optimality', 'complete', 'algorithm', 'summary', + 'containers', 'unpacked_items', 'score']; + +/** A value as the reference would read it from `JSON.stringify` text, which is what the addon is handed. */ +function documentOf(value, what) { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw shape(`${what} is not JSON`); + return JSON.parse(encoded); +} + +function checkInfeasibleProof(result, issues) { + if (at(result, 'status') !== 'infeasible') return; + for (const unpacked of list(at(result, 'unpacked_items'), 'result.unpacked_items')) { + if (getNull(getOr(unpacked, 'proof', {}), 'level') !== 'proven') { + issues.push(issue('unproven_infeasible', 'infeasible status requires a proven rejection for every unpacked item')); + return; + } + } +} + +/** Each packed container against its type, a made-to-measure box recomputed first; returns those boxes by id. */ +function checkContainers(validated, reported, context, globalSupport, issues) { + const fabricated = new PyDict(); + const originals = list(at(reported, 'containers'), 'result.containers'); + list(at(validated, 'containers'), 'result.containers').forEach((packed, index) => { + const containerType = at(packed, 'container_type'); + const declared = context.containers.get(containerType); + if (declared === undefined) { + issues.push(issue('unknown_container_type', pyStr(containerType))); + return; + } + const compact = has(originals[index], 'lattice_summary'); + const spec = checkMadeToMeasure(packed, compact, declared, context.items, context.clearance, issues); + if (spec.madeToMeasure !== null) fabricated.set(at(packed, 'id'), spec); + checkContainer(packed, spec, context.items, context.clearance, globalSupport, issues); + checkAchievedExtent(packed, spec, context.clearance, issues); + }); + return fabricated; +} + +function checkContainerCounts(validated, configuration, containers, issues) { + const opened = list(at(validated, 'containers'), 'result.containers'); + const inventory = new PyDict(); + for (const packed of opened) { + const containerType = at(packed, 'container_type'); + if (containers.has(containerType)) inventory.set(containerType, (inventory.get(containerType) ?? 0n) + 1n); + } + for (const [containerType, count] of inventory.items()) { + const { quantity } = containers.get(containerType); + if (quantity !== null && count > quantity) { + issues.push(issue('container_inventory_exceeded', `${pyStr(containerType)}: used ${count}, available ${quantity}`)); + } + } + const limit = optInt(getNull(configuration, 'max_containers'), 'max_containers'); + if (limit !== null && BigInt(opened.length) > limit) { + issues.push(issue('max_containers_exceeded', `${opened.length} containers opened, limit ${limit}`)); + } +} + +function judge(request, result) { + for (const key of REQUIRED_KEYS) { + if (!has(result, key)) throw contract(`result is missing ${strRepr(key)}`); + } + const unit = lengthUnit(request); + const configuration = getOr(request, 'configuration', {}); + const coordinatesRequired = flag(configuration, 'require_placement_coordinates', true, 'configuration'); + const clearance = toTicks(getOr(configuration, 'clearance', 0), unit, LENGTH); + const globalSupport = ratioOf(getOr(configuration, 'minimum_support_ratio', 0)); + const items = parseItems(request, unit); + const containers = parseContainers(request, unit); + + const issues = []; + const validated = materialize(coordinatesRequired, result, items, containers, clearance, issues); + checkStructure(validated, issues); + checkAlgorithmMetrics(validated, issues); + checkEffortBudget(request, validated, issues); + checkResultFacts(validated, issues); + const context = { items, containers, clearance, rules: containerTagRules(request) }; + checkAccounting(validated, context, issues); + checkInfeasibleProof(validated, issues); + const fabricated = checkContainers(validated, result, context, globalSupport, issues); + checkContainerCounts(validated, configuration, containers, issues); + checkFixedPlacements(request, validated, unit, issues); + checkGroups(request, validated, issues); + checkSolverSelection(request, validated, issues); + const score = checkObjective(request, validated, { items, containers, fabricated, clearance }, issues); + checkOptimalityBound(request, validated, items, containers, score, issues); + return issues; +} + +/** + * Every way `result` fails to be a sound answer to `request`, as the addon's `validateJson` + * answers: `{issues: [{code, detail}]}`, or `{refusal: {kind, message}}` when the documents + * cannot be judged. Never throws for a refusal; it is part of the answer. + */ +export function validateDocuments(request, result) { + try { + return { issues: judge(documentOf(request, 'the request'), documentOf(result, 'the result')) }; + } catch (error) { + if (!(error instanceof Refusal)) throw error; + return { refusal: { kind: error.kind, message: error.message } }; + } +} diff --git a/result-validation/json.js b/result-validation/json.js new file mode 100644 index 0000000..99a4147 --- /dev/null +++ b/result-validation/json.js @@ -0,0 +1,288 @@ +/** + * The JSON documents the validator reads, with the semantics Python's `json.loads` values have. + * + * The reference computes on plain dicts, lists, `int`, `float`, `str`, `bool` and `None`, and + * several of its outcomes depend on how Python treats them: a boolean is an `int`, `1 == 1.0 == + * True` and all three are one dict key, strings order by code point, a list orders element by + * element. These helpers give a JavaScript value exactly that reading, so the checks above them + * can be written as the reference writes them. + * + * A document arrives as JavaScript holds it after `JSON.parse(JSON.stringify(value))`, the + * same text the compiled addon is handed: a number is an integer when it is integral and + * `JSON.stringify` writes it without an exponent (below 1e21), as Python would then read that + * text. The port's own integers are `BigInt`, so no arithmetic here is bounded by 2^53. + */ + +import { contract, shape } from './outcome.js'; + +const hasOwn = Object.prototype.hasOwnProperty; + +export const isDict = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); + +/** The exact integer `value` is, or `null` when Python would not read it as an `int` literal. */ +function integerOf(value) { + if (typeof value === 'bigint') return value; + // Read the decimal JSON literal, not the double's exact binary integer (which can + // differ above 2^53 even though the serialized literal has no exponent). + if (typeof value === 'number' && Number.isInteger(value) && Math.abs(value) < 1e21) return BigInt(String(value)); + return null; +} + +export function kind(value) { + if (value === null) return 'null'; + if (typeof value === 'boolean') return 'a boolean'; + if (integerOf(value) !== null) return 'an integer'; + if (typeof value === 'number') return 'a number'; + if (typeof value === 'string') return 'a string'; + return Array.isArray(value) ? 'a list' : 'an object'; +} + +/** Python's `d.get(key)`, `undefined` when the key is absent. A non-object has no `.get`. */ +export function get(value, key) { + if (!isDict(value)) throw shape(`expected an object holding ${JSON.stringify(key)}, got ${kind(value)}`); + return hasOwn.call(value, key) ? value[key] : undefined; +} + +/** Python's `d.get(key, default)`: a key present as `null` still reads as `null`. */ +export function getOr(value, key, fallback) { + const found = get(value, key); + return found === undefined ? fallback : found; +} + +export const getNull = (value, key) => getOr(value, key, null); + +/** + * 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. + */ +export function flag(value, key, fallback, owner) { + const found = getNull(value, key); + if (found === null) return fallback; + if (typeof found !== 'boolean') throw contract(`${owner}.${key} must be a boolean`); + return found; +} + +/** Python's `d[key]`. */ +export function at(value, key) { + const found = get(value, key); + if (found === undefined) throw shape(`missing ${JSON.stringify(key)}`); + return found; +} + +/** Python's `key in value` for an object; anything else reads as not holding it. */ +export const has = (value, key) => isDict(value) && hasOwn.call(value, key); + +/** Something the reference iterates. Only a list is admitted. */ +export function list(value, what) { + if (!Array.isArray(value)) throw shape(`${what} must be a list, got ${kind(value)}`); + return value; +} + +export function text(value, what) { + if (typeof value !== 'string') throw shape(`${what} must be a string, got ${kind(value)}`); + return value; +} + +/** + * An integer the reference does arithmetic on. A boolean is Python's `int`; a float would + * carry the reference into float arithmetic this port does not follow, so it is refused. + */ +export function int(value, what) { + const found = pyIntInstance(value); + if (found === null) throw shape(`${what} must be an integer, got ${kind(value)}`); + return found; +} + +export const optInt = (value, what) => (value === null ? null : int(value, what)); + +/** `isinstance(value, int)`, which a boolean satisfies: the integer, or `null`. */ +export function pyIntInstance(value) { + if (typeof value === 'boolean') return value ? 1n : 0n; + return integerOf(value); +} + +/** `isinstance(value, int) and not isinstance(value, bool)`: the integer, or `null`. */ +export const strictInt = (value) => integerOf(value); + +/** Python's `int(value)`: a number truncates, a string parses as a base-10 literal. */ +export function pyInt(value, what) { + const found = pyIntInstance(value); + if (found !== null) return found; + if (typeof value === 'number' && Number.isFinite(value)) return BigInt(Math.trunc(value)); + if (typeof value === 'string') { + const parsed = parseIntLiteral(value); + if (parsed === null) throw shape(`${what}: invalid literal for int(): ${JSON.stringify(value)}`); + return parsed; + } + throw shape(`${what}: int() cannot convert ${kind(value)}`); +} + +/** The characters Python's `str.isspace` accepts, which `str.strip()` and `split()` cut on. */ +const PYTHON_SPACE = /^[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]$/; + +export const isPythonSpace = (character) => PYTHON_SPACE.test(character); + +/** Python's `str.strip()` with no argument. */ +export function pyStrip(value) { + let start = 0; + let end = value.length; + while (start < end && isPythonSpace(value[start])) start += 1; + while (end > start && isPythonSpace(value[end - 1])) end -= 1; + return value.slice(start, end); +} + +/** `int(text)`: surrounding whitespace, an optional sign, ASCII digits with single underscores. */ +function parseIntLiteral(value) { + const match = /^([+-]?)(\d+(?:_\d+)*)$/.exec(pyStrip(value)); + if (match === null) return null; + const magnitude = BigInt(match[2].replace(/_/g, '')); + return match[1] === '-' ? -magnitude : magnitude; +} + +/** Python truthiness. */ +export function truthy(value) { + if (value === null) return false; + if (typeof value === 'boolean') return value; + if (typeof value === 'bigint') return value !== 0n; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string' || Array.isArray(value)) return value.length > 0; + return Object.keys(value).length > 0; +} + +/** A value as a number Python would compare, or `null` when it is not one. */ +function numberOf(value) { + if (typeof value === 'boolean') return { integer: value ? 1n : 0n }; + const integer = integerOf(value); + if (integer !== null) return { integer }; + return typeof value === 'number' ? { float: value } : null; +} + +const sign = (difference) => (difference < 0n ? -1 : difference > 0n ? 1 : 0); + +/** An integer against a finite float, exactly, as Python compares `int` with `float`. */ +function compareIntegerToFloat(integer, float) { + if (Number.isInteger(float)) return sign(integer - BigInt(float)); + return integer <= BigInt(Math.floor(float)) ? -1 : 1; +} + +function compareNumbers(left, right) { + if (left.integer !== undefined && right.integer !== undefined) return sign(left.integer - right.integer); + if (left.integer !== undefined) return compareIntegerToFloat(left.integer, right.float); + if (right.integer !== undefined) return 0 - compareIntegerToFloat(right.integer, left.float) || 0; + return left.float < right.float ? -1 : left.float > right.float ? 1 : 0; +} + +/** Strings by code point, as Python, PHP and Rust order them; UTF-16 order differs past the BMP. */ +export function compareCodePoints(left, right) { + if (left === right) return 0; + let leftIndex = 0; + let rightIndex = 0; + while (leftIndex < left.length && rightIndex < right.length) { + const a = left.codePointAt(leftIndex); + const b = right.codePointAt(rightIndex); + if (a !== b) return a < b ? -1 : 1; + leftIndex += a > 0xffff ? 2 : 1; + rightIndex += b > 0xffff ? 2 : 1; + } + return leftIndex === left.length ? (rightIndex === right.length ? 0 : -1) : 1; +} + +/** Python `==`: numbers by value whatever their type, containers element-wise, objects by key. */ +export function pyEq(left, right) { + const a = numberOf(left); + const b = numberOf(right); + if (a !== null || b !== null) return a !== null && b !== null && compareNumbers(a, b) === 0; + if (left === null || right === null) return left === right; + if (typeof left === 'string' || typeof right === 'string') return left === right; + if (Array.isArray(left) || Array.isArray(right)) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length + && left.every((element, index) => pyEq(element, right[index])); + } + const keys = Object.keys(left); + return keys.length === Object.keys(right).length + && keys.every((key) => hasOwn.call(right, key) && pyEq(left[key], right[key])); +} + +/** Python's `<` ordering for values it can order; anything else is its `TypeError`. */ +export function pyCmp(left, right) { + const a = numberOf(left); + const b = numberOf(right); + if (a !== null && b !== null) return compareNumbers(a, b); + if (typeof left === 'string' && typeof right === 'string') return compareCodePoints(left, right); + if (Array.isArray(left) && Array.isArray(right)) { + const shared = Math.min(left.length, right.length); + for (let index = 0; index < shared; index += 1) { + if (!pyEq(left[index], right[index])) return pyCmp(left[index], right[index]); + } + return sign(BigInt(left.length - right.length)); + } + throw shape(`cannot order ${kind(left)} against ${kind(right)}`); +} + +/** `sorted(values)`: a stable sort by Python's ordering. */ +export function sortPy(values) { + return values.slice().sort(pyCmp); +} + +/** + * The identity Python hashes a set member or dict key by, so `1`, `1.0` and `True` share one. + * A list or an object is unhashable, as in Python. + */ +export function hashKey(value) { + if (typeof value === 'boolean') return value ? 'n1' : 'n0'; + if (typeof value === 'bigint') return `n${value}`; + if (typeof value === 'number') return Number.isInteger(value) ? `n${integerOf(value) ?? BigInt(value)}` : `f${value}`; + if (value === null) return 'null'; + if (typeof value === 'string') return `s${value}`; + throw shape(`${kind(value)} is unhashable`); +} + +/** + * Insertion-ordered map keyed the way a Python dict is: assigning an existing key replaces its + * value in place and keeps the key first written, as Python keeps it. + */ +export class PyDict { + constructor() { + this.entries = new Map(); + } + + set(key, value) { + const hashed = hashKey(key); + const existing = this.entries.get(hashed); + if (existing === undefined) { + this.entries.set(hashed, { key, value, index: this.entries.size }); + } else { + existing.value = value; + } + } + + get(key) { + const entry = this.entries.get(hashKey(key)); + return entry === undefined ? undefined : entry.value; + } + + has(key) { + return this.entries.has(hashKey(key)); + } + + /** The entry's position in insertion order. */ + position(key) { + return this.entries.get(hashKey(key)).index; + } + + get size() { + return this.entries.size; + } + + keys() { + return Array.from(this.entries.values(), (entry) => entry.key); + } + + values() { + return Array.from(this.entries.values(), (entry) => entry.value); + } + + items() { + return Array.from(this.entries.values(), (entry) => [entry.key, entry.value]); + } +} diff --git a/result-validation/lattice.js b/result-validation/lattice.js new file mode 100644 index 0000000..a4f855a --- /dev/null +++ b/result-validation/lattice.js @@ -0,0 +1,156 @@ +/** + * Compact lattice results, expanded independently before ordinary validation. + * + * The wire summary is an untrusted, reconstructible representation: its lattice parameters must + * agree with the request before they are used to synthesize placements, and every synthesized + * placement then passes through the same checks as an explicit one. + */ + +import { + at, getNull, getOr, has, int, list, pyEq, strictInt, truthy, +} from './json.js'; +import { ceilDiv, floorDiv } from './num.js'; +import { Refusal, contract, issue } from './outcome.js'; +import { intTuple, pyRepr, pyStr, valueTuple } from './pyfmt.js'; +import { allows, isRotation, rotated } from './spec.js'; + +const SIDES = ['length', 'width', 'height']; + +/** The result with every valid lattice summary expanded into placements; the input is not changed. */ +export function materialize(coordinatesRequired, result, items, containers, clearance, issues) { + const packedContainers = list(at(result, 'containers'), 'result.containers'); + if (!packedContainers.some((packed) => has(packed, 'lattice_summary'))) return result; + const rewritten = packedContainers.map((packed) => { + const placements = expand(packed, coordinatesRequired, items, containers, clearance, issues); + return placements === null ? packed : { ...packed, placements }; + }); + return { ...result, containers: rewritten }; +} + +/** The placements to write into one container, or `null` to leave it untouched. */ +function expand(packed, coordinatesRequired, items, containers, clearance, issues) { + const summary = getNull(packed, 'lattice_summary'); + if (summary === null) return null; + const containerId = pyStr(getOr(packed, 'id', '?')); + if (coordinatesRequired) { + issues.push(issue('unexpected_lattice_summary', `${containerId}: request requires explicit placement coordinates`)); + } + if (truthy(getNull(packed, 'placements'))) { + issues.push(issue('lattice_placements_conflict', + `${containerId}: compact summary and explicit placements are mutually exclusive`)); + } + const itemType = getNull(summary, 'item_type'); + const item = items.get(itemType); + const container = containers.get(getNull(packed, 'container_type')); + // The ordinary accounting and container checks report the unknown reference. + if (item === undefined || container === undefined) return null; + + const fields = readFields(summary, containerId); + const problems = problemsOf(fields, item, itemType, container, clearance); + if (problems.length > 0) { + for (const problem of problems) issues.push(issue('lattice_summary_mismatch', `${containerId}: ${problem}`)); + return []; + } + return synthesize(fields, summary, itemType, containerId, clearance); +} + +/** The summary's canonical fields; a missing one is the reference's contract refusal. */ +function readFields(summary, containerId) { + try { + const sides = (key) => SIDES.map((side) => at(at(at(summary, key), side), 'ticks')); + return { + orientation: at(summary, 'orientation'), + physical: sides('physical_dimensions'), + envelope: sides('envelope_dimensions'), + nx: at(summary, 'nx'), + ny: at(summary, 'ny'), + layersUsed: at(summary, 'layers_used'), + layerStep: at(at(summary, 'layer_step'), 'ticks'), + count: at(summary, 'count'), + }; + } catch (error) { + if (!(error instanceof Refusal)) throw error; + throw contract(`${containerId}: lattice_summary is missing a canonical field: ${error.message}`); + } +} + +const matchesInts = (values, expected) => values.every((value, index) => pyEq(value, expected[index])); + +function problemsOf(fields, item, itemType, container, clearance) { + const problems = []; + if (!isRotation(fields.orientation)) { + problems.push(`unknown orientation ${pyRepr(fields.orientation)}`); + } else { + const expected = rotated(item, fields.orientation); + if (!matchesInts(fields.physical, expected)) { + problems.push(`physical dimensions ${valueTuple(fields.physical)} != rotated item ${intTuple(expected)}`); + } + if (!allows(item, fields.orientation)) { + problems.push(`orientation ${fields.orientation} is forbidden for ${pyStr(itemType)}`); + } + } + const expectedEnvelope = fields.physical.map((side) => int(side, 'physical dimension') + 2n * clearance); + if (!matchesInts(fields.envelope, expectedEnvelope)) { + problems.push(`envelope dimensions ${valueTuple(fields.envelope)} != physical+clearance ${intTuple(expectedEnvelope)}`); + } + if (item.nestingHeight !== null) problems.push('compact lattice form is not valid for nesting items'); + if (!pyEq(fields.layerStep, fields.envelope[2])) { + problems.push(`layer_step ${pyStr(fields.layerStep)} != envelope height ${pyStr(fields.envelope[2])}`); + } + for (const [name, value] of [['nx', fields.nx], ['ny', fields.ny], ['layers_used', fields.layersUsed], + ['layer_step', fields.layerStep], ['count', fields.count]]) { + const number = strictInt(value); + if (number === null || number < 1n) problems.push(`${name} must be a positive integer, got ${pyRepr(value)}`); + } + if (problems.length === 0) independentCounts(fields, item, container, problems); + return problems; +} + +/** Only reached once every field is a positive integer and the envelope is the expected one. */ +function independentCounts(fields, item, container, problems) { + const nx = int(fields.nx, 'lattice field'); + const ny = int(fields.ny, 'lattice field'); + const layersUsed = int(fields.layersUsed, 'lattice field'); + const layerStep = int(fields.layerStep, 'lattice field'); + const count = int(fields.count, 'lattice field'); + const expectedNx = floorDiv(container.inner[0], int(fields.envelope[0], 'lattice field')); + const expectedNy = floorDiv(container.inner[1], int(fields.envelope[1], 'lattice field')); + const expectedLayers = ceilDiv(count, nx * ny); + if (nx !== expectedNx) problems.push(`nx ${nx} != independently computed ${expectedNx}`); + if (ny !== expectedNy) problems.push(`ny ${ny} != independently computed ${expectedNy}`); + if (layersUsed !== expectedLayers) { + problems.push(`layers_used ${layersUsed} != independently computed ${expectedLayers}`); + } + if (count > int(item.quantity, 'quantity')) { + problems.push(`count ${count} exceeds requested quantity ${pyStr(item.quantity)}`); + } + const verticalCapacity = floorDiv(container.inner[2], layerStep); + if (layersUsed > verticalCapacity) { + problems.push(`layers_used ${layersUsed} exceeds vertical capacity ${verticalCapacity}`); + } +} + +function synthesize(fields, summary, itemType, containerId, clearance) { + const nx = int(fields.nx, 'lattice field'); + const ny = int(fields.ny, 'lattice field'); + const count = int(fields.count, 'lattice field'); + const layerStep = int(fields.layerStep, 'lattice field'); + const envelope = [int(fields.envelope[0], 'lattice field'), int(fields.envelope[1], 'lattice field')]; + const perLayer = nx * ny; + const physical = at(summary, 'physical_dimensions'); + const placements = []; + for (let index = 0n; index < count; index += 1n) { + placements.push({ + item_id: `${pyStr(itemType)}#lattice:${containerId}:${index + 1n}`, + item_type: itemType, + orientation: fields.orientation, + position: { + x: { ticks: (index % nx) * envelope[0] + clearance }, + y: { ticks: ((index / nx) % ny) * envelope[1] + clearance }, + z: { ticks: (index / perLayer) * layerStep + clearance }, + }, + dimensions: physical, + }); + } + return placements; +} diff --git a/result-validation/nonprintable.js b/result-validation/nonprintable.js new file mode 100644 index 0000000..8f98677 --- /dev/null +++ b/result-validation/nonprintable.js @@ -0,0 +1,728 @@ +/** + * The code points Python's `str.isprintable` rejects, from Unicode 14.0.0. + * Generated by scripts/generate_nonprintable_table.py; do not edit by hand. + */ + +export const UNICODE_VERSION = '14.0.0'; + +/** Inclusive, sorted, disjoint. */ +const RANGES = [ + [0x7f, 0xa0], + [0xad, 0xad], + [0x378, 0x379], + [0x380, 0x383], + [0x38b, 0x38b], + [0x38d, 0x38d], + [0x3a2, 0x3a2], + [0x530, 0x530], + [0x557, 0x558], + [0x58b, 0x58c], + [0x590, 0x590], + [0x5c8, 0x5cf], + [0x5eb, 0x5ee], + [0x5f5, 0x605], + [0x61c, 0x61c], + [0x6dd, 0x6dd], + [0x70e, 0x70f], + [0x74b, 0x74c], + [0x7b2, 0x7bf], + [0x7fb, 0x7fc], + [0x82e, 0x82f], + [0x83f, 0x83f], + [0x85c, 0x85d], + [0x85f, 0x85f], + [0x86b, 0x86f], + [0x88f, 0x897], + [0x8e2, 0x8e2], + [0x984, 0x984], + [0x98d, 0x98e], + [0x991, 0x992], + [0x9a9, 0x9a9], + [0x9b1, 0x9b1], + [0x9b3, 0x9b5], + [0x9ba, 0x9bb], + [0x9c5, 0x9c6], + [0x9c9, 0x9ca], + [0x9cf, 0x9d6], + [0x9d8, 0x9db], + [0x9de, 0x9de], + [0x9e4, 0x9e5], + [0x9ff, 0xa00], + [0xa04, 0xa04], + [0xa0b, 0xa0e], + [0xa11, 0xa12], + [0xa29, 0xa29], + [0xa31, 0xa31], + [0xa34, 0xa34], + [0xa37, 0xa37], + [0xa3a, 0xa3b], + [0xa3d, 0xa3d], + [0xa43, 0xa46], + [0xa49, 0xa4a], + [0xa4e, 0xa50], + [0xa52, 0xa58], + [0xa5d, 0xa5d], + [0xa5f, 0xa65], + [0xa77, 0xa80], + [0xa84, 0xa84], + [0xa8e, 0xa8e], + [0xa92, 0xa92], + [0xaa9, 0xaa9], + [0xab1, 0xab1], + [0xab4, 0xab4], + [0xaba, 0xabb], + [0xac6, 0xac6], + [0xaca, 0xaca], + [0xace, 0xacf], + [0xad1, 0xadf], + [0xae4, 0xae5], + [0xaf2, 0xaf8], + [0xb00, 0xb00], + [0xb04, 0xb04], + [0xb0d, 0xb0e], + [0xb11, 0xb12], + [0xb29, 0xb29], + [0xb31, 0xb31], + [0xb34, 0xb34], + [0xb3a, 0xb3b], + [0xb45, 0xb46], + [0xb49, 0xb4a], + [0xb4e, 0xb54], + [0xb58, 0xb5b], + [0xb5e, 0xb5e], + [0xb64, 0xb65], + [0xb78, 0xb81], + [0xb84, 0xb84], + [0xb8b, 0xb8d], + [0xb91, 0xb91], + [0xb96, 0xb98], + [0xb9b, 0xb9b], + [0xb9d, 0xb9d], + [0xba0, 0xba2], + [0xba5, 0xba7], + [0xbab, 0xbad], + [0xbba, 0xbbd], + [0xbc3, 0xbc5], + [0xbc9, 0xbc9], + [0xbce, 0xbcf], + [0xbd1, 0xbd6], + [0xbd8, 0xbe5], + [0xbfb, 0xbff], + [0xc0d, 0xc0d], + [0xc11, 0xc11], + [0xc29, 0xc29], + [0xc3a, 0xc3b], + [0xc45, 0xc45], + [0xc49, 0xc49], + [0xc4e, 0xc54], + [0xc57, 0xc57], + [0xc5b, 0xc5c], + [0xc5e, 0xc5f], + [0xc64, 0xc65], + [0xc70, 0xc76], + [0xc8d, 0xc8d], + [0xc91, 0xc91], + [0xca9, 0xca9], + [0xcb4, 0xcb4], + [0xcba, 0xcbb], + [0xcc5, 0xcc5], + [0xcc9, 0xcc9], + [0xcce, 0xcd4], + [0xcd7, 0xcdc], + [0xcdf, 0xcdf], + [0xce4, 0xce5], + [0xcf0, 0xcf0], + [0xcf3, 0xcff], + [0xd0d, 0xd0d], + [0xd11, 0xd11], + [0xd45, 0xd45], + [0xd49, 0xd49], + [0xd50, 0xd53], + [0xd64, 0xd65], + [0xd80, 0xd80], + [0xd84, 0xd84], + [0xd97, 0xd99], + [0xdb2, 0xdb2], + [0xdbc, 0xdbc], + [0xdbe, 0xdbf], + [0xdc7, 0xdc9], + [0xdcb, 0xdce], + [0xdd5, 0xdd5], + [0xdd7, 0xdd7], + [0xde0, 0xde5], + [0xdf0, 0xdf1], + [0xdf5, 0xe00], + [0xe3b, 0xe3e], + [0xe5c, 0xe80], + [0xe83, 0xe83], + [0xe85, 0xe85], + [0xe8b, 0xe8b], + [0xea4, 0xea4], + [0xea6, 0xea6], + [0xebe, 0xebf], + [0xec5, 0xec5], + [0xec7, 0xec7], + [0xece, 0xecf], + [0xeda, 0xedb], + [0xee0, 0xeff], + [0xf48, 0xf48], + [0xf6d, 0xf70], + [0xf98, 0xf98], + [0xfbd, 0xfbd], + [0xfcd, 0xfcd], + [0xfdb, 0xfff], + [0x10c6, 0x10c6], + [0x10c8, 0x10cc], + [0x10ce, 0x10cf], + [0x1249, 0x1249], + [0x124e, 0x124f], + [0x1257, 0x1257], + [0x1259, 0x1259], + [0x125e, 0x125f], + [0x1289, 0x1289], + [0x128e, 0x128f], + [0x12b1, 0x12b1], + [0x12b6, 0x12b7], + [0x12bf, 0x12bf], + [0x12c1, 0x12c1], + [0x12c6, 0x12c7], + [0x12d7, 0x12d7], + [0x1311, 0x1311], + [0x1316, 0x1317], + [0x135b, 0x135c], + [0x137d, 0x137f], + [0x139a, 0x139f], + [0x13f6, 0x13f7], + [0x13fe, 0x13ff], + [0x1680, 0x1680], + [0x169d, 0x169f], + [0x16f9, 0x16ff], + [0x1716, 0x171e], + [0x1737, 0x173f], + [0x1754, 0x175f], + [0x176d, 0x176d], + [0x1771, 0x1771], + [0x1774, 0x177f], + [0x17de, 0x17df], + [0x17ea, 0x17ef], + [0x17fa, 0x17ff], + [0x180e, 0x180e], + [0x181a, 0x181f], + [0x1879, 0x187f], + [0x18ab, 0x18af], + [0x18f6, 0x18ff], + [0x191f, 0x191f], + [0x192c, 0x192f], + [0x193c, 0x193f], + [0x1941, 0x1943], + [0x196e, 0x196f], + [0x1975, 0x197f], + [0x19ac, 0x19af], + [0x19ca, 0x19cf], + [0x19db, 0x19dd], + [0x1a1c, 0x1a1d], + [0x1a5f, 0x1a5f], + [0x1a7d, 0x1a7e], + [0x1a8a, 0x1a8f], + [0x1a9a, 0x1a9f], + [0x1aae, 0x1aaf], + [0x1acf, 0x1aff], + [0x1b4d, 0x1b4f], + [0x1b7f, 0x1b7f], + [0x1bf4, 0x1bfb], + [0x1c38, 0x1c3a], + [0x1c4a, 0x1c4c], + [0x1c89, 0x1c8f], + [0x1cbb, 0x1cbc], + [0x1cc8, 0x1ccf], + [0x1cfb, 0x1cff], + [0x1f16, 0x1f17], + [0x1f1e, 0x1f1f], + [0x1f46, 0x1f47], + [0x1f4e, 0x1f4f], + [0x1f58, 0x1f58], + [0x1f5a, 0x1f5a], + [0x1f5c, 0x1f5c], + [0x1f5e, 0x1f5e], + [0x1f7e, 0x1f7f], + [0x1fb5, 0x1fb5], + [0x1fc5, 0x1fc5], + [0x1fd4, 0x1fd5], + [0x1fdc, 0x1fdc], + [0x1ff0, 0x1ff1], + [0x1ff5, 0x1ff5], + [0x1fff, 0x200f], + [0x2028, 0x202f], + [0x205f, 0x206f], + [0x2072, 0x2073], + [0x208f, 0x208f], + [0x209d, 0x209f], + [0x20c1, 0x20cf], + [0x20f1, 0x20ff], + [0x218c, 0x218f], + [0x2427, 0x243f], + [0x244b, 0x245f], + [0x2b74, 0x2b75], + [0x2b96, 0x2b96], + [0x2cf4, 0x2cf8], + [0x2d26, 0x2d26], + [0x2d28, 0x2d2c], + [0x2d2e, 0x2d2f], + [0x2d68, 0x2d6e], + [0x2d71, 0x2d7e], + [0x2d97, 0x2d9f], + [0x2da7, 0x2da7], + [0x2daf, 0x2daf], + [0x2db7, 0x2db7], + [0x2dbf, 0x2dbf], + [0x2dc7, 0x2dc7], + [0x2dcf, 0x2dcf], + [0x2dd7, 0x2dd7], + [0x2ddf, 0x2ddf], + [0x2e5e, 0x2e7f], + [0x2e9a, 0x2e9a], + [0x2ef4, 0x2eff], + [0x2fd6, 0x2fef], + [0x2ffc, 0x3000], + [0x3040, 0x3040], + [0x3097, 0x3098], + [0x3100, 0x3104], + [0x3130, 0x3130], + [0x318f, 0x318f], + [0x31e4, 0x31ef], + [0x321f, 0x321f], + [0xa48d, 0xa48f], + [0xa4c7, 0xa4cf], + [0xa62c, 0xa63f], + [0xa6f8, 0xa6ff], + [0xa7cb, 0xa7cf], + [0xa7d2, 0xa7d2], + [0xa7d4, 0xa7d4], + [0xa7da, 0xa7f1], + [0xa82d, 0xa82f], + [0xa83a, 0xa83f], + [0xa878, 0xa87f], + [0xa8c6, 0xa8cd], + [0xa8da, 0xa8df], + [0xa954, 0xa95e], + [0xa97d, 0xa97f], + [0xa9ce, 0xa9ce], + [0xa9da, 0xa9dd], + [0xa9ff, 0xa9ff], + [0xaa37, 0xaa3f], + [0xaa4e, 0xaa4f], + [0xaa5a, 0xaa5b], + [0xaac3, 0xaada], + [0xaaf7, 0xab00], + [0xab07, 0xab08], + [0xab0f, 0xab10], + [0xab17, 0xab1f], + [0xab27, 0xab27], + [0xab2f, 0xab2f], + [0xab6c, 0xab6f], + [0xabee, 0xabef], + [0xabfa, 0xabff], + [0xd7a4, 0xd7af], + [0xd7c7, 0xd7ca], + [0xd7fc, 0xf8ff], + [0xfa6e, 0xfa6f], + [0xfada, 0xfaff], + [0xfb07, 0xfb12], + [0xfb18, 0xfb1c], + [0xfb37, 0xfb37], + [0xfb3d, 0xfb3d], + [0xfb3f, 0xfb3f], + [0xfb42, 0xfb42], + [0xfb45, 0xfb45], + [0xfbc3, 0xfbd2], + [0xfd90, 0xfd91], + [0xfdc8, 0xfdce], + [0xfdd0, 0xfdef], + [0xfe1a, 0xfe1f], + [0xfe53, 0xfe53], + [0xfe67, 0xfe67], + [0xfe6c, 0xfe6f], + [0xfe75, 0xfe75], + [0xfefd, 0xff00], + [0xffbf, 0xffc1], + [0xffc8, 0xffc9], + [0xffd0, 0xffd1], + [0xffd8, 0xffd9], + [0xffdd, 0xffdf], + [0xffe7, 0xffe7], + [0xffef, 0xfffb], + [0xfffe, 0xffff], + [0x1000c, 0x1000c], + [0x10027, 0x10027], + [0x1003b, 0x1003b], + [0x1003e, 0x1003e], + [0x1004e, 0x1004f], + [0x1005e, 0x1007f], + [0x100fb, 0x100ff], + [0x10103, 0x10106], + [0x10134, 0x10136], + [0x1018f, 0x1018f], + [0x1019d, 0x1019f], + [0x101a1, 0x101cf], + [0x101fe, 0x1027f], + [0x1029d, 0x1029f], + [0x102d1, 0x102df], + [0x102fc, 0x102ff], + [0x10324, 0x1032c], + [0x1034b, 0x1034f], + [0x1037b, 0x1037f], + [0x1039e, 0x1039e], + [0x103c4, 0x103c7], + [0x103d6, 0x103ff], + [0x1049e, 0x1049f], + [0x104aa, 0x104af], + [0x104d4, 0x104d7], + [0x104fc, 0x104ff], + [0x10528, 0x1052f], + [0x10564, 0x1056e], + [0x1057b, 0x1057b], + [0x1058b, 0x1058b], + [0x10593, 0x10593], + [0x10596, 0x10596], + [0x105a2, 0x105a2], + [0x105b2, 0x105b2], + [0x105ba, 0x105ba], + [0x105bd, 0x105ff], + [0x10737, 0x1073f], + [0x10756, 0x1075f], + [0x10768, 0x1077f], + [0x10786, 0x10786], + [0x107b1, 0x107b1], + [0x107bb, 0x107ff], + [0x10806, 0x10807], + [0x10809, 0x10809], + [0x10836, 0x10836], + [0x10839, 0x1083b], + [0x1083d, 0x1083e], + [0x10856, 0x10856], + [0x1089f, 0x108a6], + [0x108b0, 0x108df], + [0x108f3, 0x108f3], + [0x108f6, 0x108fa], + [0x1091c, 0x1091e], + [0x1093a, 0x1093e], + [0x10940, 0x1097f], + [0x109b8, 0x109bb], + [0x109d0, 0x109d1], + [0x10a04, 0x10a04], + [0x10a07, 0x10a0b], + [0x10a14, 0x10a14], + [0x10a18, 0x10a18], + [0x10a36, 0x10a37], + [0x10a3b, 0x10a3e], + [0x10a49, 0x10a4f], + [0x10a59, 0x10a5f], + [0x10aa0, 0x10abf], + [0x10ae7, 0x10aea], + [0x10af7, 0x10aff], + [0x10b36, 0x10b38], + [0x10b56, 0x10b57], + [0x10b73, 0x10b77], + [0x10b92, 0x10b98], + [0x10b9d, 0x10ba8], + [0x10bb0, 0x10bff], + [0x10c49, 0x10c7f], + [0x10cb3, 0x10cbf], + [0x10cf3, 0x10cf9], + [0x10d28, 0x10d2f], + [0x10d3a, 0x10e5f], + [0x10e7f, 0x10e7f], + [0x10eaa, 0x10eaa], + [0x10eae, 0x10eaf], + [0x10eb2, 0x10eff], + [0x10f28, 0x10f2f], + [0x10f5a, 0x10f6f], + [0x10f8a, 0x10faf], + [0x10fcc, 0x10fdf], + [0x10ff7, 0x10fff], + [0x1104e, 0x11051], + [0x11076, 0x1107e], + [0x110bd, 0x110bd], + [0x110c3, 0x110cf], + [0x110e9, 0x110ef], + [0x110fa, 0x110ff], + [0x11135, 0x11135], + [0x11148, 0x1114f], + [0x11177, 0x1117f], + [0x111e0, 0x111e0], + [0x111f5, 0x111ff], + [0x11212, 0x11212], + [0x1123f, 0x1127f], + [0x11287, 0x11287], + [0x11289, 0x11289], + [0x1128e, 0x1128e], + [0x1129e, 0x1129e], + [0x112aa, 0x112af], + [0x112eb, 0x112ef], + [0x112fa, 0x112ff], + [0x11304, 0x11304], + [0x1130d, 0x1130e], + [0x11311, 0x11312], + [0x11329, 0x11329], + [0x11331, 0x11331], + [0x11334, 0x11334], + [0x1133a, 0x1133a], + [0x11345, 0x11346], + [0x11349, 0x1134a], + [0x1134e, 0x1134f], + [0x11351, 0x11356], + [0x11358, 0x1135c], + [0x11364, 0x11365], + [0x1136d, 0x1136f], + [0x11375, 0x113ff], + [0x1145c, 0x1145c], + [0x11462, 0x1147f], + [0x114c8, 0x114cf], + [0x114da, 0x1157f], + [0x115b6, 0x115b7], + [0x115de, 0x115ff], + [0x11645, 0x1164f], + [0x1165a, 0x1165f], + [0x1166d, 0x1167f], + [0x116ba, 0x116bf], + [0x116ca, 0x116ff], + [0x1171b, 0x1171c], + [0x1172c, 0x1172f], + [0x11747, 0x117ff], + [0x1183c, 0x1189f], + [0x118f3, 0x118fe], + [0x11907, 0x11908], + [0x1190a, 0x1190b], + [0x11914, 0x11914], + [0x11917, 0x11917], + [0x11936, 0x11936], + [0x11939, 0x1193a], + [0x11947, 0x1194f], + [0x1195a, 0x1199f], + [0x119a8, 0x119a9], + [0x119d8, 0x119d9], + [0x119e5, 0x119ff], + [0x11a48, 0x11a4f], + [0x11aa3, 0x11aaf], + [0x11af9, 0x11bff], + [0x11c09, 0x11c09], + [0x11c37, 0x11c37], + [0x11c46, 0x11c4f], + [0x11c6d, 0x11c6f], + [0x11c90, 0x11c91], + [0x11ca8, 0x11ca8], + [0x11cb7, 0x11cff], + [0x11d07, 0x11d07], + [0x11d0a, 0x11d0a], + [0x11d37, 0x11d39], + [0x11d3b, 0x11d3b], + [0x11d3e, 0x11d3e], + [0x11d48, 0x11d4f], + [0x11d5a, 0x11d5f], + [0x11d66, 0x11d66], + [0x11d69, 0x11d69], + [0x11d8f, 0x11d8f], + [0x11d92, 0x11d92], + [0x11d99, 0x11d9f], + [0x11daa, 0x11edf], + [0x11ef9, 0x11faf], + [0x11fb1, 0x11fbf], + [0x11ff2, 0x11ffe], + [0x1239a, 0x123ff], + [0x1246f, 0x1246f], + [0x12475, 0x1247f], + [0x12544, 0x12f8f], + [0x12ff3, 0x12fff], + [0x1342f, 0x143ff], + [0x14647, 0x167ff], + [0x16a39, 0x16a3f], + [0x16a5f, 0x16a5f], + [0x16a6a, 0x16a6d], + [0x16abf, 0x16abf], + [0x16aca, 0x16acf], + [0x16aee, 0x16aef], + [0x16af6, 0x16aff], + [0x16b46, 0x16b4f], + [0x16b5a, 0x16b5a], + [0x16b62, 0x16b62], + [0x16b78, 0x16b7c], + [0x16b90, 0x16e3f], + [0x16e9b, 0x16eff], + [0x16f4b, 0x16f4e], + [0x16f88, 0x16f8e], + [0x16fa0, 0x16fdf], + [0x16fe5, 0x16fef], + [0x16ff2, 0x16fff], + [0x187f8, 0x187ff], + [0x18cd6, 0x18cff], + [0x18d09, 0x1afef], + [0x1aff4, 0x1aff4], + [0x1affc, 0x1affc], + [0x1afff, 0x1afff], + [0x1b123, 0x1b14f], + [0x1b153, 0x1b163], + [0x1b168, 0x1b16f], + [0x1b2fc, 0x1bbff], + [0x1bc6b, 0x1bc6f], + [0x1bc7d, 0x1bc7f], + [0x1bc89, 0x1bc8f], + [0x1bc9a, 0x1bc9b], + [0x1bca0, 0x1ceff], + [0x1cf2e, 0x1cf2f], + [0x1cf47, 0x1cf4f], + [0x1cfc4, 0x1cfff], + [0x1d0f6, 0x1d0ff], + [0x1d127, 0x1d128], + [0x1d173, 0x1d17a], + [0x1d1eb, 0x1d1ff], + [0x1d246, 0x1d2df], + [0x1d2f4, 0x1d2ff], + [0x1d357, 0x1d35f], + [0x1d379, 0x1d3ff], + [0x1d455, 0x1d455], + [0x1d49d, 0x1d49d], + [0x1d4a0, 0x1d4a1], + [0x1d4a3, 0x1d4a4], + [0x1d4a7, 0x1d4a8], + [0x1d4ad, 0x1d4ad], + [0x1d4ba, 0x1d4ba], + [0x1d4bc, 0x1d4bc], + [0x1d4c4, 0x1d4c4], + [0x1d506, 0x1d506], + [0x1d50b, 0x1d50c], + [0x1d515, 0x1d515], + [0x1d51d, 0x1d51d], + [0x1d53a, 0x1d53a], + [0x1d53f, 0x1d53f], + [0x1d545, 0x1d545], + [0x1d547, 0x1d549], + [0x1d551, 0x1d551], + [0x1d6a6, 0x1d6a7], + [0x1d7cc, 0x1d7cd], + [0x1da8c, 0x1da9a], + [0x1daa0, 0x1daa0], + [0x1dab0, 0x1deff], + [0x1df1f, 0x1dfff], + [0x1e007, 0x1e007], + [0x1e019, 0x1e01a], + [0x1e022, 0x1e022], + [0x1e025, 0x1e025], + [0x1e02b, 0x1e0ff], + [0x1e12d, 0x1e12f], + [0x1e13e, 0x1e13f], + [0x1e14a, 0x1e14d], + [0x1e150, 0x1e28f], + [0x1e2af, 0x1e2bf], + [0x1e2fa, 0x1e2fe], + [0x1e300, 0x1e7df], + [0x1e7e7, 0x1e7e7], + [0x1e7ec, 0x1e7ec], + [0x1e7ef, 0x1e7ef], + [0x1e7ff, 0x1e7ff], + [0x1e8c5, 0x1e8c6], + [0x1e8d7, 0x1e8ff], + [0x1e94c, 0x1e94f], + [0x1e95a, 0x1e95d], + [0x1e960, 0x1ec70], + [0x1ecb5, 0x1ed00], + [0x1ed3e, 0x1edff], + [0x1ee04, 0x1ee04], + [0x1ee20, 0x1ee20], + [0x1ee23, 0x1ee23], + [0x1ee25, 0x1ee26], + [0x1ee28, 0x1ee28], + [0x1ee33, 0x1ee33], + [0x1ee38, 0x1ee38], + [0x1ee3a, 0x1ee3a], + [0x1ee3c, 0x1ee41], + [0x1ee43, 0x1ee46], + [0x1ee48, 0x1ee48], + [0x1ee4a, 0x1ee4a], + [0x1ee4c, 0x1ee4c], + [0x1ee50, 0x1ee50], + [0x1ee53, 0x1ee53], + [0x1ee55, 0x1ee56], + [0x1ee58, 0x1ee58], + [0x1ee5a, 0x1ee5a], + [0x1ee5c, 0x1ee5c], + [0x1ee5e, 0x1ee5e], + [0x1ee60, 0x1ee60], + [0x1ee63, 0x1ee63], + [0x1ee65, 0x1ee66], + [0x1ee6b, 0x1ee6b], + [0x1ee73, 0x1ee73], + [0x1ee78, 0x1ee78], + [0x1ee7d, 0x1ee7d], + [0x1ee7f, 0x1ee7f], + [0x1ee8a, 0x1ee8a], + [0x1ee9c, 0x1eea0], + [0x1eea4, 0x1eea4], + [0x1eeaa, 0x1eeaa], + [0x1eebc, 0x1eeef], + [0x1eef2, 0x1efff], + [0x1f02c, 0x1f02f], + [0x1f094, 0x1f09f], + [0x1f0af, 0x1f0b0], + [0x1f0c0, 0x1f0c0], + [0x1f0d0, 0x1f0d0], + [0x1f0f6, 0x1f0ff], + [0x1f1ae, 0x1f1e5], + [0x1f203, 0x1f20f], + [0x1f23c, 0x1f23f], + [0x1f249, 0x1f24f], + [0x1f252, 0x1f25f], + [0x1f266, 0x1f2ff], + [0x1f6d8, 0x1f6dc], + [0x1f6ed, 0x1f6ef], + [0x1f6fd, 0x1f6ff], + [0x1f774, 0x1f77f], + [0x1f7d9, 0x1f7df], + [0x1f7ec, 0x1f7ef], + [0x1f7f1, 0x1f7ff], + [0x1f80c, 0x1f80f], + [0x1f848, 0x1f84f], + [0x1f85a, 0x1f85f], + [0x1f888, 0x1f88f], + [0x1f8ae, 0x1f8af], + [0x1f8b2, 0x1f8ff], + [0x1fa54, 0x1fa5f], + [0x1fa6e, 0x1fa6f], + [0x1fa75, 0x1fa77], + [0x1fa7d, 0x1fa7f], + [0x1fa87, 0x1fa8f], + [0x1faad, 0x1faaf], + [0x1fabb, 0x1fabf], + [0x1fac6, 0x1facf], + [0x1fada, 0x1fadf], + [0x1fae8, 0x1faef], + [0x1faf7, 0x1faff], + [0x1fb93, 0x1fb93], + [0x1fbcb, 0x1fbef], + [0x1fbfa, 0x1ffff], + [0x2a6e0, 0x2a6ff], + [0x2b739, 0x2b73f], + [0x2b81e, 0x2b81f], + [0x2cea2, 0x2ceaf], + [0x2ebe1, 0x2f7ff], + [0x2fa1e, 0x2ffff], + [0x3134b, 0xe00ff], + [0xe01f0, 0x10ffff], +]; + +/** O(log r) over the r ranges. */ +export function nonPrintable(code) { + let low = 0; + let high = RANGES.length - 1; + while (low <= high) { + const middle = (low + high) >> 1; + const [first, last] = RANGES[middle]; + if (code < first) { + high = middle - 1; + } else if (code > last) { + low = middle + 1; + } else { + return true; + } + } + return false; +} diff --git a/result-validation/num.js b/result-validation/num.js new file mode 100644 index 0000000..60cfec7 --- /dev/null +++ b/result-validation/num.js @@ -0,0 +1,195 @@ +/** + * Exact integer and rational arithmetic with Python's semantics, in `BigInt`. + * + * The reference computes on unbounded integers and `Fraction`, and so does this port: a + * trailer's volume in cubic ticks is about 4e32, far past 2^53, and its products with a weight + * unit or a million are larger still. Floats appear only where the reference itself converts a + * ratio to a double (`float(fraction)`, `int(float(ratio) * 10^6 + 0.5)`, `%.6f`), and those + * conversions are reproduced bit for bit. + */ + +import { shape } from './outcome.js'; +import { pyStrip } from './json.js'; + +export const max = (left, right) => (left > right ? left : right); +export const min = (left, right) => (left < right ? left : right); +export const abs = (value) => (value < 0n ? -value : value); + +/** Python's `left // right`: rounds toward negative infinity. */ +export function floorDiv(left, right) { + if (right === 0n) throw shape('division by zero'); + const quotient = left / right; + return left % right !== 0n && (left < 0n) !== (right < 0n) ? quotient - 1n : quotient; +} + +/** Python's `left % right`: takes the sign of the divisor. */ +export const pyMod = (left, right) => left - floorDiv(left, right) * right; + +/** `-(-left // right)`, the reference's ceiling division. */ +export const ceilDiv = (left, right) => -floorDiv(-left, right); + +export function gcd(left, right) { + let a = abs(left); + let b = abs(right); + while (b !== 0n) [a, b] = [b, a % b]; + return a; +} + +const bitLength = (value) => value.toString(2).length; + +/** Python's `Fraction`: always reduced, the denominator always positive. */ +export class Ratio { + constructor(numerator, denominator) { + if (denominator === 0n) throw shape('Fraction with a zero denominator'); + const divisor = gcd(numerator, denominator); + const flip = denominator < 0n ? -1n : 1n; + this.numerator = (flip * numerator) / divisor; + this.denominator = (flip * denominator) / divisor; + Object.freeze(this); + } + + static of(integer) { + return new Ratio(integer, 1n); + } + + add(other) { + return new Ratio( + this.numerator * other.denominator + other.numerator * this.denominator, + this.denominator * other.denominator, + ); + } + + mul(other) { + return new Ratio(this.numerator * other.numerator, this.denominator * other.denominator); + } + + negate() { + return new Ratio(-this.numerator, this.denominator); + } + + /** -1, 0 or 1 as `this` is below, equal to or above `other`. */ + compare(other) { + const difference = this.numerator * other.denominator - other.numerator * this.denominator; + return difference < 0n ? -1 : difference > 0n ? 1 : 0; + } + + /** `numerator // denominator`. */ + floor() { + return floorDiv(this.numerator, this.denominator); + } + + /** + * Python's `float(fraction)`: the nearest double, ties to even, by exact division to more + * bits than a double holds plus a sticky remainder bit. A ratio no double can hold is + * Python's `OverflowError`. + */ + toDouble() { + if (this.numerator === 0n) return 0; + const magnitude = abs(this.numerator); + // A quotient of 66 or 67 bits: 13 or more below the 53 a double keeps, for the rounding. + const shift = 66 - (bitLength(magnitude) - bitLength(this.denominator)); + const dividend = shift >= 0 ? magnitude << BigInt(shift) : magnitude; + const divisor = shift >= 0 ? this.denominator : this.denominator << BigInt(-shift); + let mantissa = dividend / divisor; + const sticky = dividend % divisor !== 0n; + const dropped = bitLength(mantissa) - 53; + const remainder = mantissa & ((1n << BigInt(dropped)) - 1n); + const half = 1n << BigInt(dropped - 1); + mantissa >>= BigInt(dropped); + if (remainder > half || (remainder === half && (sticky || (mantissa & 1n) === 1n))) mantissa += 1n; + // Exact for every normal double; no ratio the validator converts is subnormal. + const value = Number(mantissa) * 2 ** (dropped - shift); + if (!Number.isFinite(value)) throw shape('integer division result too large for a float'); + return this.numerator < 0n ? -value : value; + } +} + +/** A run of `\d+(_\d+)*` (possibly empty) at the start of `text`, and what follows it. */ +function digitGroup(text) { + const match = /^(?:\d+(?:_\d+)*)?/.exec(text)[0]; + return [match.replace(/_/g, ''), text.slice(match.length)]; +} + +/** + * `Fraction(text)`, by the grammar of CPython 3.11's `fractions._RATIONAL_FORMAT`: optional + * whitespace and sign, then `n/d`, or a decimal with an optional exponent; digit groups may be + * split by single underscores. Only ASCII digits are read, as the other ports read them. + */ +export function parseFraction(text) { + const invalid = () => shape(`Invalid literal for Fraction: ${JSON.stringify(text)}`); + let body = pyStrip(text); + const negative = body.startsWith('-'); + if (negative || body.startsWith('+')) body = body.slice(1); + if (!/^(?:\d|\.\d)/.test(body)) throw invalid(); + const [whole, rest] = digitGroup(body); + let numerator = whole === '' ? 0n : BigInt(whole); + let denominator = 1n; + if (rest.startsWith('/')) { + const [digits, tail] = digitGroup(rest.slice(1)); + if (digits === '' || tail !== '') throw invalid(); + denominator = BigInt(digits); + } else { + let tail = rest; + if (tail.startsWith('.')) { + const [decimals, afterDecimals] = digitGroup(tail.slice(1)); + if (decimals !== '') { + const scale = 10n ** BigInt(decimals.length); + numerator = numerator * scale + BigInt(decimals); + denominator = scale; + } + tail = afterDecimals; + } + const exponent = /^[eE]([+-]?)/.exec(tail); + if (exponent !== null) { + const [digits, afterExponent] = digitGroup(tail.slice(exponent[0].length)); + if (digits === '') throw invalid(); + const factor = 10n ** BigInt(digits); + if (exponent[1] === '-') denominator *= factor; + else numerator *= factor; + tail = afterExponent; + } + if (tail !== '') throw invalid(); + } + return new Ratio(negative ? -numerator : numerator, denominator); +} + +const DOUBLE_VIEW = new DataView(new ArrayBuffer(8)); + +/** The exact value of a finite double, as `[negative, mantissa, exponent]` with |v| = m * 2^e. */ +function exactDouble(value) { + DOUBLE_VIEW.setFloat64(0, value); + const high = DOUBLE_VIEW.getUint32(0); + const low = DOUBLE_VIEW.getUint32(4); + const biased = (high >>> 20) & 0x7ff; + const fraction = (BigInt(high & 0xfffff) << 32n) | BigInt(low); + const negative = high >>> 31 === 1; + if (biased === 0) return [negative, fraction, -1074]; + return [negative, fraction | (1n << 52n), biased - 1075]; +} + +/** + * `f"{value:.6f}"`: the exact binary value rounded half to even at six decimals, as Python + * formats it. `toFixed` rounds an exact tie up, and 1/128 is one. + */ +export function fixed6(value) { + if (Number.isNaN(value)) return 'nan'; + if (!Number.isFinite(value)) return value > 0 ? 'inf' : '-inf'; + const [negative, mantissa, exponent] = exactDouble(value); + const scaled = mantissa * 1000000n; + let units; + if (exponent >= 0) { + units = scaled << BigInt(exponent); + } else { + const divisor = 1n << BigInt(-exponent); + units = scaled / divisor; + const twice = (scaled % divisor) * 2n; + if (twice > divisor || (twice === divisor && units % 2n === 1n)) units += 1n; + } + const digits = units.toString().padStart(7, '0'); + return `${negative ? '-' : ''}${digits.slice(0, -6)}.${digits.slice(-6)}`; +} + +/** Python's `int(float(ratio) * 1_000_000 + 0.5)`, the solvers' ppm rule. */ +export function ratioPpm(ratio) { + return BigInt(Math.trunc(ratio.toDouble() * 1000000 + 0.5)); +} diff --git a/result-validation/objective.js b/result-validation/objective.js new file mode 100644 index 0000000..c57a04a --- /dev/null +++ b/result-validation/objective.js @@ -0,0 +1,258 @@ +/** + * The objective vector recomputed from the result alone, and the request-level rules that only + * the whole result can answer: fixed placements, groups and solver selection. + */ + +import { achievedExtent } from './container.js'; +import { + PyDict, at, compareCodePoints, getNull, getOr, has, hashKey, int, list, pyCmp, pyEq, pyInt, sortPy, text, truthy, +} from './json.js'; +import { ceilDiv, floorDiv, max } from './num.js'; +import { contract, issue, shape } from './outcome.js'; +import { intList, pyRepr, pyStr, valueTuple } from './pyfmt.js'; +import { volumeOf } from './spec.js'; +import { SCORE_SCALE } from './stacking.js'; +import { LENGTH, TICKS_PER_GRAM, WEIGHT, perUnit, toTicks } from './units.js'; + +/** `floor(volume * weight ticks per unit / (length ticks per unit ^ 3 * divisor))`. */ +function dimensionalWeight(size, divisor, lengthUnit, weightUnit) { + const length = perUnit(LENGTH, lengthUnit); + const weight = perUnit(WEIGHT, weightUnit); + return floorDiv(size[0] * size[1] * size[2] * weight, length ** 3n * divisor); +} + +/** + * One container's carrier charge from the request's own rate card: billed weight up to whole + * grams, the minimum charge, then the fuel surcharge rounded up. + */ +function landedCharge(spec, billed) { + if (spec.rateTable === null) { + throw contract(`container ${pyRepr(spec.id)} has no rate_table, so lowest_landed_cost cannot price it`); + } + const table = spec.rateTable; + const brackets = list(at(table, 'weight_brackets_g'), 'weight_brackets_g').map((bound) => pyInt(bound, 'bracket')); + const prices = list(at(table, 'prices_minor'), 'prices_minor').map((price) => pyInt(price, 'price')); + const grams = ceilDiv(billed, TICKS_PER_GRAM); + for (let index = 0; index < Math.min(brackets.length, prices.length); index += 1) { + if (grams <= brackets[index]) { + const base = max(prices[index], pyInt(getOr(table, 'minimum_charge_minor', 0), 'minimum_charge_minor')); + const permille = pyInt(getOr(table, 'fuel_surcharge_permille', 0), 'fuel_surcharge_permille'); + return base + ceilDiv(base * permille, 1000n); + } + } + if (brackets.length === 0) throw shape('an empty rate table'); + throw contract(`container ${pyRepr(spec.id)} bills at ${grams} g, above its rate table's last bracket ` + + `(${brackets[brackets.length - 1]} g); the shipment has no published price`); +} + +/** The highest envelope top over a container's placements, 0 when it holds none. */ +function topOf(packed, clearance) { + const tops = list(at(packed, 'placements'), 'placements').map((placement) => { + const z = int(at(at(at(placement, 'position'), 'z'), 'ticks'), 'placement ticks'); + const height = int(at(at(at(placement, 'dimensions'), 'height'), 'ticks'), 'placement ticks'); + return z + height + clearance; + }); + return tops.length === 0 ? 0n : tops.reduce(max); +} + +/** What one container is billed at under `shipping_cost` and `lowest_landed_cost`. */ +function billedWeight(packed, spec, inputs, billing) { + const divisor = int(billing.divisor, 'dimensional_weight_divisor'); + const gross = pyInt(getOr(getOr(packed, 'gross_weight', {}), 'ticks', 0), 'gross_weight'); + const size = spec.flatBag ? achievedExtent(packed, inputs.clearance) : spec.outer ?? spec.inner; + const dimensional = dimensionalWeight(size, divisor, text(billing.lengthUnit, 'dimensional_weight_length_unit'), + text(billing.weightUnit, 'dimensional_weight_weight_unit')); + return max(gross, dimensional); +} + +/** The per-container sums every objective draws on. */ +function containerTotals(containers, inputs, name, billing) { + const totals = { cost: 0n, unused: 0n, height: 0n, billable: 0n, achievedHeight: 0n, landed: 0n }; + const billed = (name === 'shipping_cost' || name === 'lowest_landed_cost') && billing.divisor !== null; + for (const packed of containers) { + const spec = inputs.fabricated.get(getNull(packed, 'id')) ?? inputs.containers.get(at(packed, 'container_type')); + if (spec === undefined) continue; + totals.cost += spec.costMinor; + const volume = volumeOf(spec); + if (volume === 0n) continue; + const used = pyInt(at(packed, 'used_volume_ticks3'), 'used_volume_ticks3'); + totals.unused += floorDiv((volume - used) * SCORE_SCALE, volume); + const top = topOf(packed, inputs.clearance); + totals.height += floorDiv(top * SCORE_SCALE, spec.inner[2]); + totals.achievedHeight += top; + if (!billed) continue; + const weight = billedWeight(packed, spec, inputs, billing); + totals.billable += weight; + if (name === 'lowest_landed_cost') totals.landed += landedCharge(spec, weight); + } + return totals; +} + +/** `sum(nominal volume)` over the unpacked items whose type the request declares. */ +function unpackedSum(unpackedItems, items, measure) { + return unpackedItems.reduce((total, unpacked) => { + const spec = items.get(at(unpacked, 'item_type')); + return spec === undefined ? total : total + measure(spec); + }, 0n); +} + +const nominalVolume = (spec) => spec.dimensions[0] * spec.dimensions[1] * spec.dimensions[2]; + +/** The objective vector of `result` under the objective `name`. */ +function objective(result, inputs, name, billing) { + const containers = list(at(result, 'containers'), 'result.containers'); + const totals = containerTotals(containers, inputs, name, billing); + const unpackedItems = list(at(result, 'unpacked_items'), 'result.unpacked_items'); + const unpacked = BigInt(unpackedItems.length); + const opened = BigInt(containers.length); + const unknown = () => contract(`unknown requested objective ${pyRepr(name)}`); + if (typeof name !== 'string') throw unknown(); + const requiresDivisor = () => { + if (billing.divisor === null) throw contract(`${name} objective requires configuration.dimensional_weight_divisor`); + }; + switch (name) { + case 'default': return [unpacked, opened, totals.cost, totals.unused, totals.height]; + case 'lowest_cost': return [unpacked, totals.cost, opened, totals.unused, totals.height]; + case 'shipping_cost': + requiresDivisor(); + return [unpacked, totals.billable, opened, totals.unused, totals.height]; + case 'lowest_landed_cost': + requiresDivisor(); + return [unpacked, totals.landed, opened, totals.unused, totals.height]; + case 'open_dimension_height': return [unpacked, totals.achievedHeight, opened, totals.cost, totals.unused]; + case 'maximum_value': { + const forgone = unpackedSum(unpackedItems, inputs.items, (spec) => (truthy(spec.value) ? int(spec.value, 'value') : 0n)); + return [unpacked, forgone, opened, totals.cost, totals.unused]; + } + case 'maximum_volume': { + const unpackedVolume = unpackedSum(unpackedItems, inputs.items, nominalVolume); + const requested = inputs.items.values() + .reduce((total, spec) => total + nominalVolume(spec) * int(spec.quantity, 'quantity'), 0n); + const unpackedPpm = requested === 0n ? 0n : ceilDiv(unpackedVolume * SCORE_SCALE, requested); + return [unpackedPpm, unpacked, opened, totals.cost, totals.unused]; + } + default: throw unknown(); + } +} + +/** Checks the reported score against the recomputed one, and returns the reported score. */ +export function checkObjective(request, result, inputs, issues) { + const configuration = getOr(request, 'configuration', {}); + const selected = getOr(configuration, 'objective', 'default'); + const reportedName = getNull(result, 'objective'); + if ((has(configuration, 'objective') || reportedName !== null) && !pyEq(reportedName, selected)) { + issues.push(issue('objective_mismatch', `reported ${pyRepr(reportedName)}, requested ${pyRepr(selected)}`)); + } + const reported = list(at(result, 'score'), 'score').map((component) => pyInt(component, 'score')); + const billing = { + divisor: getNull(configuration, 'dimensional_weight_divisor'), + lengthUnit: getOr(configuration, 'dimensional_weight_length_unit', 'in'), + weightUnit: getOr(configuration, 'dimensional_weight_weight_unit', 'lb'), + }; + const recomputed = objective(result, inputs, selected, billing); + if (reported.length !== recomputed.length || reported.some((value, index) => value !== recomputed[index])) { + issues.push(issue('score_mismatch', `reported ${intList(reported)}, recomputed ${intList(recomputed)}`)); + } + return reported; +} + +export function checkSolverSelection(request, result, issues) { + const selected = getOr(getOr(request, 'configuration', {}), 'solvers', []); + if (!truthy(selected)) return; + const solvers = list(selected, 'configuration.solvers'); + const reported = getNull(at(result, 'algorithm'), 'solver'); + const base = typeof reported === 'string' ? reported.split(':', 1)[0] : reported; + if (!solvers.some((solver) => pyEq(solver, base))) { + issues.push(issue('solver_selection_mismatch', `reported ${pyRepr(reported)}, requested one of ${pyRepr(selected)}`)); + } +} + +/** A group travels together or not at all: one container, and never half-packed. */ +export function checkGroups(request, result, issues) { + const rawItems = list(at(request, 'items'), 'request.items'); + const groups = new PyDict(); + for (const raw of rawItems) { + if (getNull(raw, 'group') !== null) groups.set(at(raw, 'id'), at(raw, 'group')); + } + if (groups.size === 0) return; + const located = new PyDict(); + const packed = new PyDict(); + for (const container of list(at(result, 'containers'), 'result.containers')) { + for (const placement of list(at(container, 'placements'), 'placements')) { + const group = groups.get(at(placement, 'item_type')); + if (group === undefined) continue; + if (!located.has(group)) located.set(group, new PyDict()); + located.get(group).set(at(container, 'id'), true); + packed.set(group, (packed.get(group) ?? 0n) + 1n); + } + } + const expected = new PyDict(); + for (const raw of rawItems) { + const group = getNull(raw, 'group'); + if (group !== null) expected.set(group, (expected.get(group) ?? 0n) + int(getOr(raw, 'quantity', 1), 'quantity')); + } + for (const group of sortPy(located.keys())) { + const places = located.get(group).keys(); + if (places.length > 1) { + const where = places.map((place) => text(place, 'a container id')).sort(compareCodePoints); + issues.push(issue('group_split', `${pyStr(group)}: ${where.join(', ')}`)); + } + const placed = packed.get(group) ?? 0n; + const wanted = expected.get(group) ?? 0n; + if (placed !== wanted) issues.push(issue('group_partially_packed', `${pyStr(group)}: ${placed} of ${wanted} placed`)); + } +} + +/** A fixed placement's identity, `[container id, item type, x, y, z, orientation]`, hashed whole. */ +const keyHash = (key) => key.map(hashKey).join('\u0000'); + +/** `set.add(key)`: an equal key already present stays, as Python's set keeps it. */ +function keepFirst(keys, key) { + const hash = keyHash(key); + if (!keys.has(hash)) keys.set(hash, key); +} + +function sortedKeys(keys) { + return keys.slice().sort(pyCmp); +} + +/** Every fixed placement is reported where the request put it, and nothing else claims to be. */ +export function checkFixedPlacements(request, result, unit, issues) { + const requested = new Map(); + for (const entry of list(getOr(request, 'fixed_placements', []), 'fixed_placements')) { + const containerId = `${pyStr(at(entry, 'container_type'))}#${pyStr(getOr(entry, 'container_instance', 1))}`; + const position = getOr(entry, 'position', {}); + const key = [containerId, at(entry, 'item_type'), + ...['x', 'y', 'z'].map((axis) => toTicks(getOr(position, axis, 0), unit, LENGTH)), at(entry, 'orientation')]; + keepFirst(requested, key); + } + const reported = new Map(); + const containerTypes = new PyDict(); + for (const container of list(at(result, 'containers'), 'result.containers')) { + const containerId = getOr(container, 'id', ''); + containerTypes.set(containerId, at(container, 'container_type')); + for (const placement of list(at(container, 'placements'), 'placements')) { + if (getNull(placement, 'fixed') !== true) continue; + const position = at(placement, 'position'); + const key = [containerId, at(placement, 'item_type'), + ...['x', 'y', 'z'].map((axis) => at(at(position, axis), 'ticks')), at(placement, 'orientation')]; + keepFirst(reported, key); + } + } + for (const key of sortedKeys(Array.from(requested.values()))) { + const containerId = text(key[0], 'container id'); + const hash = containerId.lastIndexOf('#'); + const declaredType = hash === -1 ? containerId : containerId.slice(0, hash); + if (containerTypes.get(containerId) !== declaredType) { + issues.push(issue('fixed_container_missing', containerId)); + } else if (!reported.has(keyHash(key))) { + issues.push(issue('fixed_placement_moved', + `${pyStr(key[1])} fixed in ${containerId} at ${valueTuple(key.slice(2, 5))} ${pyStr(key[5])}`)); + } + } + const unexpected = Array.from(reported.entries()).filter(([hash]) => !requested.has(hash)).map(([, key]) => key); + for (const key of sortedKeys(unexpected)) { + issues.push(issue('unexpected_fixed_placement', + `${pyStr(key[1])} in ${pyStr(key[0])} at ${valueTuple(key.slice(2, 5))} ${pyStr(key[5])}`)); + } +} diff --git a/result-validation/outcome.js b/result-validation/outcome.js new file mode 100644 index 0000000..830ac21 --- /dev/null +++ b/result-validation/outcome.js @@ -0,0 +1,23 @@ +/** What the port answers: a list of issues, or a refusal to judge. */ + +/** One way a result fails to be a sound answer to its request. */ +export const issue = (code, detail) => ({ code, detail }); + +/** + * Why the port cannot judge a pair of documents (docs/VALIDATION-CONTRACT.md). + * + * `contract` is the reference raising `MalformedResult` with the same message. `shape` is the + * reference failing on the input some other way -- a missing key, a value of the wrong type -- + * where only the fact of the refusal is compared, so the message is this port's own. + */ +export class Refusal extends Error { + constructor(kind, message) { + super(message); + this.name = 'Refusal'; + this.kind = kind; + } +} + +export const contract = (message) => new Refusal('contract', message); + +export const shape = (message) => new Refusal('shape', message); diff --git a/result-validation/pyfmt.js b/result-validation/pyfmt.js new file mode 100644 index 0000000..25098b1 --- /dev/null +++ b/result-validation/pyfmt.js @@ -0,0 +1,96 @@ +/** + * Python's `str()` and `repr()` for the values an issue detail prints. + * + * The reference builds every detail with an f-string, so a detail carries Python's spelling of + * whatever it names: `None`, `True`, `'quoted'`, `(1, 2, 3)`, `1e-05`. Matching it is what lets + * one issue list be compared across languages byte for byte. + */ + +import { strictInt } from './json.js'; +import { nonPrintable } from './nonprintable.js'; + +/** `str(value)`: a string as itself, anything else as its `repr`. */ +export const pyStr = (value) => (typeof value === 'string' ? value : pyRepr(value)); + +export function pyRepr(value) { + if (value === null) return 'None'; + if (typeof value === 'boolean') return value ? 'True' : 'False'; + const integer = strictInt(value); + if (integer !== null) return integer.toString(); + if (typeof value === 'number') return floatRepr(value); + if (typeof value === 'string') return strRepr(value); + if (Array.isArray(value)) return list(value.map(pyRepr)); + return `{${Object.keys(value).map((key) => `${strRepr(key)}: ${pyRepr(value[key])}`).join(', ')}}`; +} + +export const boolRepr = (flag) => (flag ? 'True' : 'False'); + +/** `[a, b]` from already-formatted elements. */ +export const list = (elements) => `[${elements.join(', ')}]`; + +/** `(a, b)` from already-formatted elements; a one-element tuple keeps its trailing comma. */ +export const tuple = (elements) => (elements.length === 1 ? `(${elements[0]},)` : `(${elements.join(', ')})`); + +/** A tuple of document values, each as its `repr`. */ +export const valueTuple = (values) => tuple(values.map(pyRepr)); + +/** A tuple of the port's own `BigInt`s. */ +export const intTuple = (values) => tuple(values.map(String)); + +export const intList = (values) => list(values.map(String)); + +/** + * `repr(float)`: the shortest digits that round-trip -- the digits `toExponential()` gives -- + * in fixed notation for decimal exponents from -4 to 15 and scientific (`1e-05`, `1e+16`) + * outside that. + */ +export function floatRepr(value) { + if (Number.isNaN(value)) return 'nan'; + if (!Number.isFinite(value)) return value > 0 ? 'inf' : '-inf'; + const sign = value < 0 || Object.is(value, -0) ? '-' : ''; + const [mantissa, power] = Math.abs(value).toExponential().split('e'); + const digits = mantissa.replace('.', ''); + const exponent = Number(power); + if (exponent >= -4 && exponent < 16) { + const point = exponent + 1; + if (point <= 0) return `${sign}0.${'0'.repeat(-point)}${digits}`; + if (digits.length <= point) return `${sign}${digits}${'0'.repeat(point - digits.length)}.0`; + return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`; + } + const fraction = digits.length > 1 ? `.${digits.slice(1)}` : ''; + const magnitude = String(Math.abs(exponent)).padStart(2, '0'); + return `${sign}${digits[0]}${fraction}e${exponent < 0 ? '-' : '+'}${magnitude}`; +} + +const hex = (code, width) => code.toString(16).padStart(width, '0'); + +/** One code point as `repr` writes it inside quotes of kind `quote`. */ +function escaped(code, quote) { + if (code === 0x5c) return '\\\\'; + if (code === 0x09) return '\\t'; + if (code === 0x0a) return '\\n'; + if (code === 0x0d) return '\\r'; + const character = String.fromCodePoint(code); + if (character === quote) return `\\${quote}`; + if (code < 0x20 || code === 0x7f) return `\\x${hex(code, 2)}`; + if (code < 0x7f || !nonPrintable(code)) return character; + if (code <= 0xff) return `\\x${hex(code, 2)}`; + if (code <= 0xffff) return `\\u${hex(code, 4)}`; + return `\\U${hex(code, 8)}`; +} + +/** + * `repr(str)`: single quotes unless the text holds a single quote and no double quote, with + * Python's escapes for the backslash, the quote, `\t`, `\n`, `\r` and every code point + * `str.isprintable` rejects. Walked by code point, so a lone surrogate is one escape. + */ +export function strRepr(value) { + const quote = value.includes("'") && !value.includes('"') ? '"' : "'"; + let out = quote; + for (let index = 0; index < value.length;) { + const code = value.codePointAt(index); + out += escaped(code, quote); + index += code > 0xffff ? 2 : 1; + } + return out + quote; +} diff --git a/result-validation/spec.js b/result-validation/spec.js new file mode 100644 index 0000000..256d801 --- /dev/null +++ b/result-validation/spec.js @@ -0,0 +1,206 @@ +/** + * The request, read independently of the engine's own model (the reference's `ItemSpec`, + * `ContainerSpec` and `MachineSpec`). + */ + +import { ROTATIONS, cuboid } from './geometry.js'; +import { + PyDict, at, compareCodePoints, flag, getNull, getOr, hashKey, int, isDict, list, optInt, pyEq, pyInt, text, truthy, +} from './json.js'; +import { ratioPpm } from './num.js'; +import { shape } from './outcome.js'; +import { LENGTH, WEIGHT, dimensions, ratioOf, toTicks } from './units.js'; + +/** The six axis-aligned exit directions a placement could be slid out through. */ +export const EXIT_DIRECTIONS = Object.freeze(['+x', '-x', '+y', '-y', '+z', '-z']); + +const UPRIGHT_ROTATIONS = ['LWH', 'WLH']; + +/** `ROTATIONS[orientation]`. */ +export function rotationAxes(orientation) { + const found = ROTATIONS.find(([name]) => name === orientation); + if (found === undefined) throw shape(`unknown orientation ${JSON.stringify(orientation)}`); + return found[1]; +} + +/** `orientation in ROTATIONS`, for any document value; an unhashable one is Python's `TypeError`. */ +export function isRotation(orientation) { + hashKey(orientation); + return typeof orientation === 'string' && ROTATIONS.some(([name]) => name === orientation); +} + +/** `value in tags` for a frozenset of strings; an unhashable value is Python's `TypeError`. */ +export function holds(tags, value) { + hashKey(value); + return typeof value === 'string' && tags.has(value); +} + +export function intersectsTags(left, right) { + for (const tag of left) { + if (right.has(tag)) return true; + } + return false; +} + +/** `sorted(tags)`, by code point. */ +export const sortedTags = (tags) => Array.from(tags).sort(compareCodePoints); + +export const allows = (spec, orientation) => spec.allowedRotations.some((allowed) => pyEq(allowed, orientation)); + +export function rotated(spec, orientation) { + const axes = rotationAxes(orientation); + return [spec.dimensions[axes[0]], spec.dimensions[axes[1]], spec.dimensions[axes[2]]]; +} + +/** `frozenset(value)` of a request's tag list. The contract's tags are strings. */ +function tagSet(value, what) { + return new Set(list(value, what).map((tag) => text(tag, what))); +} + +/** `None if raw.get(key) is None else read(raw[key])`. */ +function optional(raw, key, read) { + const value = getNull(raw, key); + return value === null ? null : read(value); +} + +const weightTicks = (value) => toTicks(value, 'g', WEIGHT); + +function allowedRotations(raw, keepUpright) { + const listed = getNull(raw, 'allowed_rotations'); + if (listed !== null) return list(listed, 'allowed_rotations').slice(); + return keepUpright ? UPRIGHT_ROTATIONS.slice() : ROTATIONS.map(([name]) => name); +} + +function hullVertices(value, unit) { + return list(value, 'hull_vertices').map((vertex) => ['x', 'y', 'z'].map((axis) => toTicks(at(vertex, axis), unit, LENGTH))); +} + +function parseItem(raw, unit, index) { + const owner = `items[${index}]`; + const keepUpright = flag(raw, 'keep_upright', false, owner); + const stackable = flag(raw, 'stackable', true, owner); + const mustBeOnFloor = flag(raw, 'must_be_on_floor', false, owner); + const groups = getNull(raw, 'accepts_stacking_groups'); + return Object.freeze({ + allowedRotations: allowedRotations(raw, keepUpright), + id: text(at(raw, 'id'), 'an item id'), + dimensions: dimensions(at(raw, 'dimensions'), unit), + weight: weightTicks(getOr(raw, 'weight', 0)), + quantity: getOr(raw, 'quantity', 1), + stackable, + mustBeOnFloor, + maxTopLoad: optional(raw, 'max_top_load', weightTicks), + minimumSupportRatio: ratioOf(getOr(raw, 'minimum_support_ratio', 0)), + tags: tagSet(getOr(raw, 'tags', []), 'tags'), + eligibleContainerTags: tagSet(getOr(raw, 'eligible_container_tags', []), 'eligible_container_tags'), + maxStackedItems: optInt(getNull(raw, 'max_stacked_items'), 'max_stacked_items'), + groundContactRule: getNull(raw, 'ground_contact_rule'), + nestingHeight: optional(raw, 'nesting_height', (value) => toTicks(value, unit, LENGTH)), + incompatibleTags: tagSet(getOr(raw, 'incompatible_tags', []), 'incompatible_tags'), + stopIndex: optInt(getNull(raw, 'stop_index'), 'stop_index'), + value: getNull(raw, 'value'), + shapeType: getOr(raw, 'shape_type', 'rigid_cuboid'), + hullVertices: optional(raw, 'hull_vertices', (value) => hullVertices(value, unit)), + // The solvers' ratio rule, applied once: floor(ratio * 1000000 + 0.5) on the double. + compressionRatioPpm: optional(raw, 'compression_ratio', (value) => ratioPpm(ratioOf(value))), + maxCompressionPressureKpa: optInt(getNull(raw, 'max_compression_pressure_kpa'), 'max_compression_pressure_kpa'), + stackingGroup: getNull(raw, 'stacking_group'), + acceptsStackingGroups: truthy(groups) ? tagSet(groups, 'accepts_stacking_groups') : new Set(), + }); +} + +/** The request's item types by id, in request order; a repeated id keeps its first place. */ +export function parseItems(request, unit) { + const items = new PyDict(); + for (const [index, raw] of list(at(request, 'items'), 'request.items').entries()) { + const spec = parseItem(raw, unit, index); + items.set(at(raw, 'id'), spec); + } + return items; +} + +function accessDirections(raw) { + const declared = list(getOr(raw, 'access_directions', []), 'access_directions'); + declared.forEach(hashKey); + return EXIT_DIRECTIONS.filter((direction) => declared.some((value) => value === direction)); +} + +function obstacles(raw, unit) { + const boxes = []; + for (const obstacle of list(getOr(raw, 'obstacles', []), 'obstacles')) { + const additional = list(getOr(obstacle, 'additional_boxes', []), 'additional_boxes'); + for (const component of [obstacle, ...additional]) { + const origin = getOr(component, 'origin', {}); + const [length, width, height] = dimensions(at(component, 'dimensions'), unit); + const [x, y, z] = ['x', 'y', 'z'].map((axis) => toTicks(getOr(origin, axis, 0), unit, LENGTH)); + boxes.push(cuboid(x, y, z, length, width, height)); + } + } + return boxes; +} + +/** `{str(tag): int(limit)}`, then sorted by tag as the reference iterates it. */ +function tagLimits(raw) { + const declared = getOr(raw, 'tag_limits', {}); + if (!isDict(declared)) throw shape('tag_limits must be an object'); + return Object.keys(declared) + .map((tag) => [tag, pyInt(declared[tag], 'tag_limits')]) + .sort(([left], [right]) => compareCodePoints(left, right)); +} + +function parseMachine(raw, unit) { + const sides = (name) => { + const value = getNull(raw, name); + return value === null ? [0n, 0n, 0n] : dimensions(value, unit); + }; + const price = getNull(raw, 'cost_per_square_metre_minor'); + return Object.freeze({ + minimum: sides('minimum'), + increment: sides('increment'), + pricePerSquareMetreMinor: truthy(price) ? int(price, 'cost_per_square_metre_minor') : 0n, + tarePerSquareMetre: optional(raw, 'tare_per_square_metre', weightTicks) ?? 0n, + allowance: sides('outer_allowance'), + }); +} + +function axles(value, unit) { + return list(value, 'axles').map((axle) => Object.freeze({ + position: toTicks(at(axle, 'position'), unit, LENGTH), + maxLoad: optional(axle, 'max_load', weightTicks), + })); +} + +function parseContainer(raw, unit) { + return Object.freeze({ + id: at(raw, 'id'), + inner: dimensions(at(raw, 'inner_dimensions'), unit), + accessDirections: accessDirections(raw), + outer: optional(raw, 'outer_dimensions', (value) => dimensions(value, unit)), + costMinor: int(getOr(raw, 'cost_minor', 0), 'cost_minor'), + maxPayload: optional(raw, 'max_payload', weightTicks), + maxItems: optInt(getNull(raw, 'max_items'), 'max_items'), + quantity: optInt(getNull(raw, 'quantity'), 'quantity'), + obstacles: obstacles(raw, unit), + tags: tagSet(getOr(raw, 'tags', []), 'tags'), + tagLimits: tagLimits(raw), + voidFillReserveRatio: ratioOf(getOr(raw, 'void_fill_reserve_ratio', 0)), + tareWeight: weightTicks(getOr(raw, 'tare_weight', 0)), + axles: optional(raw, 'axles', (value) => axles(value, unit)), + maxStackDensity: optional(raw, 'max_stack_density', weightTicks), + rateTable: getNull(raw, 'rate_table'), + flatBag: getNull(raw, 'deformable_boundary') !== null, + reportsAchievedExtent: getNull(raw, 'deformable_boundary') !== null || getNull(raw, 'made_to_measure') !== null, + madeToMeasure: optional(raw, 'made_to_measure', (machine) => parseMachine(machine, unit)), + }); +} + +/** The request's container types by id, in request order. */ +export function parseContainers(request, unit) { + const containers = new PyDict(); + for (const raw of list(at(request, 'containers'), 'request.containers')) { + containers.set(at(raw, 'id'), parseContainer(raw, unit)); + } + return containers; +} + +export const volumeOf = (container) => container.inner[0] * container.inner[1] * container.inner[2]; diff --git a/result-validation/stacking.js b/result-validation/stacking.js new file mode 100644 index 0000000..dbf27e9 --- /dev/null +++ b/result-validation/stacking.js @@ -0,0 +1,342 @@ +/** + * What rests on what, and everything that follows from it: support, top load, stacking limits, + * load propagation, crush, stack density and route unloading order. + * + * Every function takes the container's placements as `entries`: `[itemId, {envelope, + * placement, spec}]` pairs in the order of the reference's `envelopes` dict, where a repeated + * item id keeps its first position and its last placement. + */ + +import { cuboid, hullVolume, intersects, overlapAreaXY, rotateHull, vertices } from './geometry.js'; +import { at, hashKey, int, pyCmp, pyEq, sortPy, text } from './json.js'; +import { Ratio, ceilDiv, fixed6, floorDiv, max, ratioPpm } from './num.js'; +import { issue, shape } from './outcome.js'; +import { list, pyRepr, pyStr } from './pyfmt.js'; +import { EXIT_DIRECTIONS, holds, rotated, rotationAxes } from './spec.js'; +import { TICKS_PER_KG, TICKS_PER_MM } from './units.js'; + +/** One square metre in length ticks squared. */ +export const SQUARE_METRE_TICKS = (TICKS_PER_MM * 1000n) ** 2n; + +export const SCORE_SCALE = 1000000n; + +/** + * Exact direct-support edges, as `supporters[upper] = [[lower, area], ...]` and `directAbove[lower] + * = Set(upper)` by entry index. Ordinary face contacts keep placement order; nested instances of + * one column are linked only to their adjacent predecessor, which replaces any shadowed face + * contact from the same column. O(n log n + q + e) time and O(n + e) space for `q` same-plane + * candidates and `e` edges. + */ +export function supportGraph(entries) { + const byTop = new Map(); + entries.forEach(([, entry], index) => { + const tops = byTop.get(entry.envelope.z2); + if (tops === undefined) byTop.set(entry.envelope.z2, [index]); + else tops.push(index); + }); + const supporters = entries.map(([, entry], upper) => (byTop.get(entry.envelope.z) || []) + .filter((lower) => lower !== upper) + .map((lower) => [lower, overlapAreaXY(entries[lower][1].envelope, entry.envelope)]) + .filter(([, area]) => area > 0n)); + linkNestedColumns(entries, supporters); + const directAbove = entries.map(() => new Set()); + supporters.forEach((edges, upper) => edges.forEach(([lower]) => directAbove[lower].add(upper))); + return { supporters, directAbove }; +} + +/** Each nested instance rests on its adjacent predecessor in the column, with its full base. */ +function linkNestedColumns(entries, supporters) { + const columnOf = new Map(); + const columns = new Map(); + entries.forEach(([, entry], index) => { + const nesting = entry.spec === undefined ? null : entry.spec.nestingHeight; + if (nesting === null) return; + const { envelope } = entry; + const key = [hashKey(at(entry.placement, 'item_type')), envelope.x, envelope.y, envelope.x2, envelope.y2, nesting].join('|'); + columnOf.set(index, key); + const column = columns.get(key); + if (column === undefined) columns.set(key, [index]); + else column.push(index); + }); + const predecessors = []; + for (const column of columns.values()) { + column.sort((left, right) => { + const a = entries[left][1].envelope.z; + const b = entries[right][1].envelope.z; + return a < b ? -1 : a > b ? 1 : left - right; + }); + for (let position = 1; position < column.length; position += 1) { + const [lower, upper] = [column[position - 1], column[position]]; + if (entries[lower][1].envelope.z2 - entries[upper][1].envelope.z === entries[lower][1].spec.nestingHeight) { + predecessors.push([upper, lower]); + } + } + } + for (const [upper, lower] of predecessors) { + const key = columnOf.get(upper); + const edges = supporters[upper].filter(([candidate]) => columnOf.get(candidate) !== key); + const position = edges.findIndex(([candidate]) => candidate > lower); + edges.splice(position === -1 ? edges.length : position, 0, [lower, entries[upper][1].envelope.baseArea]); + supporters[upper] = edges; + } +} + +/** Exact `floor(base_area * ppm / 10^6)`, the least supported area the ratio allows. */ +const requiredSupportArea = (baseArea, ppm) => floorDiv(baseArea * ppm, SCORE_SCALE); + +export function checkSupportAndLoad(entries, graph, globalSupport, issues) { + entries.forEach(([rawId, entry], index) => { + const { spec, envelope } = entry; + if (spec === undefined || envelope.z === 0n) return; + const itemId = pyStr(rawId); + const supporting = graph.supporters[index]; + const required = globalSupport.compare(spec.minimumSupportRatio) < 0 ? spec.minimumSupportRatio : globalSupport; + checkGroundContact(itemId, envelope, supporting, entries, spec.groundContactRule, issues); + if (required.compare(Ratio.of(0n)) > 0) { + const area = supporting.reduce((total, [, contact]) => total + contact, 0n); + if (area < requiredSupportArea(envelope.baseArea, ratioPpm(required))) { + const ratio = new Ratio(area, envelope.baseArea); + issues.push(issue('insufficient_support', + `${itemId}: ${fixed6(ratio.toDouble())} < ${fixed6(required.toDouble())}`)); + } + } + for (const [lower] of supporting) checkResting(itemId, spec, entries[lower], issues); + }); + entries.forEach(([rawId, entry], index) => { + const limit = entry.spec === undefined ? null : entry.spec.maxStackedItems; + if (limit === null) return; + const above = countAbove(index, graph.directAbove); + if (BigInt(above) > limit) { + issues.push(issue('max_stacked_items_exceeded', `${pyStr(rawId)}: ${above} above > limit ${limit}`)); + } + }); +} + +/** What one direct supporter allows to rest on it. */ +function checkResting(itemId, spec, [lowerId, lowerEntry], issues) { + const lowerSpec = lowerEntry.spec; + if (lowerSpec === undefined) return; + const lower = pyStr(lowerId); + if (!lowerSpec.stackable) { + issues.push(issue('non_stackable', `${itemId} rests on ${lower}`)); + } else if (lowerSpec.acceptsStackingGroups.size > 0 && !holds(lowerSpec.acceptsStackingGroups, spec.stackingGroup)) { + issues.push(issue('stacking_group_incompatible', `${itemId} rests on ${lower}`)); + } + if (lowerSpec.maxTopLoad !== null && spec.weight > lowerSpec.maxTopLoad) { + issues.push(issue('top_load_exceeded', `${itemId} (${spec.weight} ticks) on ${lower} (limit ${lowerSpec.maxTopLoad})`)); + } +} + +/** Everything resting on an item, however indirectly. */ +function countAbove(index, directAbove) { + const descendants = new Set(); + const pending = Array.from(directAbove[index]); + while (pending.length > 0) { + const above = pending.pop(); + if (descendants.has(above)) continue; + descendants.add(above); + pending.push(...directAbove[above]); + } + return descendants.size; +} + +function checkGroundContact(itemId, envelope, supporting, entries, rule, issues) { + if (rule === null || rule === 'free') return; + if (rule === 'covered') { + const corners = [[envelope.x, envelope.y], [envelope.x2, envelope.y], [envelope.x, envelope.y2], [envelope.x2, envelope.y2]]; + const covered = corners.every(([x, y]) => supporting.some(([lower]) => { + const surface = entries[lower][1].envelope; + return surface.x <= x && x <= surface.x2 && surface.y <= y && y <= surface.y2; + })); + if (!covered) issues.push(issue('ground_contact_violation', `${itemId}: covered requires all four base corners`)); + } else if (rule === 'single' && supporting.length !== 1) { + issues.push(issue('ground_contact_violation', `${itemId}: single has ${supporting.length} supporters`)); + } else if (rule === 'multiple' && supporting.length < 2) { + issues.push(issue('ground_contact_violation', `${itemId}: multiple has ${supporting.length} supporters`)); + } +} + +/** + * Cumulative weight each item bears from everything resting on it, split across supporters by + * contact area and conserved exactly (the remainder goes to the last supporter), top-down by + * descending envelope ceiling. O(n log n + e). + */ +export function propagateLoad(entries, graph) { + const order = entries.map((_, index) => index).sort((left, right) => { + const a = entries[left][1].envelope; + const b = entries[right][1].envelope; + if (a.z2 !== b.z2) return a.z2 > b.z2 ? -1 : 1; + if (a.z !== b.z) return a.z > b.z ? -1 : 1; + return pyCmp(entries[left][0], entries[right][0]); + }); + const loads = entries.map(() => 0n); + for (const upper of order) { + const { spec } = entries[upper][1]; + if (spec === undefined) continue; + const supports = graph.supporters[upper]; + const totalArea = supports.reduce((total, [, area]) => total + area, 0n); + if (totalArea === 0n) continue; + const downward = spec.weight + loads[upper]; + let assigned = 0n; + supports.forEach(([lower, area], position) => { + const share = position === supports.length - 1 ? downward - assigned : floorDiv(downward * area, totalArea); + assigned += share; + loads[lower] += share; + }); + } + return loads; +} + +/** The pressure a footprint carries, in kPa, exactly: `load * g * (ticks/m)^2 / (ticks/kg * Pa/kPa * area)`. */ +function pressure(load, baseArea) { + return new Ratio(load * 980665n * SQUARE_METRE_TICKS, TICKS_PER_KG * 100000n * 1000n * baseArea); +} + +/**: no `compressible` item may carry more pressure than it declared. */ +export function checkCrush(packedId, entries, loads, issues) { + entries.forEach(([rawId, entry], index) => { + const limit = entry.spec === undefined ? null : entry.spec.maxCompressionPressureKpa; + if (limit === null) return; + const carried = pressure(loads[index], entry.envelope.baseArea); + if (carried.compare(Ratio.of(limit)) > 0) { + issues.push(issue('crush_violation', + `${pyStr(rawId)} in ${packedId}: ${fixed6(carried.toDouble())} kPa exceeds ${limit} kPa`)); + } + }); +} + +/** Occupied height under load, rounded up, never below one tick; a crushed item keeps its height. */ +function compressedHeight(envelope, spec, load) { + const limit = spec.maxCompressionPressureKpa; + // The reference asserts the pair: a pressure limit without a ratio is not a request it reads. + if (spec.compressionRatioPpm === null) throw shape(`${spec.id}: max_compression_pressure_kpa without compression_ratio`); + const carried = pressure(load, envelope.baseArea); + if (carried.compare(Ratio.of(limit)) > 0 || limit === 0n) return envelope.height; + const divisor = limit * 1000000n * carried.denominator; + const retained = divisor - spec.compressionRatioPpm * carried.numerator; + return max(1n, ceilDiv(envelope.height * retained, divisor)); +} + +/**: weight per square metre of an item's own base, at every level of the stack. */ +export function checkStackDensity(packedId, entries, loads, maxDensity, issues) { + entries.forEach(([rawId, entry], index) => { + if (entry.spec === undefined) return; + const total = entry.spec.weight + loads[index]; + if (total * SQUARE_METRE_TICKS > maxDensity * entry.envelope.baseArea) { + issues.push(issue('stack_density_exceeded', `${pyStr(rawId)} in ${packedId}: ${total} ticks over ` + + `${entry.envelope.baseArea} ticks^2 exceeds ${maxDensity} ticks/m^2`)); + } + }); +} + +/** The region between the envelope's own face and the matching container wall along `direction`. */ +function sweptRegion(envelope, container, direction) { + let [x1, y1, z1, x2, y2, z2] = [envelope.x, envelope.y, envelope.z, envelope.x2, envelope.y2, envelope.z2]; + if (direction === '+x') [x1, x2] = [x2, container[0]]; + else if (direction === '-x') [x1, x2] = [0n, x1]; + else if (direction === '+y') [y1, y2] = [y2, container[1]]; + else if (direction === '-y') [y1, y2] = [0n, y1]; + else if (direction === '+z') [z1, z2] = [z2, container[2]]; + else [z1, z2] = [0n, z1]; + return cuboid(x1, y1, z1, x2 - x1, y2 - y1, z2 - z1); +} + +function setSome(set, predicate) { + for (const item of set) { + if (predicate(item)) return true; + } + return false; +} + +function hasClearExit(item, entries, present, container, directions) { + return directions.some((direction) => { + const region = sweptRegion(entries[item][1].envelope, container, direction); + return !setSome(present, (other) => other !== item && intersects(region, entries[other][1].envelope)); + }); +} + +/** `min(candidates)` by item id, the first of equals. */ +function lowestId(candidates, entries) { + return candidates.reduce((best, candidate) => (pyCmp(entries[candidate][0], entries[best][0]) < 0 ? candidate : best)); +} + +/** + * Items due at an earlier stop must all be removable -- unblocked from + * above and slidable out a door (all six walls when the container names none) -- before the + * route moves on. O(s * n^2) for `s` stops: each removal rescans the items still due. + */ +export function checkRouteRemovalOrder(packedId, entries, graph, container, doors, issues) { + const stops = []; + entries.forEach(([, entry], index) => { + if (entry.spec !== undefined && entry.spec.stopIndex !== null) stops.push([index, entry.spec.stopIndex]); + }); + if (stops.length === 0) return; + const directions = doors.length === 0 ? EXIT_DIRECTIONS : doors; + const present = new Set(entries.map((_, index) => index)); + const distinct = Array.from(new Set(stops.map(([, stop]) => stop))).sort((a, b) => (a < b ? -1 : 1)); + for (const stop of distinct) { + let due = stops.filter(([index, value]) => value === stop && present.has(index)).map(([index]) => index); + while (due.length > 0) { + const removable = due.filter((item) => !setSome(graph.directAbove[item], (above) => present.has(above)) + && hasClearExit(item, entries, present, container, directions)); + if (removable.length === 0) { + const names = sortPy(due.map((index) => entries[index][0])).map(pyRepr); + issues.push(issue('unloading_order_violation', + `${packedId}: stop ${stop} cannot fully unload ${list(names)} before the route moves on`)); + return; + } + const chosen = lowestId(removable, entries); + present.delete(chosen); + due = due.filter((item) => item !== chosen); + } + } +} + +/** + * Whether two stacked instances form a valid nested column: same type and footprint, and the + * upper one sunk into the lower one by exactly the nesting height. + */ +export function validNesting(left, right) { + if (left.spec === undefined || right.spec === undefined) return false; + const nesting = left.spec.nestingHeight; + if (nesting === null || left.spec.id !== right.spec.id + || !pyEq(at(left.placement, 'item_type'), at(right.placement, 'item_type')) + || right.spec.nestingHeight !== nesting) return false; + const [a, b] = [left.envelope, right.envelope]; + if (a.x !== b.x || a.y !== b.y || a.x2 !== b.x2 || a.y2 !== b.y2) return false; + const [lower, upper] = a.z <= b.z ? [a, b] : [b, a]; + return lower.z !== upper.z && lower.z2 - upper.z === nesting; +} + +const orientationAxes = (placement) => rotationAxes(text(at(placement, 'orientation'), 'orientation')); + +/** + * The solid a placement occupies, as vertices in container coordinates: the exact hull when the + * request allows it and the reported box matches, otherwise the envelope's corners. + */ +export function placedHull(envelope, placement, spec) { + if (spec === undefined || spec.hullVertices === null || spec.shapeType !== 'convex_hull' || spec.stopIndex !== null) { + return vertices(envelope); + } + const expected = rotated(spec, text(at(placement, 'orientation'), 'orientation')); + if (envelope.length !== expected[0] || envelope.width !== expected[1] || envelope.height !== expected[2]) { + return vertices(envelope); + } + return rotateHull(spec.hullVertices, orientationAxes(placement)) + .map(([x, y, z]) => [envelope.x + x, envelope.y + y, envelope.z + z]); +} + +/** Space a placement actually takes: its compressed box, its hull, or its box. */ +export function occupiedVolume(envelope, placement, spec, load) { + if (spec !== undefined && spec.maxCompressionPressureKpa !== null) { + return envelope.baseArea * compressedHeight(envelope, spec, load); + } + if (spec !== undefined && spec.shapeType === 'convex_hull' && spec.hullVertices !== null) { + return hullVolume(rotateHull(spec.hullVertices, orientationAxes(placement))); + } + // Only an exact hull makes `_placed_hull` differ from the envelope's corners, and a hull was + // answered above, so the reference's remaining `_hull_volume(solid)` arm is never reached. + const dimensions = at(placement, 'dimensions'); + const side = (name) => int(at(at(dimensions, name), 'ticks'), 'a placement dimension'); + return side('length') * side('width') * side('height'); +} diff --git a/result-validation/units.js b/result-validation/units.js new file mode 100644 index 0000000..550b98d --- /dev/null +++ b/result-validation/units.js @@ -0,0 +1,130 @@ +/** Measures to exact ticks, re-derived from the unit tables rather than taken from the engine. */ + +import { at, get, getNull, isDict, isPythonSpace, pyIntInstance, pyStrip, text } from './json.js'; +import { Ratio, parseFraction } from './num.js'; +import { contract, shape } from './outcome.js'; +import { pyStr } from './pyfmt.js'; + +/** Ticks per unit, in the reference's order. One length tick is 1/16000 mm. */ +export const LENGTH = Object.freeze([ + ['mm', 16000n], ['cm', 160000n], ['m', 16000000n], + ['in', 406400n], ['inch', 406400n], ['inches', 406400n], + ['ft', 4876800n], ['tick', 1n], ['ticks', 1n], +]); + +/** One weight tick is 1/8 microgram. */ +export const WEIGHT = Object.freeze([ + ['mg', 8000n], ['g', 8000000n], ['kg', 8000000000n], + ['oz', 226796185n], ['lb', 3628738960n], ['lbs', 3628738960n], + ['tick', 1n], ['ticks', 1n], +]); + +/** + * Every name the engines read in `units.length`, after stripping ASCII whitespace and lowering + * ASCII case, to the `LENGTH` key it means. + */ +const REQUEST_LENGTH_UNITS = new Map([ + ...LENGTH.map(([name]) => [name, name]), ['millimeter', 'mm'], ['millimeters', 'mm'], +]); + +/** + * `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. + */ +export function lengthUnit(request) { + const units = getNull(request, 'units'); + if (units === null) return 'mm'; + if (!isDict(units)) throw contract('units must be an object'); + const length = getNull(units, 'length'); + if (length === null) return 'mm'; + const key = typeof length === 'string' + ? length.replace(/^[ \t\n\r\v\f]+|[ \t\n\r\v\f]+$/g, '').replace(/[A-Z]/g, (letter) => letter.toLowerCase()) + : ''; + const found = REQUEST_LENGTH_UNITS.get(key); + if (found === undefined) throw contract('units.length must be one of mm, cm, m, in, ft'); + return found; +} + +export const TICKS_PER_MM = 16000n; +export const TICKS_PER_GRAM = 8000000n; +export const TICKS_PER_KG = 8000000000n; + +/** Suffixes longest first; Python's stable sort keeps the table's order among equal lengths. */ +const suffixesOf = (table) => table.map(([name]) => name).sort((left, right) => right.length - left.length); +const SUFFIXES = new Map([[LENGTH, suffixesOf(LENGTH)], [WEIGHT, suffixesOf(WEIGHT)]]); + +/** `table[unit]`, an exact key. */ +export function perUnit(table, unit) { + const found = table.find(([name]) => name === unit); + if (found === undefined) throw shape(`unknown unit ${JSON.stringify(unit)}`); + return found[1]; +} + +/** + * The longest unit suffix the text ends with, case-insensitively, that leaves a non-empty + * number before it. The number is cut from the original text by the suffix's length in code + * points, as Python slices it, even where lowering changed the text's length. + */ +function unitSuffix(stripped, table) { + const lowered = stripped.toLowerCase(); + const characters = Array.from(stripped); + for (const suffix of SUFFIXES.get(table)) { + if (!lowered.endsWith(suffix) || characters.length < suffix.length) continue; + const number = pyStrip(characters.slice(0, characters.length - suffix.length).join('')); + if (number !== '') return [number, suffix]; + } + return null; +} + +/** `to_ticks`: a number, a string with an optional unit suffix, or `{value, unit}`, rounded half to even. */ +export function toTicks(measure, defaultUnit, table) { + let unit = defaultUnit; + let raw = measure; + if (isDict(measure)) { + raw = at(measure, 'value'); + const stated = get(measure, 'unit'); + if (stated !== undefined) unit = text(stated, "a measure's unit"); + } else if (typeof measure === 'string') { + const found = unitSuffix(pyStrip(measure), table); + if (found !== null) [raw, unit] = found; + } + return roundHalfEven(parseRational(raw).mul(Ratio.of(perUnit(table, unit.toLowerCase())))); +} + +function roundHalfEven(scaled) { + const floor = scaled.floor(); + const twice = 2n * (scaled.numerator - floor * scaled.denominator); + if (twice > scaled.denominator) return floor + 1n; + if (twice < scaled.denominator) return floor; + return floor % 2n === 0n ? floor : floor + 1n; +} + +/** Python's `text.split(None, 1)` of stripped text holding whitespace: the first word and the rest. */ +function splitFirstWord(stripped) { + let end = 0; + while (!isPythonSpace(stripped[end])) end += 1; + let rest = end; + while (isPythonSpace(stripped[rest])) rest += 1; + return [stripped.slice(0, end), stripped.slice(rest)]; +} + +/** `parse_rational`: an integer exactly, anything else through its `str()`, mixed when it holds a space. */ +export function parseRational(value) { + const integer = pyIntInstance(value); + if (integer !== null) return Ratio.of(integer); + const stripped = pyStrip(pyStr(value)); + if (!stripped.includes(' ')) return parseFraction(stripped); + const [whole, rest] = splitFirstWord(stripped); + const fraction = parseFraction(rest); + return parseFraction(whole).add(whole.startsWith('-') ? fraction.negate() : fraction); +} + +/** `Fraction(str(value))`, the reference's reading of a ratio field. */ +export const ratioOf = (value) => parseFraction(pyStr(value)); + +export const dimensions = (value, unit) => [ + toTicks(at(value, 'length'), unit, LENGTH), + toTicks(at(value, 'width'), unit, LENGTH), + toTicks(at(value, 'height'), unit, LENGTH), +]; diff --git a/test/effort-boundary.test.mjs b/test/effort-boundary.test.mjs new file mode 100644 index 0000000..1b9cd16 --- /dev/null +++ b/test/effort-boundary.test.mjs @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { packFallback } from '../fallback.js'; +import { validate } from './validate.mjs'; + +/** + * An effort budget stops a search; it is not read off the counters afterwards. + * + * A start that finishes having used exactly its budget is not truncated and answers as an + * unlimited run does; one count short, the guard refuses the next counted step, so the start is + * truncated with the effort flag; and no counter ever passes its limit, including on the paths + * that count a run of steps at once -- a lattice batch and a committed block. + */ + +const COUNTERS = { + max_placement_attempts: 'orientations_considered', + max_candidates_evaluated: 'feasible_candidates', + max_search_nodes: 'search_nodes_expanded', +}; + +const box = (id, length, width, height, quantity) => ({ + id, quantity, weight: '1 kg', dimensions: { length: String(length), width: String(width), height: String(height) }, +}); +const scene = (items, configuration, containerQuantity = 3) => ({ + units: { length: 'mm' }, + configuration: { time_limit_ms: 600_000, ...configuration }, + items, + containers: [{ id: 'c', quantity: containerQuantity, inner_dimensions: { length: '100', width: '100', height: '100' } }], +}); +const withBudget = (request, budget) => ({ ...request, configuration: { ...request.configuration, effort_budget: budget } }); +const withoutDuration = (result) => JSON.stringify(result, (key, value) => (key === 'duration_ms' ? undefined : value)); + +// One extreme-point start, so the start the budget cuts is the one that answers. +const singleStart = scene([box('a', 30, 20, 10, 12), box('b', 40, 40, 25, 4), box('c', 15, 15, 15, 9)], + { solver_profile: 'fast', solvers: ['extreme_points'] }, 1); +// Two item types that the block solver commits several members at a time. +const blocks = scene([box('a', 20, 20, 20, 40), box('b', 50, 50, 30, 6)], { solver_profile: 'quality', solvers: ['homogeneous_blocks'] }); +// One item type, which the compact lattice answers in batches. +const lattice = scene([box('a', 30, 20, 10, 60)], { solver_profile: 'fast' }, 2); + +/** The effort contract the reference validator checks, plus a valid packing. */ +function assertEffortConsistent(request, result) { + assert.deepEqual(validate(request, result), []); + const budget = request.configuration.effort_budget; + const { metrics, effort_limit_reached: flagged } = result.algorithm; + let reached = false; + for (const [limit, counter] of Object.entries(COUNTERS)) { + if (budget[limit] == null) continue; + assert.ok(metrics[counter] <= budget[limit], `${counter} ${metrics[counter]} passed ${limit} ${budget[limit]}`); + reached ||= metrics[counter] === budget[limit]; + } + if (flagged) assert.ok(reached, 'the effort flag needs a counter at its limit'); + const winner = result.termination.starts.find((start) => start.selected); + assert.equal(winner.truncated, flagged || result.algorithm.time_limit_reached); + assert.equal(result.termination.any_start_truncated, result.termination.starts.some((start) => start.truncated)); +} + +for (const [limit, counter] of Object.entries(COUNTERS)) { + test(`a start using exactly its ${limit} finishes untruncated, and one short is stopped`, () => { + const unlimited = packFallback(singleStart); + const used = unlimited.algorithm.metrics[counter]; + assert.ok(used > 1); + + const exact = withBudget(singleStart, { [limit]: used }); + const atLimit = packFallback(exact); + assertEffortConsistent(exact, atLimit); + assert.equal(atLimit.algorithm.effort_limit_reached, false); + assert.equal(atLimit.termination.code, 'complete'); + assert.deepEqual(atLimit.termination.starts.map((start) => start.truncated), [false]); + assert.equal(withoutDuration(atLimit), withoutDuration(unlimited)); + + const short = withBudget(singleStart, { [limit]: used - 1 }); + const stopped = packFallback(short); + assertEffortConsistent(short, stopped); + assert.equal(stopped.algorithm.effort_limit_reached, true); + assert.equal(stopped.termination.code, 'effort_limit'); + assert.equal(stopped.algorithm.metrics[counter], used - 1); + }); +} + +for (const [name, request] of Object.entries({ blocks, lattice })) { + test(`no counter passes its limit on the ${name} path, and only a stop sets the flag`, () => { + const unlimited = packFallback(request); + for (const [limit, counter] of Object.entries(COUNTERS)) { + const used = unlimited.algorithm.metrics[counter]; + assert.ok(used > 1, `${name} uses ${counter}`); + for (let value = 1; value <= used + 1; value++) { + const budgeted = withBudget(request, { [limit]: value }); + const result = packFallback(budgeted); + assertEffortConsistent(budgeted, result); + if (value >= used) { + assert.equal(result.algorithm.effort_limit_reached, false, `${limit}=${value}`); + assert.equal(withoutDuration(result), withoutDuration(unlimited), `${limit}=${value}`); + } + } + } + }); +} + +test('a lattice batch the budget cuts short places what it has room for and stops', () => { + const request = withBudget(lattice, { max_placement_attempts: 7 }); + const result = packFallback(request); + assertEffortConsistent(request, result); + assert.equal(result.summary.packed_item_count, 7); + assert.equal(result.algorithm.effort_limit_reached, true); + assert.ok(result.unpacked_items.every((item) => item.reason === 'effort_limit')); +}); + +test('a block the budget cuts short places its first members and stops', () => { + const unlimited = packFallback(blocks); + const request = withBudget(blocks, { max_placement_attempts: 3 }); + const result = packFallback(request); + assertEffortConsistent(request, result); + assert.equal(result.algorithm.metrics.orientations_considered, 3); + assert.equal(result.algorithm.effort_limit_reached, true); + assert.ok(result.summary.packed_item_count > 0 && result.summary.packed_item_count < unlimited.summary.packed_item_count); +}); + +test('a later start handed a spent budget is still started and stopped', () => { + const portfolio = scene([box('a', 30, 20, 10, 12), box('b', 40, 40, 25, 4)], { multi_start_orders: 3 }, 1); + const first = packFallback(withBudget(portfolio, { max_restarts: 1 })); + const request = withBudget(portfolio, { max_search_nodes: first.algorithm.metrics.search_nodes_expanded }); + const result = packFallback(request); + assertEffortConsistent(request, result); + const [own, ...later] = result.termination.starts; + assert.deepEqual([own.started, own.truncated], [true, false]); + assert.ok(later.length >= 2); + for (const start of later) assert.deepEqual([start.started, start.truncated], [true, true]); +}); + +test('a named block solver outside quality reaches its first block', () => { + // Both profiles leave container_plan_node_limit at one, short of the first block. + for (const solver_profile of ['fast', 'balanced']) { + const request = { ...blocks, configuration: { ...blocks.configuration, solver_profile } }; + const result = packFallback(request); + assert.deepEqual(validate(request, result), []); + assert.equal(result.summary.packed_item_count, 46, solver_profile); + assert.equal(result.termination.code, 'complete', solver_profile); + } +}); + +test('a node limit stop is the search answer, not a time limit', () => { + const request = { ...blocks, configuration: { ...blocks.configuration, container_plan_node_limit: 1 } }; + const result = packFallback(request); + assert.deepEqual(validate(request, result), []); + assert.equal(result.summary.packed_item_count, 0); + assert.equal(result.algorithm.time_limit_reached, false); + assert.equal(result.termination.code, 'complete'); +}); diff --git a/test/fallback.test.mjs b/test/fallback.test.mjs index 8c31c2f..bb81e4c 100644 --- a/test/fallback.test.mjs +++ b/test/fallback.test.mjs @@ -5,6 +5,7 @@ import test from 'node:test'; import { Deadline, SequenceReplayError, UNSUPPORTED_FIELDS, UnsupportedFeatureError, BoundOverflowError, __spatialIndexForTests, __inspectHullShapeForTests, __objectiveBoundsForTests, + __requestBoundForTests, __occupiesLessThanItsBoxForTests, __candidatePrefixForTests, __groupBatchesForTests, aggregateTermination, InvalidRequestError, explainUnpackedItem, explanationForUnpackedItem, packFallback, rebalanceWeight, verifyLoadingPrefixBusinessRules, __ratiosForTests, __planBeamForTests, @@ -2156,6 +2157,59 @@ test('the objective lower bound matches Python on every corpus case', (t) => { assert.deepEqual(__objectiveBoundsForTests(instances, containers), one.bounds, `bounds diverge from Python on ${one.fixture}`); } + // The edges are written from the oracle: the fixed-placement rule and every edge of the one + // refusal predicate, where null means this port must refuse too (finding F4). + assert.ok(scene.edges.length > 0, 'the refusal predicate\'s edges are not optional'); + for (const edge of scene.edges) { + const instances = edge.instances.map((raw) => ({ + volume: BigInt(raw.volume), weight: BigInt(raw.weight), shrinks: raw.shrinks, + })); + const containers = edge.containers.map((raw) => ({ + usable: BigInt(raw.usable), inner: BigInt(raw.inner), baseArea: BigInt(raw.base_area), + height: BigInt(raw.height), + payload: raw.payload === null ? null : BigInt(raw.payload), + maxItems: raw.max_items === null ? null : BigInt(raw.max_items), + quantity: raw.quantity === null ? null : BigInt(raw.quantity), + costMinor: BigInt(raw.cost_minor), + })); + if (edge.bounds === null) { + assert.throws(() => __objectiveBoundsForTests(instances, containers), BoundOverflowError, + edge.name); + } else { + assert.deepEqual(__objectiveBoundsForTests(instances, containers), edge.bounds, edge.name); + } + } +}); + +test('the request bound is read from the request itself, in the objective\'s own order', () => { + // F3: the adapter forms volumes in BigInt from tick lengths and reads every item type's + // whole quantity, so a one-metre cube's 4.1e21 cubic ticks stay exact. + const metre = request([cube('m', 1000, { quantity: 2 })], + [box('b', 2000, 1000, 1000, { cost_minor: 3 })]); + assert.deepEqual(__requestBoundForTests(metre), [0, 1, 3, 0, 1000000]); + assert.deepEqual(__requestBoundForTests({ ...metre, configuration: { objective: 'lowest_cost' } }), + [0, 3, 1, 0, 1000000]); + assert.equal(__requestBoundForTests({ ...metre, configuration: { objective: 'maximum_value' } }), null); + assert.equal(__requestBoundForTests({ ...metre, configuration: { objective: 'constructor' } }), null); +}); + +test('a result states its bound, and attainment is the only route to optimal', () => { + // OPTIMALITY-GAP.md § 1.3: a perfect fill attains every key and is proven; a missed key + // carries the prefix and the gap; an uncovered objective states nothing. + const proven = packFallback(request([cube('c', 100, { quantity: 2 })], [box('b', 200, 100, 100)])); + assert.equal(proven.status, 'optimal'); + assert.deepEqual(proven.optimality, { code: 'proven_optimal', lower_bound: proven.score }); + + const missed = packFallback(request([cube('c', 100)], [box('b', 300, 100, 100)])); + assert.equal(missed.status, 'feasible'); + assert.equal(missed.optimality.code, 'not_proven'); + assert.equal(missed.optimality.lower_bound.length, missed.optimality.gap_key + 1); + assert.equal(missed.optimality.absolute_gap, + missed.score[missed.optimality.gap_key] - missed.optimality.lower_bound[missed.optimality.gap_key]); + + const uncovered = packFallback(request([cube('c', 100)], [box('b', 300, 100, 100)], + { configuration: { objective: 'maximum_volume' } })); + assert.deepEqual(uncovered.optimality, { code: 'not_proven' }); }); test('a sum past the declared ceiling is refused rather than answered', () => { @@ -2958,11 +3012,12 @@ test('fast enumerates one block mode where balanced enumerates both', () => { assert.equal(spent('balanced') - own, 2 * (spent('fast') - own)); }); -test('the added block start carries its own node limit, not the container plan limit', () => { - // Selected by name on `balanced` the solver keeps the profile's limit of one node and - // stops before its first block; the added start must not inherit that. +test('the block solver carries its own node limit outside quality, not the container plan limit', () => { + // `balanced` leaves the container plan limit at one node, short of the first block; the + // solver selected by name and the added start both carry their own bound. const selected = packFallback(cartons('balanced', { solvers: ['homogeneous_blocks'] })); - assert.equal(selected.summary.packed_item_count, 0); + assert.equal(selected.summary.container_count, 1); + assert.equal(selected.unpacked_items.length, 0); assert.equal(packFallback(cartons('balanced', { container_plan_node_limit: 1 })).summary.container_count, 1); }); diff --git a/test/legacy-smoke.mjs b/test/legacy-smoke.mjs index 78136b7..6facc8c 100644 --- a/test/legacy-smoke.mjs +++ b/test/legacy-smoke.mjs @@ -13,7 +13,9 @@ const result = pack({ assert.ok(['rust', 'javascript'].includes(backend())); assert.equal(typeof version(), 'string'); -assert.equal(result.status, 'feasible'); +// Two cubes fill the container exactly, so the 1.8 optimality check proves the answer optimal. +assert.equal(result.status, 'optimal'); +assert.equal(result.optimality.code, 'proven_optimal'); assert.equal(result.complete, true); assert.equal(result.summary.packed_item_count, 2); assert.equal(result.summary.unpacked_item_count, 0); diff --git a/test/native-stand-in.mjs b/test/native-stand-in.mjs index caf1013..40d7fcd 100644 --- a/test/native-stand-in.mjs +++ b/test/native-stand-in.mjs @@ -23,6 +23,13 @@ function packageCopy() { for (const name of MANIFEST.files.filter((file) => file.endsWith('.js'))) { fs.copyFileSync(path.join(PACKAGE, name), path.join(root, name)); } + // A declared directory of modules the entry point imports, such as `result-validation/`; + // `examples/` only imports the package and is left out. + const isModuleDirectory = (file) => file !== 'examples' && fs.existsSync(path.join(PACKAGE, file)) + && fs.statSync(path.join(PACKAGE, file)).isDirectory(); + for (const name of MANIFEST.files.filter(isModuleDirectory)) { + fs.cpSync(path.join(PACKAGE, name), path.join(root, name), { recursive: true }); + } fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ type: 'module' })); return root; } diff --git a/test/negative-seed.test.mjs b/test/negative-seed.test.mjs new file mode 100644 index 0000000..63c3957 --- /dev/null +++ b/test/negative-seed.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { packFallback } from '../fallback.js'; +import { validate } from './validate.mjs'; + +// Every engine interprets a shuffle seed by its residue modulo 2^31. +// A generous clock and bounded effort keep the full report reproducible, counters included. +const request = seed => ({ + units: { length: 'mm' }, + configuration: { + solver_profile: 'quality', seed, time_limit_ms: 600_000, + effort_budget: { max_candidates_evaluated: 1_000_000, max_placement_attempts: 1_000_000, max_search_nodes: 1_000_000 }, + }, + items: [20, 25, 30, 35, 40, 45].map(side => ({ + id: `c${side}`, dimensions: { length: String(side), width: String(side), height: String(side) }, weight: '1 g', + })), + containers: [{ id: 'c', quantity: 1, inner_dimensions: { length: '160', width: '140', height: '120' } }], +}); +const withoutSeed = result => JSON.stringify(result, (key, value) => ['seed', 'duration_ms', 'elapsed_ms'].includes(key) ? undefined : value); + +for (const seed of [-7, -7 - 2 ** 31, 42 + 2 ** 31]) { + test(`quality seed ${seed} preserves its echo and uses its modulo 2^31 residue`, () => { + const payload = request(seed); + const result = packFallback(payload); + const residue = ((seed % 2 ** 31) + 2 ** 31) % 2 ** 31; + const normalized = packFallback(request(residue)); + assert.deepEqual(validate(payload, result), []); + assert.equal(result.algorithm.seed, seed); + assert.equal(result.algorithm.time_limit_reached, false); + assert.equal(normalized.algorithm.time_limit_reached, false); + assert.equal(withoutSeed(result), withoutSeed(normalized)); + }); +} diff --git a/test/request-errors.test.mjs b/test/request-errors.test.mjs index 9f4b1f2..ee4c036 100644 --- a/test/request-errors.test.mjs +++ b/test/request-errors.test.mjs @@ -163,7 +163,8 @@ test('null optional fields are absent, and a valid request is admitted', () => { }); request.configuration.effort_budget = null; assert.equal(packFallback(request).status, 'feasible'); - assert.equal(packFallback({ ...base(), units: null }).status, 'feasible'); + // Without the obstacle the base request's packing attains its bound, which proves it. + assert.equal(packFallback({ ...base(), units: null }).status, 'optimal'); }); test('an unknown objective or access direction is refused with not_allowed and its pointer', () => { diff --git a/test/request-measures.test.mjs b/test/request-measures.test.mjs new file mode 100644 index 0000000..db24a15 --- /dev/null +++ b/test/request-measures.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +// Count actual parser executions without adding production instrumentation. Reintroducing +// a raw model parser in a frame makes the work grow with the portfolio again. +const sourceUrl=new URL('../fallback.js',import.meta.url); +const rawSource=fs.readFileSync(sourceUrl,'utf8'); +const parserNeedle='function scalar(v,def,table){'; +assert.equal(rawSource.split(parserNeedle).length-1,1,'scalar instrumentation must match exactly once'); +const source=rawSource.replace(/from '(\.\/[^']+)'/g, + (_,specifier)=>`from '${new URL(specifier,sourceUrl).href}'`) + .replace(parserNeedle,parserNeedle+'globalThis.__requestMeasureParses++;'); +const {packFallback}=await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + +test('made-to-measure input parsing does not grow with portfolio starts',()=>{ + // Keep the package test independent of the workspace conformance tree. + const sides=value=>({length:String(value),width:String(value),height:String(value)}); + const fixture={units:{length:'mm'},configuration:{minimum_support_ratio:1, + effort_budget:{max_candidates_evaluated:1000000,max_placement_attempts:1000000,max_search_nodes:1000000}}, + items:[{id:'cube',quantity:8,weight:'100',dimensions:sides(100)}], + containers:[{id:'machine',cost_minor:40,inner_dimensions:sides(1510),made_to_measure:{ + minimum:sides(60),increment:sides(25),cost_per_square_metre_minor:1000, + outer_allowance:sides(3),tare_per_square_metre:'500'}}]}; + const counts=[]; + for(const starts of [1,4]){ + const request=structuredClone(fixture); + request.configuration={...request.configuration,solver_profile:'quality',multi_start_orders:starts,time_limit_ms:300000}; + globalThis.__requestMeasureParses=0; + const result=packFallback(request); + assert.ok(['feasible','optimal'].includes(result.status)); + assert.ok(globalThis.__requestMeasureParses>0,'instrumentation must observe real parser work'); + counts.push(globalThis.__requestMeasureParses); + } + assert.equal(counts[1],counts[0],'machine input measures must come from the request table'); + delete globalThis.__requestMeasureParses; +}); diff --git a/test/result-validation-bound.test.mjs b/test/result-validation-bound.test.mjs new file mode 100644 index 0000000..69a7775 --- /dev/null +++ b/test/result-validation-bound.test.mjs @@ -0,0 +1,182 @@ +/** + * The JavaScript port's copy of the optimality bound, held to the oracle's numbers on the + * shared scene, and the four checks it feeds, each broken once. + * + * The corpus test holds the port to the reference's verdict on every pair the cover keeps; this + * pins the formulas on every case and refusal edge of `conformance/scene/objective-bounds.json`, + * volumes carried as decimal strings and read as `BigInt`, never through a `Number`. + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { lowerBound } from '../result-validation/bound.js'; +import { validateWith } from '../index.js'; + +const SCENE = new URL('../../../../conformance/scene/objective-bounds.json', import.meta.url); + +const integer = (value) => BigInt(value); +const optional = (value) => (value === null ? null : BigInt(value)); +const runs = (instances, field) => instances.map((instance) => [integer(instance[field]), 1n]) + .sort((left, right) => (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0)); + +function sceneBound(scenario) { + const containers = scenario.containers.map((container) => ({ + innerVolume: integer(container.inner), + baseArea: integer(container.base_area), + height: integer(container.height), + usable: integer(container.usable), + costMinor: integer(container.cost_minor), + payload: optional(container.payload), + maxItems: optional(container.max_items), + quantity: optional(container.quantity), + })); + const shrinks = scenario.instances.some((instance) => instance.shrinks); + return lowerBound(runs(scenario.instances, 'volume'), runs(scenario.instances, 'weight'), shrinks, containers); +} + +if (!fs.existsSync(SCENE)) { + test('the shared scene is not part of this package', { skip: true }, () => {}); +} else { + test('the validator bound matches every case and refusal edge of the shared scene', () => { + const scene = JSON.parse(fs.readFileSync(SCENE, 'utf8')); + const disagreements = [...scene.cases, ...scene.edges] + .map((scenario) => ({ name: scenario.name || scenario.fixture, computed: sceneBound(scenario), expected: scenario.bounds })) + .filter(({ computed, expected }) => JSON.stringify(computed && computed.map(String)) !== JSON.stringify(expected && expected.map(String))); + assert.deepEqual(disagreements, []); + assert.ok(scene.cases.length + scene.edges.length > 400); + }); +} + +test('an unlimited capacity, a negative one and zero-volume instances are read as the reference reads them', () => { + const container = { + innerVolume: 1000n, baseArea: 100n, height: 10n, usable: -1n, costMinor: 3n, payload: 5n, maxItems: null, quantity: 1n, + }; + assert.deepEqual(lowerBound([[0n, 2n]], [[1n, 2n]], false, [container]), [2n, 0n, 0n, 0n, 0n]); + assert.deepEqual(lowerBound([[0n, 2n]], [[1n, 2n]], false, [{ ...container, quantity: null }]), [0n, 1n, 3n, 1000000n, 0n]); + assert.deepEqual(lowerBound([[1n, 1n]], [[0n, 1n]], false, []), [1n, 0n, 0n, 0n, 0n]); +}); + +/** One cube in a box for two: attains every position but the height, 1000000 against 500000. */ +const REQUEST = { + items: [{ id: 'cube', quantity: 1, dimensions: { length: '10', width: '10', height: '10' } }], + containers: [{ id: 'box', inner_dimensions: { length: '20', width: '10', height: '10' } }], +}; + +const MM3 = 16000 ** 3; + +function result(optimality) { + const tick = (mm) => ({ ticks: mm * 16000, value: String(mm), unit: 'mm' }); + return { + status: 'feasible', + feasibility: { code: 'feasible' }, + termination: { + code: 'complete', + any_start_truncated: false, + all_required_starts_completed: true, + winning_start_truncated: false, + global_deadline_reached: false, + starts: [{ id: 'test', started: true, completed: true, truncated: false, selected: true, global_deadline_reached: false }], + }, + optimality, + complete: true, + algorithm: { + solver: 'test', + time_limit_reached: false, + effort_limit_reached: false, + candidates_evaluated: 1, + placements_attempted: 1, + metrics: { + candidate_points_considered: 1, + orientations_considered: 1, + feasible_candidates: 1, + collision_checks: 0, + support_checks: 0, + space_partitions: 0, + search_nodes_expanded: 0, + }, + }, + score: [0, 1, 0, 500000, 1000000], + summary: { container_count: 1, packed_item_count: 1, unpacked_item_count: 0 }, + containers: [{ + id: 'box#1', + container_type: 'box', + payload_weight: { ticks: 0 }, + used_volume_ticks3: String(1000 * MM3), + centre_of_mass_offset_ppm: 0, + placements: [{ + item_id: 'cube#1', + item_type: 'cube', + orientation: 'LWH', + position: { x: tick(0), y: tick(0), z: tick(0) }, + dimensions: { length: tick(10), width: tick(10), height: tick(10) }, + }], + }], + unpacked_items: [], + }; +} + +const GAP = { + code: 'not_proven', + lower_bound: [0, 1, 0, 500000, 500000], + gap_key: 4, + absolute_gap: 500000, + relative_gap: { numerator: 500000, denominator: 500000 }, +}; + +const issuesOf = (optimality, change = () => {}) => { + const broken = result(JSON.parse(JSON.stringify(optimality))); + change(broken); + return validateWith(null, REQUEST, broken).map((found) => found.code); +}; + +test('a stated bound and gap are valid, and so is their absence', () => { + assert.deepEqual(issuesOf(GAP), []); + assert.deepEqual(issuesOf({ code: 'not_proven' }), []); +}); + +test('each of the four checks fires on its own mutation', () => { + assert.deepEqual(issuesOf(GAP, (broken) => { broken.optimality.lower_bound[1] = 2; }), ['optimality_bound_mismatch']); + assert.deepEqual(issuesOf(GAP, (broken) => { broken.optimality.absolute_gap = 1; }), ['optimality_gap_mismatch']); + assert.deepEqual(issuesOf(GAP, (broken) => { broken.score[3] = 0; }), + ['score_mismatch', 'score_below_bound', 'optimality_bound_mismatch']); + assert.deepEqual(issuesOf(GAP, (broken) => { + broken.status = 'optimal'; + broken.optimality.code = 'proven_optimal'; + }), ['result_fact_mismatch', 'unproven_optimality_claim']); +}); + +for (const cheapCost of [0, 1]) { + test(`an unrepresentable gap is withheld and rejected when forged (lower cost ${cheapCost})`, async () => { + const { packFallback } = await import('../fallback.js'); + const request = { + items: [{ id: 'i', quantity: 2, dimensions: { length: '10', width: '10', height: '10' } }], + containers: [ + { id: 'cheap', inner_dimensions: { length: '1', width: '1', height: '1' }, cost_minor: cheapCost, max_items: 1 }, + { id: 'big', inner_dimensions: { length: '10', width: '10', height: '10' }, cost_minor: Number.MAX_SAFE_INTEGER, max_items: 1 }, + ], + }; + const packed = packFallback(request); + assert.deepEqual(packed.optimality, { code: 'not_proven' }); + assert.deepEqual(validateWith(null, request, packed), []); + const absolute = 2 * Number.MAX_SAFE_INTEGER - 2 * cheapCost; + packed.optimality = { code: 'not_proven', lower_bound: [0, 2, 2 * cheapCost], gap_key: 2, absolute_gap: absolute }; + if (cheapCost) packed.optimality.relative_gap = { numerator: absolute, denominator: 2 * cheapCost }; + assert.ok(validateWith(null, request, packed).some(issue => issue.code === 'optimality_gap_mismatch')); + }); +} + +test('a gap at the portable ceiling is still stated', async () => { + const { packFallback } = await import('../fallback.js'); + const request = { + items: [{ id: 'i', dimensions: { length: '10', width: '10', height: '10' } }], + containers: [ + { id: 'cheap', inner_dimensions: { length: '1', width: '1', height: '1' }, cost_minor: 0 }, + { id: 'big', inner_dimensions: { length: '10', width: '10', height: '10' }, cost_minor: Number.MAX_SAFE_INTEGER }, + ], + }; + const packed = packFallback(request); + assert.equal(packed.optimality.absolute_gap, Number.MAX_SAFE_INTEGER); + assert.deepEqual(validateWith(null, request, packed), []); +}); diff --git a/test/result-validation-corpus.test.mjs b/test/result-validation-corpus.test.mjs new file mode 100644 index 0000000..669a67b --- /dev/null +++ b/test/result-validation-corpus.test.mjs @@ -0,0 +1,77 @@ +/** + * The JavaScript port of the reference validator gives the reference's verdict on every + * pair of the shared corpus, with no addon involved. + * + * `conformance/result_validation/corpus.json` (scripts/generate_result_validation_corpus.py) is + * the subset of the cross-language corpus that reaches every arc, issue code and refusal kind the + * full corpus reaches in the reference, each pair with the reference's outcome. Each pair is + * parsed and handed to `validateWith(null, ...)`, so the port answers whatever this host has + * built, and is judged by `conformance/result_validation/run.py`'s rules: the same issues in the + * same order; for a `contract` refusal, a `contract` refusal with the same message (up to a quoted + * Python exception); for a `shape` refusal, any refusal. The full five-thousand-pair comparison is + * the workspace's result-validation conformance run, as its `engine-fallback` engine. + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { MalformedResult, validateWith } from '../index.js'; + +const CORPUS = new URL('../../../../conformance/result_validation/corpus.json', import.meta.url); +const SOURCES = new URL('../result-validation/', import.meta.url); + +/** Reference refusals that end by quoting a Python exception compare up to the quote. */ +const QUOTES_PYTHON_ERROR = /^(placement is missing tick coordinates: |.*: lattice_summary is missing a canonical field: )/; + +function outcome(requestText, resultText) { + let request; + let result; + try { + request = JSON.parse(requestText); + result = JSON.parse(resultText); + } catch (error) { + // Text that is not JSON cannot reach `validate`; the reference refuses it as `shape`. + return { refusal: { kind: 'shape', message: error.message } }; + } + try { + return { issues: validateWith(null, request, result) }; + } catch (error) { + if (!(error instanceof MalformedResult)) throw error; + return { refusal: { kind: error.kind, message: error.message } }; + } +} + +function agrees(expected, actual) { + if (expected.issues) return JSON.stringify(actual.issues) === JSON.stringify(expected.issues); + if (!actual.refusal) return false; + if (expected.refusal.kind !== 'contract') return true; + const quoted = QUOTES_PYTHON_ERROR.exec(expected.refusal.message); + return actual.refusal.kind === 'contract' + && (quoted ? actual.refusal.message.startsWith(quoted[1]) : actual.refusal.message === expected.refusal.message); +} + +if (!fs.existsSync(CORPUS)) { + test('the shared result-validation corpus is not part of this package', { skip: true }, () => {}); +} else { + const corpus = JSON.parse(fs.readFileSync(CORPUS, 'utf8')); + + test('every pair of the corpus gets the reference\'s verdict from the JavaScript port', () => { + assert.equal(corpus.format, 'packvium-result-validation-corpus/v1'); + const disagreements = corpus.cases + .map((pair, index) => ({ index, expected: pair.expected, actual: outcome(pair.request, pair.result) })) + .filter(({ expected, actual }) => !agrees(expected, actual)); + assert.deepEqual(disagreements, []); + }); + + test('the corpus reaches every issue code the port can raise', () => { + const raisable = new Set(); + for (const name of fs.readdirSync(SOURCES).filter((file) => file.endsWith('.js'))) { + const source = fs.readFileSync(new URL(name, SOURCES), 'utf8'); + for (const match of source.matchAll(/issue\('([a-z_0-9]+)'/g)) raisable.add(match[1]); + } + const raised = new Set(corpus.cases.flatMap((pair) => (pair.expected.issues || []).map((found) => found.code))); + assert.deepEqual([...raisable].filter((code) => !raised.has(code)), []); + assert.ok(raisable.size >= 70, `only ${raisable.size} issue codes found in the port's source`); + }); +} diff --git a/test/result-validation-port.test.mjs b/test/result-validation-port.test.mjs new file mode 100644 index 0000000..d2ae297 --- /dev/null +++ b/test/result-validation-port.test.mjs @@ -0,0 +1,227 @@ +/** + * The Python semantics the JavaScript port of the reference validator rests on. + * + * The corpus tests hold the whole port to the reference's verdicts; these pin the pieces a + * corpus rarely reaches -- `repr` of a float or an astral string, `%.6f` of an exact tie, the + * `Fraction` grammar, correctly rounded `float(fraction)`, Python's equality and ordering across + * types -- with the same cases the Rust core's unit tests use, so the two ports are pinned alike. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { LENGTH, toTicks } from '../result-validation/units.js'; +import { + PyDict, compareCodePoints, get, hashKey, int, kind, list, pyCmp, pyEq, pyInt, pyIntInstance, sortPy, text, truthy, +} from '../result-validation/json.js'; +import { Ratio, ceilDiv, fixed6, floorDiv, parseFraction, pyMod, ratioPpm } from '../result-validation/num.js'; +import { floatRepr, pyRepr, strRepr, tuple } from '../result-validation/pyfmt.js'; +import { UNICODE_VERSION } from '../result-validation/nonprintable.js'; +import { Refusal } from '../result-validation/outcome.js'; +import { cuboid, hullVolume, rotateHull, vertices } from '../result-validation/geometry.js'; +import { placedHull } from '../result-validation/stacking.js'; +import { MalformedResult, pack, validateWith } from '../index.js'; + +const refusesAsShape = (run) => assert.throws(run, (error) => error instanceof Refusal && error.kind === 'shape'); + +test('floats print as Python repr prints them', () => { + const cases = [ + [1e16, '1e+16'], [1e-5, '1e-05'], [0.0001, '0.0001'], [123456789012345678, '1.2345678901234568e+17'], + [0.1, '0.1'], [-0.5, '-0.5'], [5.5, '5.5'], [1e15 + 0.5, '1000000000000000.5'], [2.5e-6, '2.5e-06'], + [1e21, '1e+21'], [1.5, '1.5'], [Infinity, 'inf'], [-Infinity, '-inf'], [NaN, 'nan'], [-0, '-0.0'], [0, '0.0'], + ]; + for (const [value, expected] of cases) assert.equal(floatRepr(value), expected, String(value)); +}); + +test('strings print as Python repr prints them, by code point', () => { + assert.equal(strRepr('abc'), "'abc'"); + assert.equal(strRepr('\u{e0001}\u0600'), "'\\U000e0001\\u0600'"); + assert.equal(strRepr('\u0378\ue000\u00e9'), "'\\u0378\\ue000\u00e9'"); + assert.equal(UNICODE_VERSION, '14.0.0'); + assert.equal(strRepr("it's"), '"it\'s"'); + assert.equal(strRepr('\'"'), "'\\'\"'"); + assert.equal(strRepr('a\nb\\'), "'a\\nb\\\\'"); + assert.equal(strRepr('\u0007\u00e9\u200b'), "'\\x07\u00e9\\u200b'"); + assert.equal(strRepr('a\tb\rc\u007f\u0085'), "'a\\tb\\rc\\x7f\\x85'"); + assert.equal(strRepr('\u2028\u{f0000}\u{1f600}'), "'\\u2028\\U000f0000\u{1f600}'"); + assert.equal(strRepr('\ud800'), "'\\ud800'"); +}); + +test('containers and special values print as Python prints them', () => { + assert.equal(pyRepr({ a: [1, null, true], b: 'x', c: 1.5, d: false }), "{'a': [1, None, True], 'b': 'x', 'c': 1.5, 'd': False}"); + assert.equal(pyRepr(12n), '12'); + assert.equal(tuple(['7']), '(7,)'); + assert.equal(tuple(['1', '2']), '(1, 2)'); +}); + +test('%.6f rounds the exact double half to even, as Python does', () => { + assert.equal(fixed6(1 / 128), '0.007812'); + assert.equal(fixed6(3 / 128), '0.023438'); + assert.equal(fixed6(2.5e-6), '0.000003'); + assert.equal(fixed6(0.5), '0.500000'); + assert.equal(fixed6(1234.5), '1234.500000'); + assert.equal(fixed6(2 ** 60), '1152921504606846976.000000'); + assert.equal(fixed6(-1e-9), '-0.000000'); + assert.equal(fixed6(5e-324), '0.000000'); + assert.equal(fixed6(Infinity), 'inf'); + assert.equal(fixed6(-Infinity), '-inf'); + assert.equal(fixed6(NaN), 'nan'); +}); + +test('fractions parse by CPython 3.11\'s grammar', () => { + const cases = [ + ['1_000', 1000n, 1n], ['.5', 1n, 2n], ['5.', 5n, 1n], ['1e3', 1000n, 1n], ['1E-2', 1n, 100n], + [' 3/16 ', 3n, 16n], ['+.5e1', 5n, 1n], ['1_0.0_1', 1001n, 100n], ['-0.25', -1n, 4n], ['\u001c7\u3000', 7n, 1n], + ]; + for (const [source, numerator, denominator] of cases) { + const parsed = parseFraction(source); + assert.deepEqual([parsed.numerator, parsed.denominator], [numerator, denominator], source); + } + for (const bad of ['1 / 2', '0x10', '', '.', '1__0', '_1', '1/0', 'None', '1e', '1/2x', '1.5x']) { + refusesAsShape(() => parseFraction(bad)); + } +}); + +test('float(fraction) is correctly rounded, ties to even', () => { + assert.equal(new Ratio(5n, 2000000n).toDouble(), 2.5e-6); + assert.equal(new Ratio(1n, 3n).toDouble(), 1 / 3); + assert.equal(new Ratio(-1n, 3n).toDouble(), -1 / 3); + assert.equal(Ratio.of((1n << 60n) + 1n).toDouble(), 2 ** 60); + assert.equal(Ratio.of((1n << 54n) + 3n).toDouble(), 2 ** 54 + 4); + assert.equal(Ratio.of((1n << 53n) + 1n).toDouble(), 2 ** 53); + assert.equal(Ratio.of(0n).toDouble(), 0); + refusesAsShape(() => Ratio.of(10n ** 400n).toDouble()); + refusesAsShape(() => new Ratio(1n, 0n)); + assert.equal(ratioPpm(parseFraction('0.0001245')), 124n); + assert.equal(new Ratio(1n, 2n).add(new Ratio(1n, 3n)).compare(new Ratio(5n, 6n)), 0); +}); + +test('integer division floors as Python floors', () => { + assert.equal(floorDiv(-7n, 2n), -4n); + assert.equal(floorDiv(7n, -2n), -4n); + assert.equal(floorDiv(6n, 3n), 2n); + assert.equal(ceilDiv(7n, 2n), 4n); + assert.equal(pyMod(-7n, 3n), 2n); + refusesAsShape(() => floorDiv(1n, 0n)); +}); + +test('measures read as the reference reads them', () => { + const ticks = (measure) => toTicks(measure, 'mm', LENGTH); + assert.equal(ticks(2), 32000n); + assert.equal(ticks(0.5), 8000n); + assert.equal(ticks('12 3/8 in'), 12n * 406400n + 152400n); + assert.equal(ticks('-1 1/2 mm'), -24000n); + assert.equal(ticks('3 ticks'), 3n); + assert.equal(ticks('10 CM'), 1600000n); + assert.equal(ticks('0.00003125'), 0n); + assert.equal(ticks('0.00009375'), 2n); + assert.equal(ticks({ value: '1', unit: 'in' }), 406400n); + assert.equal(ticks(true), 16000n); + refusesAsShape(() => ticks('5 furlong')); + refusesAsShape(() => ticks({ value: 1, unit: null })); +}); + +test('values compare, hash and convert as Python\'s do', () => { + assert.equal(pyCmp([1, 2], [1]), 1); + assert.equal(pyCmp([1], [1, 2]), -1); + assert.equal(pyCmp(1.5, 2), -1); + assert.equal(pyCmp(2, 1.5), 1); + assert.equal(pyCmp(2.5, 1.5), 1); + assert.equal(pyCmp(1.5, 1.5), 0); + assert.equal(pyCmp(1e21, 10n ** 21n), 0); + assert.equal(pyCmp(-1.5, -1), -1); + assert.ok(pyEq([1, [2], null], [true, [2n], null])); + assert.ok(!pyEq([1, 2], [1, 3])); + assert.ok(!pyEq({ a: 1 }, { b: 1 })); + assert.ok(pyEq({ a: 1 }, { a: true })); + assert.ok(!pyEq('1', 1)); + assert.ok(!pyEq(null, 0)); + assert.ok(!pyEq([1], { 0: 1 })); + assert.ok(truthy(0.5) && truthy(1n) && !truthy(0n) && !truthy({}) && truthy({ a: 1 }) && !truthy('')); + assert.equal(hashKey(null), 'null'); + assert.equal(hashKey(1), hashKey(true)); + assert.notEqual(hashKey(2.5), hashKey(2)); + assert.equal(hashKey(1e21), hashKey(10n ** 21n)); + assert.deepEqual([null, false, 1, 1.5, 'x', [1], {}].map(kind), + ['null', 'a boolean', 'an integer', 'a number', 'a string', 'a list', 'an object']); + assert.equal(pyIntInstance(true), 1n); + assert.equal(pyInt(false, 'x'), 0n); + assert.equal(pyInt(-2.7, 'x'), -2n); + assert.equal(pyInt(' +1_000 ', 'x'), 1000n); + assert.equal(pyInt('-12', 'x'), -12n); + assert.deepEqual(sortPy(['\u{1f600}', '\uff21', 'a']), ['a', '\uff21', '\u{1f600}']); + assert.equal(compareCodePoints('ab', 'a'), 1); + assert.equal(compareCodePoints('a', 'ab'), -1); +}); + +test('what the reference cannot read is a shape refusal', () => { + const refusals = [ + () => get(5, 'x'), () => list('x', 'x'), () => text(1, 'x'), () => int(1.5, 'x'), () => pyInt([], 'x'), + () => pyInt('1__0', 'x'), () => pyCmp([1], 'a'), () => hashKey([1]), () => sortPy([1, 'a']), + () => pyCmp(null, null), () => rotateHull([], [0, 1, 2]), + ]; + refusals.forEach((run) => refusesAsShape(run)); +}); + +test('a repeated key keeps its first place and its last value, as in a Python dict', () => { + const dict = new PyDict(); + dict.set(1, 'a'); + dict.set('b', 'b'); + dict.set(true, 'c'); + assert.deepEqual(dict.items(), [[1, 'c'], ['b', 'b']]); + assert.equal(dict.position('b'), 1); + assert.equal(dict.get(1.5), undefined); +}); + +test('a hull\'s volume is exact, whatever the vertex order', () => { + const cube = []; + for (const x of [0n, 2n]) for (const y of [0n, 2n]) for (const z of [0n, 2n]) cube.push([x, y, z]); + assert.equal(hullVolume(cube), 8n); + assert.equal(hullVolume(rotateHull(cube.slice().reverse(), [1, 0, 2])), 8n); + // A wedge with a vertex on an edge midpoint: the face walk passes over it. + const wedge = [[0n, 0n, 0n], [4n, 0n, 0n], [0n, 4n, 0n], [0n, 0n, 4n], [2n, 0n, 0n]]; + assert.equal(hullVolume(wedge), 10n); +}); + +test('a hull whose reported box is not its rotated size is judged as that box', () => { + const spec = { + shapeType: 'convex_hull', stopIndex: null, dimensions: [2n, 2n, 2n], + hullVertices: [[0n, 0n, 0n], [2n, 0n, 0n], [0n, 2n, 0n], [0n, 0n, 2n]], + }; + const grown = cuboid(-1n, -1n, -1n, 4n, 4n, 4n); + assert.deepEqual(placedHull(grown, { orientation: 'LWH' }, spec), vertices(grown)); + const exact = cuboid(0n, 0n, 0n, 2n, 2n, 2n); + assert.equal(placedHull(exact, { orientation: 'LWH' }, spec).length, 4); +}); + +test('a container without an id is refused once an issue has to name it', () => { + const request = { + items: [{ id: 'cube', quantity: 1, dimensions: { length: '10', width: '10', height: '10' } }], + containers: [{ id: 'box', inner_dimensions: { length: '10', width: '10', height: '10' } }], + }; + const result = pack(request); + assert.deepEqual(validateWith(null, request, result), []); + delete result.containers[0].id; + assert.deepEqual(validateWith(null, request, result), []); + result.containers[0].payload_weight = { ticks: 1 }; + assert.throws(() => validateWith(null, request, result), (error) => error instanceof MalformedResult && error.kind === 'shape'); +}); + + +test('large JSON integer counters keep the decimal value read by the reference', () => { + const request = { + items: [{ id: 'a', dimensions: { length: '1', width: '1', height: '1' } }], + containers: [{ id: 'box', inner_dimensions: { length: '1', width: '1', height: '1' } }], + configuration: { effort_budget: { max_candidates_evaluated: 1 } }, + }; + const result = pack(request); + const count = 1000000000000000100; + result.algorithm.metrics.feasible_candidates = count; + result.algorithm.candidates_evaluated = count; + assert.deepEqual(validateWith(null, request, result).filter(({ code }) => code === 'effort_budget_exceeded'), [{ + code: 'effort_budget_exceeded', + detail: 'feasible_candidates is 1000000000000000100, above max_candidates_evaluated=1', + }]); + assert.equal(pyEq(count, 1000000000000000100n), true); + assert.equal(hashKey(count), hashKey(1000000000000000100n)); +}); diff --git a/test/result-validation.test.mjs b/test/result-validation.test.mjs index 4303c2a..b8c4316 100644 --- a/test/result-validation.test.mjs +++ b/test/result-validation.test.mjs @@ -3,8 +3,9 @@ * * The addon returns one JSON document, `{issues}` or `{refusal: {kind, message}}`, so a * refusal's `kind` survives the boundary; `validateWith` turns that into the package's own - * `MalformedResult`. Issue-for-issue agreement with `packvium.validate` over the whole - * conformance corpus is `conformance/result_validation/run.py`'s job; this pins the wrapper. + * `MalformedResult`. Without an addon the package's JavaScript port answers. + * Issue-for-issue agreement with `packvium.validate` over the whole conformance corpus is + * `conformance/result_validation/run.py`'s job; this pins the wrapper. */ import assert from 'node:assert/strict'; @@ -25,6 +26,24 @@ const REQUEST = { containers: [{ id: 'box', inner_dimensions: { length: '20', width: '10', height: '10' } }], }; +const require = createRequire(import.meta.url); + +/** The compiled addon when one is loadable, else `null`. */ +function loadNative() { + // The same specifiers `force-fallback.cjs` blocks, so a run pinned to the fallback never + // loads the addon here. + for (const candidate of ['./packvium-native.node', '../../packvium-rust/bindings/node']) { + try { return require(candidate); } catch { /* not built or blocked */ } + } + return null; +} + +/** Every validator this host can run: the JavaScript port always, the addon when built. */ +function validators() { + const native = loadNative(); + return [['javascript', null], ...(native?.validateJson ? [['addon', native]] : [])]; +} + const answering = (outcome) => ({ validateJson: () => JSON.stringify(outcome) }); test('the addon\'s issues are returned as they came', () => { @@ -42,21 +61,35 @@ test('a refusal becomes a MalformedResult carrying its kind', () => { }); }); -test('without the addon, validate says so rather than answering', () => { - assert.throws(() => validateWith(null, REQUEST, {}), ValidatorUnavailableError); - if (backend() === 'javascript') { - assert.throws(() => validate(REQUEST, pack(REQUEST)), (error) => error.code === 'validator_unavailable'); - } +test('without the addon, the JavaScript port answers', () => { + const result = pack(REQUEST); + assert.deepEqual(validateWith(null, REQUEST, result), []); + assert.deepEqual(validateWith({}, REQUEST, result), []); + const broken = JSON.parse(JSON.stringify(result)); + const placements = broken.containers[0].placements; + placements[1].position = JSON.parse(JSON.stringify(placements[0].position)); + assert.deepEqual(validateWith(null, REQUEST, broken).map((issue) => issue.code), ['collision']); + if (backend() === 'javascript') assert.deepEqual(validate(REQUEST, result), []); +}); + +test('the port refuses what it cannot judge as a MalformedResult carrying its kind', () => { + assert.throws(() => validateWith(null, REQUEST, {}), (error) => { + assert.ok(error instanceof MalformedResult); + assert.equal(error.message, "result is missing 'status'"); + assert.equal(error.kind, 'contract'); + return true; + }); + assert.throws(() => validateWith(null, undefined, pack(REQUEST)), (error) => error.kind === 'shape'); +}); + +test('ValidatorUnavailableError is still exported, though nothing throws it', () => { + const error = new ValidatorUnavailableError(); + assert.equal(error.code, 'validator_unavailable'); + assert.equal(error.name, 'ValidatorUnavailableError'); }); test('the real addon judges a packed result valid and a broken one not', (t) => { - const require = createRequire(import.meta.url); - let native = null; - // The same specifiers `force-fallback.cjs` blocks, so a run pinned to the fallback never - // loads the addon here. - for (const candidate of ['./packvium-native.node', '../../packvium-rust/bindings/node']) { - try { native = require(candidate); break; } catch { /* not built or blocked */ } - } + const native = loadNative(); if (!native?.validateJson) { t.skip('no native addon exporting validateJson is loadable here'); return; @@ -72,16 +105,7 @@ test('the real addon judges a packed result valid and a broken one not', (t) => // (audit F5): the void-fill reserve is `floor(volume * ppm / 10^6)` with the solvers' // double conversion of the ratio. `0.0001245` is 124 ppm there where the exact decimal rounds // to 125, so a 10^7 cubic tick container reserves 1240 ticks, not 1250. -test('the real addon judges the void-fill reserve by the solvers\' ppm rule', (t) => { - const require = createRequire(import.meta.url); - let native = null; - for (const candidate of ['./packvium-native.node', '../../packvium-rust/bindings/node']) { - try { native = require(candidate); break; } catch { /* not built or blocked */ } - } - if (!native?.validateJson) { - t.skip('no native addon exporting validateJson is loadable here'); - return; - } +for (const [name, native] of validators()) test(`the ${name} validator judges the void-fill reserve by the solvers' ppm rule`, () => { const request = { units: { length: 'tick' }, items: [{ id: 'bar', quantity: 1, allowed_rotations: ['LWH'], dimensions: { length: '1', width: '1', height: '1' } }], @@ -109,16 +133,7 @@ test('the real addon judges the void-fill reserve by the solvers\' ppm rule', (t // `compression_ratio` goes through the same conversion. A 5 kg brick holds a 1 m // compressible pad at half its 10 kPa limit, where one ppm moves the occupied height by about // eight ticks, so 0.0001245 must compress exactly as 0.000124 does. -test('the real addon judges compression by the solvers\' ppm rule', (t) => { - const require = createRequire(import.meta.url); - let native = null; - for (const candidate of ['./packvium-native.node', '../../packvium-rust/bindings/node']) { - try { native = require(candidate); break; } catch { /* not built or blocked */ } - } - if (!native?.validateJson) { - t.skip('no native addon exporting validateJson is loadable here'); - return; - } +for (const [name, native] of validators()) test(`the ${name} validator judges compression by the solvers' ppm rule`, () => { const request = (ratio) => ({ items: [ { id: 'pad', quantity: 1, must_be_on_floor: true, allowed_rotations: ['LWH'],