From 2b9745d604ce8bae684280d5bc61a2eea89f531c Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 4 Oct 2026 14:29:57 -0400 Subject: [PATCH] chore(release): 1.5.0 --- CHANGELOG.md | 38 ++++ CITATION.cff | 25 +++ CONTRIBUTING.md | 9 +- README.md | 161 ++++++++++++++-- artifacts.js | 2 +- codemeta.json | 31 +++ docs/GUARANTEES.md | 20 +- docs/UNITS-AND-NUMERICS.md | 2 +- examples/basic.mjs | 16 +- examples/commerce.mjs | 2 +- examples/constraints.mjs | 58 +++++- examples/errors.mjs | 165 ++++++++++++++++ examples/execution.mjs | 2 +- examples/fixed_placements.mjs | 104 ++++++++++ examples/limits.mjs | 109 +++++++++++ examples/rebalancing.mjs | 63 ++++++ examples/reproducibility.mjs | 119 ++++++++++++ examples/serialization.mjs | 116 +++++++++++ examples/trucking.mjs | 139 ++++++++++++++ fallback.js | 54 ++++-- index.js | 22 +-- package.json | 8 +- request-errors.js | 28 +++ test/access-directions.test.mjs | 37 +++- test/commerce-native-dispatch.test.mjs | 4 +- test/commerce.test.mjs | 3 +- test/fallback.test.mjs | 255 +++++++++++++++++++++++++ test/fixed-placements.test.mjs | 29 ++- test/force-fallback.cjs | 2 +- test/force-fallback.test.mjs | 14 +- test/request-errors.test.mjs | 21 +- test/revisions.test.mjs | 2 +- test/sequence.test.mjs | 13 ++ 33 files changed, 1566 insertions(+), 107 deletions(-) create mode 100644 CITATION.cff create mode 100644 codemeta.json create mode 100644 examples/errors.mjs create mode 100644 examples/fixed_placements.mjs create mode 100644 examples/limits.mjs create mode 100644 examples/rebalancing.mjs create mode 100644 examples/reproducibility.mjs create mode 100644 examples/serialization.mjs create mode 100644 examples/trucking.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index de10395..ce24d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ 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.5.0] + +No API changes. The JavaScript engine's beam search is faster; route packing, +`rebalanceWeight` and the `quality` termination report are fixed, and a misspelt configuration +key is now refused (see *Fixed*). + +### Changed + +- **Beam search 7–9% faster in the JavaScript engine, same results.** Expansions that cannot + beat the best plan so far are no longer copied, and the bound that ranks beam nodes is one + binary search per node. +- **No probe for `@packvium/native`.** The package tried to load that name, which is neither + published nor declared, so a supply-chain scanner reported an unresolved dependency. A + compiled addon is still loaded from `packvium-native.node` beside `index.js`; an + `npm install` runs the JavaScript engine, as it already did. + +### Fixed + +- **`quality` with `minimum_support_ratio` no longer returns unsupported items.** The + homogeneous block solver ran even when the request asked for support, and a block set on a + smaller one overhangs it: the result could hold items below the requested ratio, some + resting on nothing, reported as `best_found`. Such a request is now left to the per-item + search, and every item meets the ratio. Present since the block solver was added. +- **A misspelt `configuration` key is refused.** A key the request schema does not declare in + `configuration` or its `effort_budget` (`profile` for `solver_profile`, `top_k` for + `alternatives`) was silently ignored, so the request ran on defaults. `pack` now throws + `InvalidRequestError` with reason `not_allowed` and the key's pointer, for example + `/configuration/profile`. +- **`rebalanceWeight` no longer strands a supported item.** It could move the item underneath + and leave the one above floating; such a move is now discarded, as documented. +- **`quality` reports `effort_limit` when the effort budget stopped it,** not `time_limit`. +- **Route packing with a `-x` door now packs all five items** in the regression scene (formerly + two), keeps next-stop parcels behind the first stop's pallet, and orients upright pallets to + save length. +- **An unknown `objective` or access direction is refused with its pointer** (`not_allowed`, + e.g. `/configuration/objective`) instead of `invalid_value` with an empty field; refusal + messages match the documentation. + ## [1.4.0] Replanning a job that has already started, and container ids that match the other engines. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..c118e17 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,25 @@ +cff-version: 1.2.0 +message: "If you use Packvium in your research, please cite it as below." +title: "Packvium for Node.js" +type: software +authors: + - name: "Packvium contributors" + website: "https://packvium.com" +version: "1.5.0" +license: MIT +url: "https://packvium.com" +repository-code: "https://github.com/toxakara/packvium-node" +repository-artifact: "https://www.npmjs.com/package/@packvium/engine" +abstract: >- + Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results. +keywords: + - 3d-bin-packing + - bin-packing + - 3d-packing + - cartonization + - container-loading + - pallet-packing + - truck-loading + - packing-algorithm + - logistics + - deterministic diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b031555..92cbc7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,11 +49,10 @@ local and needs no coordination. ## Native backend -This repository ships the pure-JavaScript fallback only. The optional N-API addon comes -from `@packvium/native`, built from a separate Rust workspace this repository does not -carry. It is **not** declared as a dependency, so nothing is installed automatically and -nothing has to resolve; install `@packvium/native` yourself if you want the compiled -backend. When it is not resolvable at `require()` time, +This repository ships the pure-JavaScript fallback only. The optional N-API addon is +built from a separate Rust workspace this repository does not carry, and is loaded only +from `packvium-native.node` beside `index.js`; no npm package is probed, so nothing is +installed automatically and nothing has to resolve. When no addon loads at `require()` time, `backend()` reports `'javascript'` and every call runs the fallback above. Treat the native path as best-effort acceleration, not a dependency to code against. diff --git a/README.md b/README.md index c5cc3c3..fd6f102 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ Deterministic 3D cartonization for Node.js. It uses the optional native engine when available and automatically falls back to the bundled JavaScript implementation. +Use it to pick the smallest carton for an order, build a pallet, load a shipping container, +or load a truck within its axle ratings and delivery-stop order. Every answer comes back as +coordinates and rotations for each item, with a reason for anything that did not fit. + Full documentation, the constraint reference and benchmarks live at [packvium.com](https://packvium.com). @@ -21,8 +25,15 @@ production. import { backend, commerce, pack } from '@packvium/engine'; const result = pack({ + units: { length: 'mm' }, + configuration: { + // Stop by counted work, not by the clock, so the answer is the same on any machine. + // The time limit is only a safety fuse; see "Deterministic results" below. + effort_budget: { max_candidates_evaluated: 1000000 }, + time_limit_ms: 60000, + }, items: [{ - id: 'book', quantity: 4, + id: 'book', quantity: 4, weight: '450 g', dimensions: { length: '210', width: '140', height: '30' }, }], containers: [{ @@ -33,7 +44,7 @@ const result = pack({ console.log(backend()); // "rust" or "javascript" console.log(result.status); // "feasible" -console.log(result.containers); +console.log(result.containers[0].placements.length); // 4 const commerceDocument = { tariffs: [{ carrier_id: 'acme', service_id: 'ground', @@ -50,6 +61,54 @@ const quote = commerce.quote(commerceDocument, { console.log(quote.quote.total_minor); ``` +Lengths and weights are strings on purpose: they are parsed into exact integers, so `'0.1'` +is a tenth of a millimetre and never `0.09999999999999999`. Every measurement in the result +comes back as `{ ticks, value, unit }` -- `ticks` is the exact integer the engine reasoned +with, `value` the same number written for a person. + +## Two backends + +`pack()` uses a compiled native addon when one is built beside `index.js` and loads, +and the bundled JavaScript engine otherwise. The published package ships no binary, so an +`npm install` runs the JavaScript engine. `backend()` says which one answered. + +- **Packing:** both return a valid packing that honours every rule in the request, and each is + deterministic on its own. They are independent searches, so for the same request they may + choose a different arrangement, a different container, or a differently worded reason for an + item left out. Do not compare results across backends byte for byte. +- **Commerce:** the two are held to the same answer: a quote's price is one exact integer + and both compute the same one, and an unanswerable request is rejected by both with the same + code and fields. + +## Deterministic results + +A solve stops when it finishes or when a limit stops it, and which limit matters: + +- `configuration.time_limit_ms` is a wall clock. How far a search gets in it depends on the + machine and its load, so an answer the clock cut short can differ on the next run. The + JavaScript engine uses **1000 ms** when you set nothing. +- `configuration.effort_budget` counts work -- `max_candidates_evaluated`, + `max_placement_attempts`, `max_search_nodes`, `max_restarts` -- and stops at the same point + on every machine, so even a partial answer repeats exactly. + +For anything you store, compare or audit, set an `effort_budget` and a `time_limit_ms` far above +what that work takes, as a fuse rather than a limit. The result says which one stopped it: + +```js +const result = pack({ ...request, configuration: { + effort_budget: { max_candidates_evaluated: 200000 }, + time_limit_ms: 60000, +} }); + +result.termination.code; // "complete", "effort_limit" or "time_limit" +result.algorithm.effort_limit_reached; // true: partial, but a rerun gives the same answer +result.algorithm.time_limit_reached; // true: the clock decided -- do not cache it as the answer +``` + +Items the search never reached are listed in `unpacked_items` with reason `effort_limit` or +`time_limit`, not as items that do not fit. [`examples/reproducibility.mjs`](examples/reproducibility.mjs) +shows both. + ## Errors A request that no engine may answer throws `InvalidRequestError`, a `RangeError`, before anything @@ -69,11 +128,26 @@ try { } ``` -`reason` is one of `missing_field`, `wrong_type`, `below_minimum`, `above_maximum`, `negative_measure`, `invalid_unit`, `duplicate_id`, `not_allowed` or `invalid_value`. `FixedPlacementError` extends it, with code -`invalid_fixed_placement` and reason `malformed` or `cannot_hold`. -The message is the same in every Packvium engine. Branch on `reason` and `field`; show the -message to a person. A request that is valid but does not fit completely is not an error: the -result lists what was left out, and why, in `unpacked_items`. +`reason` is one of `missing_field`, `wrong_type`, `below_minimum`, `above_maximum`, +`negative_measure`, `invalid_unit`, `duplicate_id`, `not_allowed` or `invalid_value`. `field` is +an RFC 6901 JSON Pointer: `~1` stands for `/` and `~0` for `~` inside a key such as a tag name, +and `''` means the request as a whole. The message is `: : `, the same in +every Packvium engine. Branch on `reason` and `field`; show the message to a person. + +- `FixedPlacementError` extends it, with code `invalid_fixed_placement` and reason `malformed` + (`field` points at the bad value) or `cannot_hold` (the fixed items are not a valid packing on + their own; `field` is `/fixed_placements`). One `catch` for `InvalidRequestError` covers both. +- `UnsupportedFeatureError` is separate, with code `unsupported_feature` and a `fields` list: the + request uses a field the schema reserves but this engine does not implement yet, such as + `container.pallet_overhang_limit`. It is refused rather than silently ignored. +- Numbers are judged by value, as the other engines judge them: `2.0` is the integer `2`, `2.5` + and `'2'` are not integers, and an integer above `Number.MAX_SAFE_INTEGER` (2^53 - 1) is + `above_maximum` -- also when it arrives as JSON text, which `JSON.parse` would otherwise round. +- Text that is not JSON makes `packJson()` throw the `SyntaxError` from `JSON.parse`. + +A request that is valid but does not fit completely is not an error: the result lists what was +left out, and why, in `unpacked_items`. [`examples/errors.mjs`](examples/errors.mjs) walks every +case above. ## Quotes, policy and catalog versions @@ -115,25 +189,32 @@ Two kinds of failure, and they are not interchangeable: from a closed set and structured fields naming what was missing. `commerce.backend()` reports whether the native addon or the JavaScript implementation -answered; both return the same result for the same input. A runnable walk-through of all -three functions is in [examples/commerce.mjs](examples/commerce.mjs), and the full -contract — document format, every result shape, all ten rejection codes, complexity and -limitations — is `docs/COMMERCE-API.md`. +answered; both give the same price and the same rejection for the same input. A runnable +walk-through of all three functions is in [examples/commerce.mjs](examples/commerce.mjs), and +the full contract — document format, every result shape, all ten rejection codes, complexity +and limitations — is [COMMERCE-API.md](https://github.com/toxakara/packvium-node/blob/main/docs/COMMERCE-API.md). ## Examples Runnable, in [`examples/`](examples). Each one is a single file you can read top to bottom -and execute without a project around it. +and execute without a project around it, and each prints what it teaches. | File | What it shows | | --- | --- | | [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. | -| [`objectives.mjs`](examples/objectives.mjs) | All six objectives on scenes where they genuinely disagree — the same scores the Python, PHP and Rust engines print for the same request. | +| [`objectives.mjs`](examples/objectives.mjs) | All six objectives on scenes where they genuinely disagree about which container to open. | +| [`constraints.mjs`](examples/constraints.mjs) | Rotations and `keep_upright`, support ratio, top load, stacking caps, incompatible tags and atomic groups — each with and without the rule — and `explainUnpackedItem` turning a refusal into a sentence. | +| [`limits.mjs`](examples/limits.mjs) | Wheel arches as `obstacles` with `additional_boxes`, `max_items` and `tag_limits` per container, and what happens when container stock or `max_containers` runs out. | +| [`trucking.mjs`](examples/trucking.mjs) | A multi-drop van: `stop_index` and a rear door in `access_directions`, two `axles` with load limits, and a safe loading order fed into `buildExecutionPlan`. | | [`shapes.mjs`](examples/shapes.mjs) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, and a cushion that compresses under load until the crush limit refuses it. | -| [`constraints.mjs`](examples/constraints.mjs) | Stacking caps, incompatible tags and atomic groups — each shown with and without the rule, plus how to read the structured refusal. | | [`units.mjs`](examples/units.mjs) | Why lengths travel as strings: fractional inches kept exact, one tick deciding a fit, and the point where a JavaScript number stops being exact and a quote is refused rather than rounded. | +| [`serialization.mjs`](examples/serialization.mjs) | `packJson` with JSON text in and out, runners-up through `alternatives`, and exactly which mistakes are refused and which are ignored. | +| [`reproducibility.mjs`](examples/reproducibility.mjs) | `effort_budget` against `time_limit_ms`: a budget that never binds, one that cuts the search short and still repeats exactly, and how to tell from the result which one stopped it. | +| [`errors.mjs`](examples/errors.mjs) | `InvalidRequestError`'s `code`, `reason`, `field` and message for each kind of bad request, JavaScript's integer range, `FixedPlacementError` and `UnsupportedFeatureError` — and a valid request that simply does not fit. | +| [`fixed_placements.mjs`](examples/fixed_placements.mjs) | Pack around items already loaded, lock half of an earlier plan by quoting its placements, and a fixed set that cannot hold. | +| [`rebalancing.mjs`](examples/rebalancing.mjs) | `rebalanceWeight` evening out payload between packed crates, the moves it made, and `maxMoves`. | | [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. | -| [`execution.mjs`](examples/execution.mjs) | Turn a result into dock instructions — and the clearest place to see what "byte-identical" does and does not promise: the four adapters agree on any given result, while this engine is free to reach a different packing than Python does. | +| [`execution.mjs`](examples/execution.mjs) | Turn a result into dock instructions, and what "byte-identical" does and does not promise: the four adapters agree on any given result, while this engine is free to reach a different packing than Python does. | | [`artifacts.mjs`](examples/artifacts.mjs) | Hand a result to a system with no engine: one document with the plan, geometry and the request that produced it, exported as CSV and a printable HTML work order, and a refusal for a format it does not know. | | [`revisions.mjs`](examples/revisions.mjs) | Replan a half-loaded job: a missing item and a locked placement recorded against the approved plan, a replan that keeps the locked item in place, and a hash-chained record that notices an edit. | @@ -141,6 +222,23 @@ and execute without a project around it. node examples/basic.mjs ``` +## Subpath modules + +The main entry point has packing, sequencing, explanations, rebalancing and `commerce`. Four +more modules are separate imports, so an application that only packs never loads them: + +```js +import { buildExecutionPlan, canonicalPlanJson } from '@packvium/engine/execution.js'; +import { buildOperationalArtifact, canonicalArtifactJson } from '@packvium/engine/artifacts.js'; +import { exportCsv, exportJson, exportWorkOrderHtml } from '@packvium/engine/artifact-exports.js'; +import { deriveRevision, rootRevision, verifyRevisionChain } from '@packvium/engine/revisions.js'; +``` + +`execution.js` turns a result into numbered work-order steps. It never computes a loading order +itself: pass one from `safeLoadingOrder()` as `loadingOrders`, or the plan says its order is +`unavailable` rather than guessing. `artifacts.js` and `artifact-exports.js` package a result for a +system with no engine; `revisions.js` records changes against an approved plan. + ## Features - Exact, deterministic placement with no floating-point geometry decisions. @@ -166,7 +264,9 @@ The native addon is optional. `npm install` works on unsupported platforms too; ## The Packvium family One request and result contract, implemented independently in four engines (Rust, -Python, PHP, JavaScript) and held to identical placements on a shared fixture set. +Python, PHP, JavaScript) and checked against each other on a shared fixture set: Python and +PHP to identical placements, Rust and JavaScript to a valid packing that scores no worse +than a per-fixture floor. Pick the package for your stack; mixing them in one system is safe. Documentation, the constraint reference and the benchmarks are at @@ -185,8 +285,33 @@ Documentation, the constraint reference and the benchmarks are at ## API and support TypeScript declarations are included. See the package's `index.d.ts` for the complete -request and result types, and `docs/COMMERCE-API.md` for the commercial/control-plane -contract. Report security issues through [SECURITY.md](SECURITY.md). +request and result types. The documentation is in the source repository, not in the npm +package: + +- [GUARANTEES.md](https://github.com/toxakara/packvium-node/blob/main/docs/GUARANTEES.md) — what is promised and what is not; +- [PUBLIC-API.md](https://github.com/toxakara/packvium-node/blob/main/docs/PUBLIC-API.md) — every request field, result shape and status; +- [UNITS-AND-NUMERICS.md](https://github.com/toxakara/packvium-node/blob/main/docs/UNITS-AND-NUMERICS.md) — units, accepted input forms, exact + arithmetic and the 2^53 ceiling; +- [COMMERCE-API.md](https://github.com/toxakara/packvium-node/blob/main/docs/COMMERCE-API.md) — the commercial/control-plane contract. + +Report security issues through [SECURITY.md](SECURITY.md). + +## Citation + +If Packvium supports your research, cite it as software. GitHub's **Cite this repository** +button reads [`CITATION.cff`](https://github.com/toxakara/packvium-node/blob/main/CITATION.cff), and +[`codemeta.json`](https://github.com/toxakara/packvium-node/blob/main/codemeta.json) carries the same record in +CodeMeta form. + +```bibtex +@software{packvium_node, + author = {{Packvium contributors}}, + title = {Packvium for Node.js}, + version = {1.5.0}, + license = {MIT}, + url = {https://packvium.com} +} +``` ## License diff --git a/artifacts.js b/artifacts.js index da44560..163abaf 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.4.0'; +export const SUITE_VERSION = '1.5.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 new file mode 100644 index 0000000..6e47b4f --- /dev/null +++ b/codemeta.json @@ -0,0 +1,31 @@ +{ + "@context": "https://w3id.org/codemeta/3.0", + "@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.5.0", + "license": "https://spdx.org/licenses/MIT", + "author": [ + { + "@type": "Organization", + "name": "Packvium contributors", + "url": "https://packvium.com" + } + ], + "url": "https://packvium.com", + "codeRepository": "https://github.com/toxakara/packvium-node", + "issueTracker": "https://github.com/toxakara/packvium-node/issues", + "programmingLanguage": "JavaScript", + "keywords": [ + "3d-bin-packing", + "bin-packing", + "3d-packing", + "cartonization", + "container-loading", + "pallet-packing", + "truck-loading", + "packing-algorithm", + "logistics", + "deterministic" + ] +} diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 11d9696..6f2b855 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -15,7 +15,9 @@ After a result reports `feasible` or `optimal` and passes independent validation 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 and in any of the implementations; + any platform, when the search completes or stops on `configuration.effort_budget`. 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 placed or listed as unpacked with a reason code. @@ -31,9 +33,10 @@ After a result reports `feasible` or `optimal` and passes independent validation - **That your inputs are right.** Dimensions, weights and clearances are taken as given. Round item exteriors outward, container interiors inward, and clearance outward before you build the request. -- **Support for non-rectangular shapes.** Items and containers are rigid axis-aligned - cuboids; obstacles are rectangular. No cylinders, deformable bags, meshes or - irregular geometry. +- **Arbitrary shapes.** Containers are axis-aligned cuboids. An item is a cuboid, a convex + hull of vertices, or a cuboid that compresses by a declared ratio; obstacles are cuboids + or unions of them. Concave meshes, curved surfaces and free-form deformable bags are not + modelled — approximate them with a convex hull or a bounding cuboid. - **A result before the deadline.** With a time limit set, the search may return `time_limit` with a partial packing, or nothing placed at all. - **Full load propagation.** Top-load limits are enforced against what rests *directly* @@ -41,14 +44,15 @@ After a result reports `feasible` or `optimal` and passes independent validation ## Not modelled at all -Deformable goods, dynamic shipping physics, contact-load propagation through arbitrary -support graphs, pallet-overhang standards, axle loads, unloading-route optimization, and -carrier tariff or rating catalogs. None of these are built in, and none are approximated +Dynamic shipping physics, contact-load propagation through arbitrary support graphs, +pallet-overhang standards, and route optimization. Axle loads, the unloading order of a +given stop sequence (`stop_index`, `access_directions`) and carrier rate tables are +modelled; choosing the route itself is not. None of the unmodelled items is approximated silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.4.0` freezes the public API. Field names, status codes, the objective +Version `1.5.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/UNITS-AND-NUMERICS.md b/docs/UNITS-AND-NUMERICS.md index 1badad2..272c4ee 100644 --- a/docs/UNITS-AND-NUMERICS.md +++ b/docs/UNITS-AND-NUMERICS.md @@ -8,7 +8,7 @@ One internal length tick is `1/16000 mm`. - `1 international inch = 25.4 mm = 406,400 ticks` - common fractional inches through at least `1/128 in` are exact integers -Accepted inputs include integers, decimal strings, fractions (`3/16`) and mixed fractions (`12 3/8`), with `mm`, `cm`, `m`, `in`, `ft`, and ticks. Binary floating-point input is intentionally rejected by the Python API; PHP type declarations accept only integer/string/structured values. +Accepted inputs include integers, decimal strings, fractions (`3/16`) and mixed fractions (`12 3/8`), with `mm`, `cm`, `m`, `in`, `ft`, and ticks. A binary float with a fractional part is intentionally rejected by the Python API, by `Length` and `Weight` alike (`TypeError`); an integral float such as `100.0` is exact, and is what JavaScript sends for `100`, so it is accepted as that integer. PHP type declarations accept only integer/string/structured values. ## Weight diff --git a/examples/basic.mjs b/examples/basic.mjs index 0e0b634..f867343 100644 --- a/examples/basic.mjs +++ b/examples/basic.mjs @@ -7,11 +7,13 @@ * * `@packvium/engine` takes and returns the same JSON contract every Packvium * implementation speaks, so a request you build here also works against the Python CLI, - * the PHP CLI or the Rust core, and comes back with the same answer. + * the PHP CLI or the Rust core. * - * The package prefers the compiled N-API addon when `@packvium/native` is installed and + * The package prefers a compiled N-API addon when one is built beside `index.js` and * falls back to a deterministic JavaScript engine otherwise. You do not choose, and you - * do not need to: `backend()` reports which one answered, and both answer the same. + * do not need to: `backend()` reports which one answered. Both return a valid packing + * that honours every rule in the request, but they are independent searches and may + * arrive at different arrangements for the same order. */ import { backend, pack, version } from '../index.js'; @@ -19,10 +21,10 @@ import { backend, pack, version } from '../index.js'; console.log(`engine ${version()} using the ${backend()} backend\n`); const request = { - // An example must not change answer merely because the host was busy. This solve needs - // a fraction of the budget; the generous wall-clock value is only a safety fuse, so a - // loaded machine cannot cut the multi-start portfolio short and let a different start win. - configuration: { time_limit_ms: 60000 }, + // An example must not change answer merely because the host was busy. `effort_budget` + // bounds the search by counted work, the same on every machine; the generous wall-clock + // value is only a safety fuse. See examples/reproducibility.mjs. + configuration: { time_limit_ms: 60000, effort_budget: { max_candidates_evaluated: 1000000 } }, items: [ // Lengths and weights are strings on purpose. They are parsed into exact integers, // so '0.1' means a tenth of a millimetre and never 0.09999999999999999. Plain diff --git a/examples/commerce.mjs b/examples/commerce.mjs index 3aaa73c..e3cce52 100644 --- a/examples/commerce.mjs +++ b/examples/commerce.mjs @@ -11,7 +11,7 @@ * starting at 1 — so `tariff_version: 2` always means "the second entry under this * carrier and service", with no separate numbering to keep in sync. * - * `commerce` picks the native addon when @packvium/native is installed and the + * `commerce` picks the native addon when one is built beside `index.js` and the * deterministic JavaScript engine otherwise. Both return the same answer; * `commerce.backend()` says which one answered. */ diff --git a/examples/constraints.mjs b/examples/constraints.mjs index 8fd223d..1720473 100644 --- a/examples/constraints.mjs +++ b/examples/constraints.mjs @@ -15,13 +15,16 @@ * engines. */ -import { pack } from '../index.js'; +import { explainUnpackedItem, pack } from '../index.js'; const MM = { units: { length: 'mm' } }; -// An example must not change answer merely because the host was busy. These solves need -// a fraction of the budget; the generous wall-clock value is only a safety fuse, so a -// loaded machine cannot cut the multi-start portfolio short and let a different start win. -const SAFETY_FUSE = { configuration: { time_limit_ms: 60000 } }; +// An example must not change answer merely because the host was busy. `effort_budget` +// bounds the search by counted work, which is the same on every machine; the generous +// wall-clock value is only a safety fuse, so a loaded machine cannot cut the multi-start +// portfolio short and let a different start win. See examples/reproducibility.mjs. +const SAFETY_FUSE = { + configuration: { time_limit_ms: 60000, effort_budget: { max_candidates_evaluated: 1000000 } }, +}; /** * Pack one variant and print what it cost. @@ -37,8 +40,11 @@ const solve = (label, items, containers) => { ` ${label.padEnd(20)} ${result.containers.length} container(s), ` + `${placed} placed, ${result.unpacked_items.length} refused`, ); + // `reason` is the stable code to branch on; `explainUnpackedItem` renders the same + // structured reason and its proof as a sentence a person can read. for (const unpacked of result.unpacked_items) { console.log(` ${unpacked.item_id.padEnd(12)} ${unpacked.reason}`); + console.log(` ${' '.repeat(12)} ${explainUnpackedItem(unpacked)}`); } }; @@ -62,6 +68,43 @@ solve('ladder + books', // with it. A constraint you cannot watch change the answer is one the reader has to take // on faith, and the pair makes the rule — rather than the geometry — provably the cause. +// `allowed_rotations` narrows the six orientations to the ones you permit, and +// `keep_upright` is the shorthand for "the item's own height stays vertical". The pole is +// 700 mm tall and the shelf 500 mm high, so it fits only lying down -- which both forbid. +// The refusal is `proven`: no permitted orientation fits any container on offer. +const pole = { length: '90', width: '90', height: '700' }; +console.log('\nallowed_rotations / keep_upright — a pole that only fits lying down'); +solve('without', [{ id: 'pole', quantity: 1, dimensions: pole, weight: '1 kg' }], shelf); +solve('allowed_rotations', [{ id: 'pole', quantity: 1, dimensions: pole, weight: '1 kg', allowed_rotations: ['LWH', 'WLH'] }], shelf); +solve('keep_upright', [{ id: 'pole', quantity: 1, dimensions: pole, weight: '1 kg', keep_upright: true }], shelf); + +// `minimum_support_ratio` is how much of an item's base must rest on something solid. +// The plinth has to stand on the floor and covers only part of the ledge, so the slab's +// one place in the first ledge is perched on it. At 0.9 that is refused, and the price +// is a second ledge rather than a refusal. +const ledge = [{ id: 'ledge', inner_dimensions: { length: '400', width: '400', height: '350' } }]; +const plinth = { id: 'plinth', quantity: 1, dimensions: { length: '200', width: '200', height: '300' }, + weight: { value: '5', unit: 'kg' }, must_be_on_floor: true }; +const slab = (rule) => ({ id: 'slab', quantity: 1, dimensions: { length: '400', width: '400', height: '60' }, + weight: { value: '9', unit: 'kg' }, ...rule }); + +console.log('\nminimum_support_ratio — a slab perched on part of its base'); +solve('without', [plinth, slab({})], ledge); +solve('with', [plinth, slab({ minimum_support_ratio: 0.9 })], ledge); + +// `max_top_load` caps the weight resting on an item -- everything above it, not only the +// box touching it. Two 5 kg sacks on a crate of eggs is 10 kg; a 6 kg limit lets one stay +// and sends the other to a second column. +const narrow = [{ id: 'column', inner_dimensions: { length: '310', width: '310', height: '600' } }]; +const eggs = (rule) => ({ id: 'egg-crate', quantity: 1, dimensions: { length: '300', width: '300', height: '200' }, + weight: { value: '1', unit: 'kg' }, must_be_on_floor: true, ...rule }); +const sack = { id: 'rice-sack', quantity: 2, dimensions: { length: '300', width: '300', height: '150' }, + weight: { value: '5', unit: 'kg' } }; + +console.log('\nmax_top_load — what may rest on a crate of eggs'); +solve('without', [eggs({}), sack], narrow); +solve('with', [eggs({ max_top_load: { value: '6', unit: 'kg' } }), sack], narrow); + const tin = { length: '150', width: '150', height: '120' }; const column = [{ id: 'column', inner_dimensions: { length: '160', width: '160', height: '600' } }]; @@ -97,5 +140,6 @@ solve('without', kit({}), shelf); solve('with', kit({ group: 'assembly' }), shelf); // Every reason code above is structured, not prose: `reason` is a stable identifier and -// `proof` carries the observations behind it. Render your own wording from the code — -// the strings here are the contract's, not a message meant for your customer. +// `proof` carries the observations behind it. `explainUnpackedItem` turns both into the +// sentence printed under each code, and `explanationForUnpackedItem` returns the same +// sentence as a message key plus arguments, for when you render your own wording. diff --git a/examples/errors.mjs b/examples/errors.mjs new file mode 100644 index 0000000..c5c5ef2 --- /dev/null +++ b/examples/errors.mjs @@ -0,0 +1,165 @@ +/** + * Errors: a request that is wrong, and how to tell it from one that simply does not fit. + * + * Run it: + * + * node examples/errors.mjs + * + * Two kinds of "no" come back from `pack`, and they must be handled differently: + * + * - **The request is wrong.** A value is missing, mistyped, out of range or in an unknown + * unit. Nothing is solved. `pack` throws `InvalidRequestError`, a `RangeError`, naming the + * value and the rule it broke. Retrying the same request gives the same error. + * - **The request is fine, but not everything fits.** That is an answer, not an error: the + * result lists each item left out in `unpacked_items`, with a reason. + * + * The error's `code`, `reason`, `field` and message are the same in every Packvium engine, + * and the same whichever backend (native or JavaScript) answered here. + */ + +import { + FixedPlacementError, + InvalidRequestError, + UnsupportedFeatureError, + pack, + packJson, +} from '../index.js'; + +/** A valid request; each case below breaks one thing in a fresh copy of it. */ +const valid = () => ({ + units: { length: 'mm' }, + configuration: { + // Counted work decides where the search stops; the wall clock is only a safety fuse. + effort_budget: { max_candidates_evaluated: 1000000 }, + time_limit_ms: 60000, + }, + items: [ + { id: 'mug', quantity: 2, weight: '400 g', dimensions: { length: '120', width: '120', height: '100' } }, + ], + containers: [ + { id: 'box', inner_dimensions: { length: '400', width: '400', height: '400' } }, + ], +}); + +const rule = '='.repeat(78); +const section = (title) => console.log(`\n${rule}\n${title}\n${rule}`); + +// ------------------------------------------------------------------------------------ +section('1. One error type, four fields to read'); + +const cases = [ + ['quantity of zero', (r) => { r.items[0].quantity = 0; }], + ['a string where a count belongs', (r) => { r.items[0].quantity = '2'; }], + ['a missing dimension', (r) => { delete r.items[0].dimensions.height; }], + ['a negative width', (r) => { r.containers[0].inner_dimensions.width = '-400'; }], + ['an unknown unit', (r) => { r.units.length = 'furlong'; }], + ['the same container id twice', (r) => { r.containers.push({ ...r.containers[0] }); }], + ['a profile that does not exist', (r) => { r.configuration.solver_profile = 'thorough'; }], + ['a support ratio above 1', (r) => { r.items[0].minimum_support_ratio = 1.5; }], +]; + +for (const [label, breakIt] of cases) { + const request = valid(); + breakIt(request); + try { + pack(request); + console.log(`${label}: accepted`); + } catch (error) { + if (!(error instanceof InvalidRequestError)) throw error; + console.log(`\n${label}`); + console.log(` reason ${error.reason}`); + console.log(` field ${error.field}`); + console.log(` detail ${error.detail}`); + console.log(` message ${error.message}`); + } +} + +console.log('\n Branch on `reason` (a closed set) and `field` (a JSON Pointer into the request'); +console.log(' you sent); show the message to a person. `error.code` is `invalid_request`, and'); +console.log(' each is also a RangeError, so code that already catches RangeError still works.'); + +// ------------------------------------------------------------------------------------ +section('2. JavaScript numbers: what is and is not an integer here'); + +// JavaScript has one number type, so an integer is judged by value, exactly as the other +// engines judge it: 2.0 is 2, while 2.5 is not an integer at all. +const counted = (quantity) => { + const request = valid(); + request.items[0].quantity = quantity; + try { + return `packed ${pack(request).summary.packed_item_count}`; + } catch (error) { + if (!(error instanceof InvalidRequestError)) throw error; + return `${error.reason}: ${error.detail}`; + } +}; +console.log(` quantity 2.0 -> ${counted(2.0)}`); +console.log(` quantity 2.5 -> ${counted(2.5)}`); + +// Past 2^53 - 1 a Number stops being exact, so every engine refuses a count beyond it +// rather than one engine answering for a value another cannot even represent. It holds +// for JSON text too: JSON.parse would silently round 9007199254740993 down to ...992. +console.log(` quantity 2 ** 53 -> ${counted(2 ** 53)}`); +try { + packJson('{"items":[{"id":"a","quantity":9007199254740993,' + + '"dimensions":{"length":"1","width":"1","height":"1"}}],' + + '"containers":[{"id":"b","inner_dimensions":{"length":"1","width":"1","height":"1"}}]}'); +} catch (error) { + if (!(error instanceof InvalidRequestError)) throw error; + console.log(` same, as JSON text -> ${error.reason}: ${error.detail}`); +} + +// A key you choose yourself -- a tag, say -- can contain '/', which a JSON Pointer +// writes as '~1'. Unescape it before looking the key up. +const tagged = valid(); +tagged.containers[0].tag_limits = { 'cold/chain': 0 }; +try { + pack(tagged); +} catch (error) { + if (!(error instanceof InvalidRequestError)) throw error; + const key = error.field.split('/').pop().replaceAll('~1', '/').replaceAll('~0', '~'); + console.log(`\n pointer ${error.field} names the tag '${key}'`); +} + +// ------------------------------------------------------------------------------------ +section('3. Fixed placements that cannot hold, and fields not implemented yet'); + +// `FixedPlacementError` is an `InvalidRequestError` with its own code, so one catch +// covers both. `malformed` points at the bad value; `cannot_hold` means the fixed items +// are not a valid packing on their own -- here, two mugs in the same place. +const fixed = valid(); +fixed.fixed_placements = [ + { item_type: 'mug', container_type: 'box', position: { x: '0', y: '0', z: '0' }, orientation: 'LWH' }, + { item_type: 'mug', container_type: 'box', position: { x: '50', y: '0', z: '0' }, orientation: 'LWH' }, +]; +try { + pack(fixed); +} catch (error) { + if (!(error instanceof FixedPlacementError)) throw error; + console.log(` ${error.code} / ${error.reason} at ${error.field}`); + console.log(` ${error.message}`); +} + +// A field the schema reserves but no engine implements yet is refused by name rather +// than silently ignored. It is a different error type, with its own code. +const ahead = valid(); +ahead.containers[0].pallet_overhang_limit = { length: '50', width: '50' }; +try { + pack(ahead); +} catch (error) { + if (!(error instanceof UnsupportedFeatureError)) throw error; + console.log(`\n ${error.code}: ${error.fields.join(', ')}`); +} + +// ------------------------------------------------------------------------------------ +section('4. Not an error: a valid request that does not fit completely'); + +const tooBig = valid(); +tooBig.items.push({ id: 'ladder', quantity: 1, weight: '6 kg', + dimensions: { length: '1800', width: '300', height: '100' } }); +const result = pack(tooBig); +console.log(` status ${result.status}, ${result.summary.packed_item_count} packed`); +for (const unpacked of result.unpacked_items) { + console.log(` ${unpacked.item_id}: ${unpacked.reason} (${unpacked.proof.level})`); +} +console.log('\n Nothing was thrown. What was left out, and why, is part of the answer.'); diff --git a/examples/execution.mjs b/examples/execution.mjs index e955563..d46801e 100644 --- a/examples/execution.mjs +++ b/examples/execution.mjs @@ -30,7 +30,7 @@ const request = { units: { length: 'mm' }, configuration: { objective: 'default', - profile: 'balanced', + solver_profile: 'balanced', seed: 42, // A safety fuse, not a target -- nothing in this scene comes close to it. time_limit_ms: 60000, diff --git a/examples/fixed_placements.mjs b/examples/fixed_placements.mjs new file mode 100644 index 0000000..0158594 --- /dev/null +++ b/examples/fixed_placements.mjs @@ -0,0 +1,104 @@ +/** + * Fixed placements: pack around what is already loaded. + * + * Run it: + * + * node examples/fixed_placements.mjs + * + * Loading rarely starts from an empty van. A fridge is already strapped in against the + * door, or an operator has locked a pallet where it stands, and the next solve must work + * around it rather than move it. + * + * `fixed_placements` says so. Each entry names an item type, a container type and + * instance, a position and an orientation -- the same fields a result placement reports, + * so a placement from an earlier result can be fixed by quoting it. Fixed items are real: + * they carry weight, count against payload and can support what is stacked on them. They + * come back unmoved, marked `"fixed": true`. + * + * examples/revisions.mjs builds a full audit trail on top of this; here it is the field + * on its own. + */ + +import { FixedPlacementError, pack } from '../index.js'; + +const configuration = { + // Counted work decides where the search stops; the wall clock is only a safety fuse. + effort_budget: { max_candidates_evaluated: 1000000 }, + time_limit_ms: 60000, +}; +const van = { id: 'van', quantity: 1, max_payload: '400 kg', + inner_dimensions: { length: '2400', width: '1400', height: '1900' } }; +const fridge = { id: 'fridge', quantity: 1, weight: '80 kg', keep_upright: true, + dimensions: { length: '700', width: '700', height: '1800' } }; +const box = { id: 'box', quantity: 6, weight: '12 kg', + dimensions: { length: '500', width: '400', height: '400' } }; + +const print = (result) => { + console.log(` status ${result.status}`); + for (const container of result.containers) { + for (const placement of container.placements) { + const { x, y, z } = placement.position; + console.log(` ${placement.item_id.padEnd(11)} at (${x.value}, ${y.value}, ${z.value}) mm` + + `${placement.fixed ? ' fixed' : ''}`); + } + } +}; + +const rule = '='.repeat(78); +const section = (title) => console.log(`\n${rule}\n${title}\n${rule}`); + +// ------------------------------------------------------------------------------------ +section('1. The fridge is already in, in the far corner'); + +// `container_instance` counts from 1: this is the first van, reported as `van#1`. +const strappedIn = { + item_type: 'fridge', container_type: 'van', container_instance: 1, + position: { x: '1700', y: '700', z: '0' }, orientation: 'LWH', +}; +print(pack({ units: { length: 'mm' }, configuration, items: [fridge, box], containers: [van], + fixed_placements: [strappedIn] })); +console.log('\n The six boxes are placed around it. The fridge is exactly where it was put,'); +console.log(' and its 80 kg counts against the van\'s payload like any other item.'); + +// ------------------------------------------------------------------------------------ +section('2. Lock half of an earlier plan, then add a late order'); + +// Solve once, as a morning plan. +const morning = pack({ units: { length: 'mm' }, configuration, items: [fridge, box], containers: [van] }); +const loaded = morning.containers[0].placements.slice(0, 4); +console.log(` morning plan: ${morning.summary.packed_item_count} placed; ` + + `${loaded.length} are on the van when a late order arrives`); + +// A result placement already has every field a fixed placement needs. Quote them. +const locked = loaded.map((placement) => ({ + item_type: placement.item_type, + container_type: 'van', + container_instance: 1, + position: { + x: { value: placement.position.x.value, unit: placement.position.x.unit }, + y: { value: placement.position.y.value, unit: placement.position.y.unit }, + z: { value: placement.position.z.value, unit: placement.position.z.unit }, + }, + orientation: placement.orientation, +})); +const lateOrder = { id: 'late-box', quantity: 3, weight: '8 kg', + dimensions: { length: '400', width: '400', height: '300' } }; +print(pack({ units: { length: 'mm' }, configuration, items: [fridge, box, lateOrder], containers: [van], + fixed_placements: locked })); +console.log('\n The four loaded items keep their places; everything else, including the late'); +console.log(' order, is planned around them.'); + +// ------------------------------------------------------------------------------------ +section('3. A fixed set that cannot hold is refused before any search'); + +// A box claimed to stand where the fridge already is. The request is wrong, not the +// search, so this is an error rather than an item left out. +try { + pack({ units: { length: 'mm' }, configuration, items: [fridge, box], containers: [van], + fixed_placements: [strappedIn, { item_type: 'box', container_type: 'van', + position: { x: '1500', y: '700', z: '0' }, orientation: 'LWH' }] }); +} catch (error) { + if (!(error instanceof FixedPlacementError)) throw error; + console.log(` ${error.reason} at ${error.field}`); + console.log(` ${error.message}`); +} diff --git a/examples/limits.mjs b/examples/limits.mjs new file mode 100644 index 0000000..69ed1c1 --- /dev/null +++ b/examples/limits.mjs @@ -0,0 +1,109 @@ +/** + * Limits: space you cannot use, and counts you may not exceed. + * + * Run it: + * + * node examples/limits.mjs + * + * Not every limit is a dimension or a weight. A van has wheel arches, a tote conveyor + * rejects a tote with too many pieces in it, a carrier caps lithium batteries per + * package, and the shelf holds only so many cartons. Each is a field in the request: + * + * - `obstacles` on a container, with `additional_boxes` for a shape made of several boxes; + * - `max_items` on a container: pieces per container, whatever their size; + * - `tag_limits` on a container: pieces carrying a given tag, per container; + * - `quantity` on a container, and `configuration.max_containers`: how many may be opened. + * + * The first three change *where* things go. The last two decide what happens when there + * is nowhere left, and there the answer is an item left out, never an error. + */ + +import { pack } from '../index.js'; + +const solve = (items, containers, configuration = {}) => pack({ + units: { length: 'mm' }, + configuration: { + // Counted work decides where the search stops; the wall clock is only a safety fuse. + effort_budget: { max_candidates_evaluated: 1000000 }, + time_limit_ms: 60000, + ...configuration, + }, + items, + containers, +}); + +const summarize = (label, result) => { + const counts = result.containers.map((container) => container.placements.length); + console.log(` ${label.padEnd(26)} ${result.containers.length} container(s) holding ` + + `[${counts.join(', ')}], ${result.unpacked_items.length} left out`); + for (const unpacked of result.unpacked_items) { + console.log(` ${unpacked.item_id.padEnd(11)} ${unpacked.reason}`); + } +}; + +const rule = '='.repeat(78); +const section = (title) => console.log(`\n${rule}\n${title}\n${rule}`); + +// ------------------------------------------------------------------------------------ +section('1. Obstacles: wheel arches in the load space'); + +// Two arches, 400 long, 300 wide and 300 high, one against each side wall. They are one +// obstacle made of two boxes: `origin` and `dimensions` give the first, and each entry in +// `additional_boxes` adds another. Nothing may overlap any of them. +const arches = { + id: 'wheel-arches', + origin: { x: '800', y: '0', z: '0' }, + dimensions: { length: '400', width: '300', height: '300' }, + additional_boxes: [ + { origin: { x: '800', y: '900', z: '0' }, dimensions: { length: '400', width: '300', height: '300' } }, + ], +}; +const van = { id: 'van', quantity: 1, inner_dimensions: { length: '1600', width: '1200', height: '800' } }; +const crates = [{ id: 'crate', quantity: 12, weight: '10 kg', + dimensions: { length: '400', width: '400', height: '400' } }]; + +for (const [label, container] of [['flat floor', van], ['with wheel arches', { ...van, obstacles: [arches] }]]) { + const result = solve(crates, [container]); + const floor = result.containers[0].placements.filter((placement) => placement.position.z.ticks === 0); + console.log(` ${label.padEnd(18)} ${result.summary.packed_item_count} crates, ` + + `${floor.length} of them on the floor`); + for (const placement of floor) { + const { x, y } = placement.position; + console.log(` at x = ${x.value.padStart(4)}, y = ${y.value.padStart(4)} mm`); + } +} +console.log('\n The arches take two floor positions. One crate squeezes between them at'); +console.log(' y = 300, and the other two go on top: all twelve still ship.'); + +// ------------------------------------------------------------------------------------ +section('2. Counts per container: max_items and tag_limits'); + +const tote = { id: 'tote', cost_minor: 100, inner_dimensions: { length: '600', width: '400', height: '400' } }; +const jars = [{ id: 'jar', quantity: 6, weight: '500 g', + dimensions: { length: '100', width: '100', height: '150' } }]; +summarize('six jars, no count limit', solve(jars, [tote])); +summarize('six jars, max_items: 4', solve(jars, [{ ...tote, max_items: 4 }])); + +// A tag limit counts only the pieces carrying that tag; the chargers ride along freely. +const batteries = [ + { id: 'battery', quantity: 5, weight: '900 g', tags: ['lithium'], + dimensions: { length: '150', width: '100', height: '80' } }, + { id: 'charger', quantity: 2, weight: '200 g', + dimensions: { length: '100', width: '100', height: '50' } }, +]; +summarize('lithium: 2 per tote', solve(batteries, [{ ...tote, tag_limits: { lithium: 2 } }])); + +// ------------------------------------------------------------------------------------ +section('3. Running out of containers'); + +const lithiumOnly = [batteries[0]]; +const limited = { ...tote, tag_limits: { lithium: 2 } }; +summarize('unlimited totes', solve(lithiumOnly, [limited])); +summarize('only 2 totes in stock', solve(lithiumOnly, [{ ...limited, quantity: 2 }])); +summarize('max_containers: 2', solve(lithiumOnly, [limited], { max_containers: 2 })); + +console.log('\n `quantity` is stock of one container type; `max_containers` caps the whole'); +console.log(' answer across every type. Either way one battery is left out, the result says'); +console.log(' so, and nothing is thrown: a limit reached is an answer, not a bad request.'); +console.log(' Which instance stays behind, and the reason code it is given, is the search\'s'); +console.log(' own account and can differ between engines; that one is left out does not.'); diff --git a/examples/rebalancing.mjs b/examples/rebalancing.mjs new file mode 100644 index 0000000..a33ca47 --- /dev/null +++ b/examples/rebalancing.mjs @@ -0,0 +1,63 @@ +/** + * Rebalancing: even out the weight between containers that are already packed. + * + * Run it: + * + * node examples/rebalancing.mjs + * + * `pack` fills containers to use as few as it can, and nothing in that goal cares whether + * one crate weighs 41 kg and the other 1 kg. A person lifting them does, and so does a + * pallet or a trailer with a weight limit per position. + * + * `rebalanceWeight(request, result)` is a separate, opt-in step. It moves whole items + * between the containers of a finished result to narrow the spread of payload weights, + * and it never runs unless you call it. Each move is checked against every rule the + * request states before it is kept, so the rebalanced result is still a valid packing. + */ + +import { pack, rebalanceWeight } from '../index.js'; + +const request = { + units: { length: 'mm' }, + configuration: { + // Counted work decides where the search stops; the wall clock is only a safety fuse. + effort_budget: { max_candidates_evaluated: 1000000 }, + time_limit_ms: 60000, + }, + items: [ + { id: 'anvil', quantity: 2, weight: '20 kg', dimensions: { length: '300', width: '300', height: '300' } }, + { id: 'pillow', quantity: 4, weight: '500 g', dimensions: { length: '300', width: '300', height: '300' } }, + ], + containers: [ + { id: 'crate', quantity: 2, max_payload: '50 kg', + inner_dimensions: { length: '1200', width: '300', height: '300' } }, + ], +}; + +const describe = (containers) => { + for (const container of containers) { + const contents = container.placements.map((placement) => placement.item_type).join(', '); + console.log(` ${container.id} ${container.payload_weight.value.padStart(5)} ` + + `${container.payload_weight.unit} ${contents}`); + } +}; + +const result = pack(request); +console.log('as packed:'); +describe(result.containers); + +const rebalanced = rebalanceWeight(request, result); +console.log(`\nrebalanced (improved: ${rebalanced.improved}):`); +describe(rebalanced.containers); + +console.log('\nmoves:'); +for (const move of rebalanced.moves) { + console.log(` ${move.item_id} from ${move.from_container_id} to ${move.to_container_id}`); +} + +// `maxMoves` bounds the work. Zero asks for none, and returns the result unchanged. +const untouched = rebalanceWeight(request, result, { maxMoves: 0 }); +console.log(`\nwith maxMoves: 0 -> improved: ${untouched.improved}, ${untouched.moves.length} moves`); + +console.log('\nThe container count and every item stay the same; only who carries what changes.'); +console.log('An empty move list means no move made the spread strictly narrower and stayed valid.'); diff --git a/examples/reproducibility.mjs b/examples/reproducibility.mjs new file mode 100644 index 0000000..e785bb1 --- /dev/null +++ b/examples/reproducibility.mjs @@ -0,0 +1,119 @@ +/** + * Reproducibility: stop the search by counted work, not by the clock. + * + * Run it: + * + * node examples/reproducibility.mjs + * + * Every solve stops for one of two reasons. It finishes, or a limit stops it. Which limit + * decides whether you can get the same answer again: + * + * - `time_limit_ms` is a wall clock. How far a search gets in a second depends on the + * machine and on what else it is doing, so an answer the clock cut short can differ on + * the next run. This package's JavaScript engine uses 1000 ms when you set nothing. + * - `effort_budget` counts work: candidate positions evaluated, placements attempted, + * search nodes expanded, restarts. The same request stops at the same point on any + * machine, so a budget-limited answer is still reproducible. + * + * The pattern for anything you store, compare or audit: set an `effort_budget` that + * bounds the work, and a `time_limit_ms` far above what that work takes, as a fuse + * against a genuine hang rather than as a search limit. + */ + +import { createHash } from 'node:crypto'; + +import { commerce, pack } from '../index.js'; + +const order = { + units: { length: 'mm' }, + items: [ + { id: 'tv', quantity: 3, weight: '14 kg', keep_upright: true, + dimensions: { length: '1000', width: '150', height: '620' } }, + { id: 'soundbar', quantity: 4, weight: '3 kg', + dimensions: { length: '900', width: '120', height: '110' } }, + { id: 'speaker', quantity: 8, weight: '4 kg', + dimensions: { length: '220', width: '200', height: '350' } }, + { id: 'cable-kit', quantity: 20, weight: '400 g', + dimensions: { length: '200', width: '150', height: '60' } }, + ], + containers: [ + { id: 'carton-L', max_payload: '40 kg', cost_minor: 900, + inner_dimensions: { length: '1100', width: '500', height: '700' } }, + { id: 'carton-M', max_payload: '20 kg', cost_minor: 400, + inner_dimensions: { length: '600', width: '400', height: '400' } }, + ], +}; + +const solve = (maxCandidates) => pack({ + ...order, + configuration: { + effort_budget: { max_candidates_evaluated: maxCandidates }, + time_limit_ms: 60000, + }, +}); + +/** + * A fingerprint of everything the result says. `duration_ms` is the one field that is a + * measurement of the run rather than part of the answer, so it is zeroed first; the rest + * is written in canonical JSON (sorted keys, no whitespace) so equal results hash equally. + */ +const fingerprint = (result) => createHash('sha256') + .update(commerce.canonicalJson({ ...result, algorithm: { ...result.algorithm, duration_ms: 0 } })) + .digest('hex') + .slice(0, 16); + +const rule = '='.repeat(78); +const section = (title) => console.log(`\n${rule}\n${title}\n${rule}`); + +const report = (label, result) => { + const { algorithm, summary, termination } = result; + console.log(`\n ${label}`); + console.log(` status ${result.status}`); + console.log(` termination ${termination.code}`); + console.log(` effort limit hit ${algorithm.effort_limit_reached}`); + console.log(` time limit hit ${algorithm.time_limit_reached}`); + console.log(` candidates counted ${algorithm.candidates_evaluated}`); + console.log(` packed / left out ${summary.packed_item_count} / ${summary.unpacked_item_count}` + + ` in ${summary.container_count} carton(s)`); +}; + +// ------------------------------------------------------------------------------------ +section('1. A budget the search never reaches'); + +const generous = solve(1000000); +report('max_candidates_evaluated: 1000000', generous); +console.log('\n The search finished on its own. The budget only matters if it would run away,'); +console.log(' and the 60 s clock only matters if the budget itself takes that long.'); + +// ------------------------------------------------------------------------------------ +section('2. A budget that cuts the search short -- and still repeats exactly'); + +const tight = solve(50); +report('max_candidates_evaluated: 50', tight); +const reasons = new Set(tight.unpacked_items.map((unpacked) => unpacked.reason)); +console.log(` reasons ${[...reasons].join(', ')}`); +console.log('\n Items the search never reached are reported as `effort_limit`, not as items'); +console.log(' that do not fit. The next run stops at exactly the same candidate:'); + +for (const [label, maxCandidates, first] of [['generous', 1000000, generous], ['tight', 50, tight]]) { + const again = solve(maxCandidates); + console.log(` ${label.padEnd(9)} run 1 ${fingerprint(first)} run 2 ${fingerprint(again)}` + + ` identical: ${fingerprint(first) === fingerprint(again)}`); +} + +// ------------------------------------------------------------------------------------ +section('3. How to tell, from the result alone, whether to trust a rerun'); + +const verdict = (result) => { + if (result.algorithm.time_limit_reached) { + return 'stopped by the clock -- a rerun may differ; do not store it as the answer'; + } + if (result.algorithm.effort_limit_reached) { + return 'stopped by the budget -- partial, but a rerun gives the same answer'; + } + return 'finished -- a rerun gives the same answer'; +}; +console.log(` generous: ${verdict(generous)}`); +console.log(` tight: ${verdict(tight)}`); +console.log('\n `algorithm.time_limit_reached` and `algorithm.effort_limit_reached` never both'); +console.log(' hold. Check the first before you cache, compare or audit a result.'); diff --git a/examples/serialization.mjs b/examples/serialization.mjs new file mode 100644 index 0000000..890af8a --- /dev/null +++ b/examples/serialization.mjs @@ -0,0 +1,116 @@ +/** + * Serialization: the request as JSON text, the result as JSON text, and runners-up. + * + * Run it: + * + * node examples/serialization.mjs + * + * Everything the engine can do is reachable through one JSON document, and that document + * is the contract every Packvium engine reads -- Python, PHP, Rust and this one. So a + * request that arrives over HTTP, sits in a queue or is saved for later needs no mapping + * layer: hand the text to `packJson` and store the text it returns. + * + * `pack(object)` and `packJson(string)` are the same call; `pack` parses and serializes + * for you. This example uses the text form throughout, then shows `alternatives`, and + * ends with exactly what is and is not refused. + */ + +import { UnsupportedFeatureError, InvalidRequestError, packJson } from '../index.js'; + +// As it might arrive in an HTTP body. Lengths and weights are decimal strings, so +// nothing is lost to floating point on the way in or out. +const body = `{ + "units": {"length": "mm"}, + "configuration": { + "solver_profile": "quality", + "alternatives": 3, + "effort_budget": {"max_candidates_evaluated": 200000}, + "time_limit_ms": 60000 + }, + "items": [ + {"id": "lamp", "quantity": 3, "weight": "2 kg", + "dimensions": {"length": "200", "width": "200", "height": "300"}}, + {"id": "book", "quantity": 6, "weight": "500 g", + "dimensions": {"length": "210", "width": "140", "height": "30"}} + ], + "containers": [ + {"id": "small", "cost_minor": 300, + "inner_dimensions": {"length": "400", "width": "300", "height": "300"}}, + {"id": "large", "cost_minor": 550, + "inner_dimensions": {"length": "600", "width": "400", "height": "350"}} + ] +}`; + +const text = packJson(body); +const result = JSON.parse(text); + +const rule = '='.repeat(78); +const section = (title) => console.log(`\n${rule}\n${title}\n${rule}`); + +// ------------------------------------------------------------------------------------ +section('1. JSON in, JSON out'); + +console.log(` request ${body.length} characters of text`); +console.log(` result ${text.length} characters of text`); +console.log(` status ${result.status}`); +console.log(` score [${result.score.join(', ')}] <- exact integers, compared left to right`); +console.log(` opened ${result.containers.map((container) => container.id).join(', ')}`); + +// Every measurement comes back in three forms: exact integer ticks, a decimal string +// for people, and its unit. Keep `ticks` for arithmetic; show `value`. +const [first] = result.containers[0].placements; +console.log(`\n one coordinate: ${JSON.stringify(first.position.x)}`); + +// ------------------------------------------------------------------------------------ +section('2. alternatives: runners-up, already scored and validated'); + +// The count includes the winner, so 3 asks for at most two runners-up. They come from the +// portfolio's other starts, which is why this request asks for the `quality` profile: +// a single-start profile has nothing else to rank. An empty list is normal. +console.log(` asked for 3, got the winner and ${result.alternatives.length} runner(s)-up`); +for (const [index, alternative] of [result, ...result.alternatives].entries()) { + const opened = alternative.containers.map((container) => container.container_type).join(' + '); + console.log(` ${index === 0 ? 'winner' : `#${index} `} score [${alternative.score.join(', ')}] ${opened}`); +} +console.log('\n No runner-up scores better than the winner. Two can share a cost and still be'); +console.log(' different arrangements. The list is this engine\'s own: another engine may'); +console.log(' return different runners-up, or none, for the same request.'); + +// ------------------------------------------------------------------------------------ +section('3. What is refused, and what is not'); + +const variant = (edit) => { + const request = JSON.parse(body); + edit(request); + return JSON.stringify(request); +}; + +// A key the engine does not recognise on an item is ignored. Misspell `keep_upright` and +// the lamp may be laid on its side; nothing is thrown. Check keys yourself if typos in +// item fields matter. +const typo = JSON.parse(packJson(variant((request) => { request.items[0].keep_uprght = true; }))); +console.log(` misspelled item field -> ${typo.status}; the misspelling was never read`); + +// An unknown value where the engine has to choose a behaviour is refused. +try { + packJson(variant((request) => { request.configuration.objective = 'cheapest'; })); +} catch (error) { + if (!(error instanceof InvalidRequestError)) throw error; + console.log(` unknown objective -> ${error.reason}: ${error.message}`); +} + +// A field the schema reserves but this engine has not implemented is refused by name, +// so a request written for a newer engine fails loudly instead of being half-honoured. +try { + packJson(variant((request) => { request.containers[0].pallet_overhang_limit = { length: '50', width: '50' }; })); +} catch (error) { + if (!(error instanceof UnsupportedFeatureError)) throw error; + console.log(` reserved, unimplemented -> ${error.code}: ${error.fields.join(', ')}`); +} + +// And text that is not JSON at all never reaches the engine. +try { + packJson('{"items": ['); +} catch (error) { + console.log(` not JSON -> ${error.name}`); +} diff --git a/examples/trucking.mjs b/examples/trucking.mjs new file mode 100644 index 0000000..d7869ea --- /dev/null +++ b/examples/trucking.mjs @@ -0,0 +1,139 @@ +/** + * Trucking: a multi-drop van with one rear door and a limit on each axle. + * + * Run it: + * + * node examples/trucking.mjs + * + * A parcel carton only has to hold its contents. A delivery van has three more rules, and + * each is a field in the same request: + * + * - `stop_index` on an item says at which drop it leaves the van, lowest first; + * - `access_directions` on the container names the walls it is unloaded through, so the + * solver can keep every earlier drop between the later ones and the door; + * - `axles` puts two axles under the container, each with its own `max_load`, and the + * result reports how the gross weight divides between them. + * + * The packing is then turned into a loading order someone can follow at the dock: + * `safeLoadingOrder` computes it from the placements, and `buildExecutionPlan` numbers + * the steps. + */ + +import { explainUnpackedItem, pack, safeLoadingOrder } from '../index.js'; +import { buildExecutionPlan } from '../execution.js'; + +// The van: 3.6 m of floor, the cab at x = 0 and the rear door on the +x wall. +const van = (rule) => ({ + id: 'van', + quantity: 1, + inner_dimensions: { length: '3600', width: '1200', height: '1400' }, + max_payload: '1500 kg', + access_directions: ['+x'], + ...rule, +}); + +// Three drops. Pallets stand upright on the floor; the bakery also has two parcels. +const pallet = (id, stop, weight) => ({ + id, quantity: 1, weight, stop_index: stop, keep_upright: true, must_be_on_floor: true, + dimensions: { length: '1100', width: '1100', height: '1000' }, +}); +const items = [ + pallet('bakery', 0, '350 kg'), + pallet('cafe', 1, '250 kg'), + pallet('kiosk', 2, '150 kg'), + { id: 'parcel', quantity: 2, weight: '20 kg', stop_index: 0, + dimensions: { length: '500', width: '400', height: '300' } }, +]; +const stopOf = Object.fromEntries(items.map((item) => [item.id, item.stop_index])); + +const solve = (container) => pack({ + units: { length: 'mm' }, + configuration: { + // Counted work decides where the search stops; the wall clock is only a safety fuse. + effort_budget: { max_candidates_evaluated: 1000000 }, + time_limit_ms: 60000, + }, + items, + containers: [container], +}); + +/** + * Axle loads arrive as exact fractions: `front_numerator / denominator` is a weight in + * ticks (8,000,000 per gram). BigInt keeps the division exact; only the rounding to a tenth + * of a kilogram is for display; the two exact values sum to the gross weight. + */ +const kilograms = (numerator, denominator) => { + const perTenth = BigInt(denominator) * 8000000n * 100n; + const tenths = (BigInt(numerator) + perTenth / 2n) / perTenth; + return `${tenths / 10n}.${tenths % 10n} kg`; +}; + +const rule = '='.repeat(78); +const section = (title) => console.log(`\n${rule}\n${title}\n${rule}`); + +// ------------------------------------------------------------------------------------ +section('1. Route order: the last drop goes in first, nearest the cab'); + +const result = solve(van({})); +const [load] = result.containers; +console.log(`status: ${result.status}, ${load.placements.length} placed`); +for (const placement of load.placements) { + const { x, z } = placement.position; + console.log(` stop ${stopOf[placement.item_type]} ${placement.item_id.padEnd(10)} ` + + `x = ${x.value.padStart(4)} mm, z = ${z.value} mm`); +} +console.log('\n The door is on the +x wall, so x grows towards it. Drop 0 sits by the door,'); +console.log(' drop 2 against the cab: at every stop, what leaves is in front of what stays.'); + +// ------------------------------------------------------------------------------------ +section('2. A loading order the dock can follow'); + +// The sequence helpers take plain integer ticks, the numbers the solver reasoned with. +const boxes = load.placements.map(({ position: p, dimensions: d }) => ({ + origin: { x: p.x.ticks, y: p.y.ticks, z: p.z.ticks }, + dimensions: { length: d.length.ticks, width: d.width.ticks, height: d.height.ticks }, +})); +const inside = load.inner_dimensions; +const order = safeLoadingOrder( + boxes, + { length: inside.length.ticks, width: inside.width.ticks, height: inside.height.ticks }, + // Through the same door the van is unloaded from. + ['+x'], +); +const request = { units: { length: 'mm' }, items, containers: [van({})] }; +const plan = buildExecutionPlan(request, result, { loadingOrders: { 0: order } }); +console.log(`order: ${plan.containers[0].order}`); +for (const step of plan.containers[0].steps) { + // A step cites its placement by type, orientation and exact position, never by a + // display string. 16000 ticks are one millimetre. + const { item_type: itemType, position_ticks: ticks } = step.placement; + console.log(` ${step.sequence}. ${itemType.padEnd(7)} to x = ${ticks.x / 16000} mm, y = ${ticks.y / 16000} mm`); +} +console.log('\n Every step slides in through the door without passing anything already loaded.'); +console.log(' Without `loadingOrders` the plan would say its order is `unavailable` rather'); +console.log(' than guess one -- see examples/execution.mjs.'); + +// ------------------------------------------------------------------------------------ +section('3. Axles: where the weight lands, and what a tighter limit refuses'); + +const axles = (rearLimit) => van({ + axles: [ + { position: '500', max_load: '600 kg' }, + { position: '2800', max_load: rearLimit }, + ], +}); +for (const rearLimit of ['700 kg', '450 kg']) { + const loaded = solve(axles(rearLimit)); + const reactions = loaded.containers[0].axle_reactions; + console.log(`\n rear axle limit ${rearLimit}: ${loaded.status}`); + console.log(` front axle carries ${kilograms(reactions.front_numerator, reactions.denominator)}`); + console.log(` rear axle carries ${kilograms(reactions.rear_numerator, reactions.denominator)}`); + for (const unpacked of loaded.unpacked_items) { + console.log(` left behind: ${unpacked.item_id} (${unpacked.reason})`); + console.log(` ${explainUnpackedItem(unpacked)}`); + } +} +console.log('\n The axle limit is enforced while packing, not checked afterwards: the bakery'); +console.log(' pallet is heaviest and nearest the rear axle, so at 450 kg it stays behind.'); +console.log(' There is no axle-specific reason code; the refusal says the search found no'); +console.log(' legal place, and `axle_reactions` on the result is where to look for why.'); diff --git a/fallback.js b/fallback.js index f04d60f..cecf4ed 100644 --- a/fallback.js +++ b/fallback.js @@ -152,7 +152,7 @@ function requireFixedPlacementShapes(raw,u){ function requireFixedEntryShape(entry,where,field,u){ if(!isObject(entry))throw malformed(`${where} is an object`,field); const unknown=unknownKeys(entry,FIXED_FIELDS); - if(unknown.length)throw malformed(`${where} does not carry ${jsonSpelling(unknown)}`,field); + if(unknown.length)throw malformed(`${where} cannot carry ${jsonSpelling(unknown)}`,field); const missing=FIXED_REQUIRED.filter(name=>!hasOwn(entry,name)); if(missing.length)throw malformed(`${where} needs ${jsonSpelling(missing)}`,field); for(const name of ['item_type','container_type']) @@ -179,7 +179,7 @@ const fixedInstance=entry=>jsonInteger(hasOwn(entry,'container_instance')?entry. export function requirePointShape(point,where,field,error){ if(!isObject(point))throw error(`${where} is a point object`,field); const unknown=unknownKeys(point,AXES); - if(unknown.length)throw error(`${where} does not carry ${jsonSpelling(unknown)}`,field); + if(unknown.length)throw error(`${where} cannot carry ${jsonSpelling(unknown)}`,field); for(const axis of AXES)if(hasOwn(point,axis)&&(point[axis]===null||typeof point[axis]==='boolean'||Array.isArray(point[axis]))) throw error(`${where}.${axis} is a measure`,`${field}/${axis}`); } @@ -485,7 +485,7 @@ function hullShape(vertices){ * * `fallback.js` is an internal package file: package.json exposes only `index.js`, which does * not re-export this function. Keeping the probe beside the algorithm lets the suite assert - * edge identity and ordering without widening `@packvium/native`'s public API or handing a + * edge identity and ordering without widening `@packvium/engine`'s public API or handing a * mutable cached shape to a caller. */ export function __inspectHullShapeForTests(vertices){ const shape=hullShape(vertices),copy=axes=>axes.map(axis=>axis.map(value=>value.toString())); @@ -1300,6 +1300,7 @@ function accessibleAgainst(base,candidateBox){ // not take. for(let index=0;index!sweptHits(sweep,candidateBox)))return false; } // An item riding the whole route is never unloaded, so it needs no door of its own. @@ -1880,7 +1881,8 @@ const exactItemLimit=req.configuration?.exact_item_limit??7; const requestedItemCount=(req.items??[]).reduce((total,item)=>total+(item.quantity??1),0)-(Array.isArray(req.fixed_placements)?req.fixed_placements.length:0); if(requestedSolvers.includes('exact_small')&&requestedItemCount>exactItemLimit)throw new RangeError('exact-small item limit exceeded'); const effort=req.configuration?.effort_budget??null; -for(const [name,value] of Object.entries(effort??{}))if(!Number.isSafeInteger(value)||value<=0)throw new RangeError(`effort_budget.${name} must be a positive safe integer`); +// A null limit is an absent one, as in every other engine (docs/ERRORS.md). +for(const [name,value] of Object.entries(effort??{}))if(value!=null&&(!Number.isSafeInteger(value)||value<=0))throw new RangeError(`effort_budget.${name} must be a positive safe integer`); const multiStartOrders=req.configuration?.multi_start_orders??1; if(!Number.isSafeInteger(multiStartOrders)||multiStartOrders<1)throw new RangeError('multi_start_orders must be a positive safe integer'); const restartLimit=effort?.max_restarts??Number.MAX_SAFE_INTEGER; @@ -1929,6 +1931,7 @@ if(solverAlias===null&&requestedSolvers.length){ for(let index=0;indexindex!==winnerIndex&&!run.unpriceableDetail).sort((a,b)=>compareScore(a.score,b.score)).slice(0,alternativeLimit); @@ -1956,6 +1959,7 @@ if(startIndex===null&&multiStartOrders>1){ }:unstartedRecord(solverAlias,index,globalDeadlineReached)); winner.termination=aggregateTermination(starts); winner.algorithm=withPortfolioEffort(winner,runs); + if(winner.algorithm.effort_limit_reached&&!winner.algorithm.time_limit_reached&&!globalDeadlineReached)winner.termination.code='effort_limit'; if(winnerIndex>0)winner.algorithm={...winner.algorithm,solver:`${winner.algorithm.solver}:seeded_${winnerIndex}`}; return finalizeOutermost(winner); } @@ -1975,7 +1979,8 @@ if(objective==='lowest_landed_cost'){ if(!['mm','cm','m','in','ft'].includes(dimLengthUnit))throw new RangeError('dimensional_weight_length_unit must be mm, cm, m, in or ft'); if(!['mg','g','kg','oz','lb'].includes(dimWeightUnit))throw new RangeError('dimensional_weight_weight_unit must be mg, g, kg, oz or lb'); const dimensionalWeight=d=>Number(volume(d)*BigInt(WT[dimWeightUnit])/(BigInt(LEN[dimLengthUnit])**3n*BigInt(dimDivisor??1))); -const globalSupportPpm=Math.round((req.configuration?.minimum_support_ratio??0)*SUPPORT_SCALE); +const globalSupportRatio=req.configuration?.minimum_support_ratio??0; +const globalSupportPpm=globalSupportRatio>0?Math.max(1,Math.round(globalSupportRatio*SUPPORT_SCALE)):0; const maxCandidatePoints=req.configuration?.max_candidate_points??4096; const qualityProfile=(req.configuration?.solver_profile??'balanced')==='quality'; const maxCandidatesPerItem=req.configuration?.max_candidates_per_item??(qualityProfile?16:1); @@ -2051,7 +2056,7 @@ items.sort((a,b)=>{ // must be loaded to end up underneath. An item with no stop rides the whole route and // loads with the last one. Every stop is Infinity when nothing declares one, so an // unrouted request keeps the ordering below untouched. - {const stop=(b.stopIndex??Infinity)-(a.stopIndex??Infinity);if(stop)return stop} + const minusX=(req.containers??[]).some(c=>(c.access_directions??[]).includes("-x")&&!(c.access_directions??[]).includes("+x"));{const stop=minusX?((a.stopIndex??Infinity)-(b.stopIndex??Infinity)):((b.stopIndex??Infinity)-(a.stopIndex??Infinity));if(stop)return stop} if(qualityProfile&&(startIndex===null||startIndex===0))return a.longest-b.longest||ascendingVolume(a,b)||compareId(a.id,b.id); if(qualityProfile&&startIndex===1)return ascendingVolume(a,b)||a.longest-b.longest||compareId(a.id,b.id); if(solverAlias==='layer')return (b.d[2]-a.d[2])||(b.d[0]*b.d[1]-a.d[0]*a.d[1])||compareId(a.id,b.id); @@ -2226,13 +2231,16 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ if(upperBound+tmpl.reserve>tmpl.innerVolume &&usedVolume([...placements,candidate])+tmpl.reserve>tmpl.innerVolume)continue} metrics.feasible_candidates++; + const routeSensitive=tmpl.doors.length>0&&(item.stopIndex!=null||state.placements.some(p=>p.item.stopIndex!=null)); const score=solverAlias==='grid' ?z*1e12+y*1e6+x :solverAlias==='layer' ?z2*1e12+z*1e8+y*1e4+x :solverAlias==='maximal_spaces' ?x2+y2+z2*1e6 - :z2*1e9+y2*1e4+x2; + :routeSensitive + ?x2*1e9+y2*1e4+z2 + :z2*1e9+y2*1e4+x2; if(width===1){if(!found.length||score{ node.used=compressionSensitive?usedVolume([...node.state.placements,candidate]):node.used+usedVolumeDelta(node.state.placements,candidate); node.state.placements.push(candidate);indexAdd(node.index,node.state.placements.length-1,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed}); retirePointsForPlacement(node.points,candidate);for(const point of pointsFrom(candidate))insertPoint(node.points,point)}; - const sortCosts=costs=>costs.sort((a,b)=>ab?1:0); - const maxCount=(sortedCosts,capacity)=>{let used=0n,count=0;for(const cost of sortedCosts){if(used+cost>capacity)break;used+=cost;count++}return count}; + // Prefix sums of the ascending costs. Costs are non-negative, so the sums never decrease + // and `maxCount` is one binary search: O(log f) per node instead of the O(f) walk. + const cumulativeCosts=costs=>{costs.sort((a,b)=>ab?1:0);let used=0n;return costs.map(cost=>used+=cost)}; + const maxCount=(cumulative,capacity)=>{let low=0,high=cumulative.length;while(low>1;if(cumulative[middle]<=capacity)low=middle+1;else high=middle}return low}; // `future` is the same array for every comparison inside one `expansions.sort(...)` - // call, so its sorted volume/weight arrays are hoisted by the caller and - // passed in here -- falls back to sorting on the fly for the trivial future=[] call - // sites below, which never reach the sort's cost. Reused arrays are read-only: - // `maxCount` no longer sorts in place. - const lowerBound=(node,future,sortedVolumes=null,sortedWeights=null)=>{let possible=future.length; - if(!future.some(item=>item.nesting!=null))possible=Math.min(possible,maxCount(sortedVolumes??sortCosts(future.map(item=>volume(item.d))),volume(tmpl.d)-node.used)); - if(tmpl.max!=null)possible=Math.min(possible,maxCount(sortedWeights??sortCosts(future.map(item=>BigInt(item.w))),BigInt(Math.max(0,tmpl.max-node.state.payload)))); + // call, so its cumulative volume/weight arrays are hoisted by the caller and + // passed in here -- falls back to building them on the fly for the trivial future=[] + // call sites below, which never reach the sort's cost. Reused arrays are read-only. + const lowerBound=(node,future,cumulativeVolumes=null,cumulativeWeights=null)=>{let possible=future.length; + if(!future.some(item=>item.nesting!=null))possible=Math.min(possible,maxCount(cumulativeVolumes??cumulativeCosts(future.map(item=>volume(item.d))),volume(tmpl.d)-node.used)); + if(tmpl.max!=null)possible=Math.min(possible,maxCount(cumulativeWeights??cumulativeCosts(future.map(item=>BigInt(item.w))),BigInt(Math.max(0,tmpl.max-node.state.payload)))); return node.unplaced.length+future.length-possible}; // Compared states are complete; placement always mutates a fresh clone first. // Weak keys let discarded branches release their cached strings with their state. @@ -2320,10 +2329,12 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ // The incumbent is only ever compared and returned (state + unplaced); it never // re-enters the beam, so cloning the candidate points and spatial index for it // was pure allocation per expansion. - for(const node of expansions){const complete={state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used,unplaced:[...node.unplaced,...future]};if(compareNode(complete,incumbent)<0)incumbent=complete} + // More unplaced items already loses compareNode's first criterion, so the copies + // below are made only for a candidate that can displace the incumbent. + for(const node of expansions){if(node.unplaced.length+future.length>incumbent.unplaced.length)continue;const complete={state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used,unplaced:[...node.unplaced,...future]};if(compareNode(complete,incumbent)<0)incumbent=complete} if(!expansions.length||exhausted){pendingFrom=position;break} - const futureVolumes=future.some(item=>item.nesting!=null)?null:sortCosts(future.map(item=>volume(item.d))); - const futureWeights=tmpl.max!=null?sortCosts(future.map(item=>BigInt(item.w))):null; + const futureVolumes=future.some(item=>item.nesting!=null)?null:cumulativeCosts(future.map(item=>volume(item.d))); + const futureWeights=tmpl.max!=null?cumulativeCosts(future.map(item=>BigInt(item.w))):null; const bounds=new Map(expansions.map(node=>[node,lowerBound(node,future,futureVolumes,futureWeights)])); expansions.sort((a,b)=>compareNode(a,b,bounds));beam=expansions.slice(0,containerPlanBeamWidth)} // Without the unreached tail a beam leader looks better than every complete incumbent, @@ -2468,7 +2479,9 @@ 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. -const homogeneousBlocksSupported=()=>policyRules.length===0&&templates.every(t=>!t.obs.length&&t.axleSpec==null +// A block set on a smaller one overhangs it, so a request that asks for support is left +// to the per-item search. +const homogeneousBlocksSupported=()=>policyRules.length===0&&globalSupportRatio===0&&templates.every(t=>!t.obs.length&&t.axleSpec==null &&Object.keys(t.tagLimits).length===0&&t.maxStackDensity==null&&t.reservePpm===0) &&items.every(i=>i.group==null&&!i.tags.length&&!i.incompatible.length&&!i.eligibleTags.length &&i.stackable&&!i.raw.must_be_on_floor&&i.maxTop==null&&i.maxStacked==null @@ -2775,6 +2788,7 @@ function rebalanceValid(context,result){ const placed=[]; for(const candidate of [...state.placements].sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||compareCodePoints(a.item.id,b.item.id))){ if(!allowed(candidate,placed,state.tmpl,context.globalSupportPpm,{support_checks:0}))return false; + if(candidate.z!==0&&candidate.publicPlacement&&Number(candidate.publicPlacement.support_ratio)>0&&supportRatioOf(candidate,placed)<=0)return false; // A move the rules forbid must fail the same check a placement did. Replaying the // container in this order is what makes a cap or a segregation answerable at all: // both are statements about what an item joins, so they need a partial container to diff --git a/index.js b/index.js index 5a1357f..bf1720b 100644 --- a/index.js +++ b/index.js @@ -22,30 +22,28 @@ export { CommerceInputError } from './commerce.js'; const require = createRequire(import.meta.url); /** - * Every module this package can load as a compiled backend, in probe order: a binary - * built beside this file first, then the `@packvium/native` optional dependency. + * Every module this package can load as a compiled backend: a binary built beside this + * file. No npm package is probed -- a specifier the manifest does not declare cannot be + * resolved by a supply-chain scanner, and none is published to declare. * * `test/force-fallback.cjs` blocks exactly this list, and asserts it stays identical to * the specifiers `loadNative` passes. */ -const NATIVE_CANDIDATES = ['./packvium-native.node', '@packvium/native']; +const NATIVE_CANDIDATES = ['./packvium-native.node']; /** * Resolve the compiled backend, or report that there is none. * - * Each candidate is required by a *literal* specifier rather than through a loop - * variable, so every module this package is able to load can be resolved by reading it. - * A probe that misses is not an error: absent, unbuilt and ABI-incompatible addons all - * land here, and every one of them means the same thing -- answer from the JavaScript - * fallback, which returns the same result more slowly. + * The candidate is required by a *literal* specifier rather than through a variable, so + * every module this package is able to load can be resolved by reading it. A probe that + * misses is not an error: absent, unbuilt and ABI-incompatible addons all land here, and + * every one of them means the same thing -- answer from the JavaScript fallback, which + * returns the same result more slowly. */ function loadNative() { try { return require('./packvium-native.node'); } catch { /* no addon beside this file */ } - try { - return require('@packvium/native'); - } catch { /* optional dependency absent or unloadable */ } return null; } @@ -82,7 +80,7 @@ export function rebalanceWeight(request, result, { maxMoves = 64 } = {}) { return rebalanceFallback(request, result, { maxMoves }); } -export const version = () => native?.version?.() ?? '1.4.0-js-fallback'; +export const version = () => native?.version?.() ?? '1.5.0-js-fallback'; /** * The exported commercial and control-plane API: a quote, a policy decision and catalog diff --git a/package.json b/package.json index 083c427..4486ddc 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name":"@packvium/engine", - "version":"1.4.0", - "description":"Native-first 3D cartonization with deterministic JS fallback", - "author":{"name":"Packvium","url":"https://packvium.com"}, - "keywords":["3d-bin-packing","bin-packing","cartonization","packing","container-loading","logistics","shipping","deterministic"], + "version":"1.5.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"], "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"}, diff --git a/request-errors.js b/request-errors.js index f6e8301..5adcd62 100644 --- a/request-errors.js +++ b/request-errors.js @@ -24,6 +24,8 @@ import { compareCodePoints } from './commerce-model.js'; import { jsonSpelling } from './canonical-json.js'; export const SOLVER_PROFILES = Object.freeze(['fast', 'balanced', 'quality', 'exact_small']); +export const OBJECTIVES = Object.freeze(['default', 'lowest_cost', 'shipping_cost', 'lowest_landed_cost', 'open_dimension_height', 'maximum_value']); +export const ACCESS_DIRECTIONS = Object.freeze(['+x', '-x', '+y', '-y', '+z', '-z']); /** The request is not one any engine may answer. Nothing was solved. */ export class InvalidRequestError extends RangeError { @@ -84,6 +86,17 @@ function requireOneOf(value, field, allowed) { } } +/** + * The schema closes this object: a key it does not name is refused, never ignored. The first + * unknown key in code-point order is named, the order every engine can share. + */ +function requireKnownFields(value, field, known) { + const unknown = Object.keys(value).filter((key) => !known.includes(key)).sort(compareCodePoints); + if (unknown.length > 0) { + throw new InvalidRequestError('not_allowed', field + pointer(unknown[0]), 'is not a known field'); + } +} + function requireObject(value, field) { if (!isObject(value)) throw new InvalidRequestError('wrong_type', field, 'must be an object'); return value; @@ -132,6 +145,12 @@ const CONFIGURATION_INTEGERS = [['time_limit_ms', 1], ['alternatives', 1], ['max ['max_candidate_points', 16], ['container_plan_beam_width', 1], ['container_plan_node_limit', 1], ['dimensional_weight_divisor', 1]]; const EFFORT_LIMITS = ['max_candidates_evaluated', 'max_placement_attempts', 'max_search_nodes', 'max_restarts']; +/** Every key the request schema's `configuration` declares; it sets `additionalProperties: false`. */ +const CONFIGURATION_FIELDS = ['alternatives', 'clearance', 'container_plan_beam_width', + 'container_plan_node_limit', 'dimensional_weight_divisor', 'dimensional_weight_length_unit', + 'dimensional_weight_weight_unit', 'effort_budget', 'exact_item_limit', 'max_candidate_points', + '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']; const AXES = ['x', 'y', 'z']; const atLeast = (minimum) => (value, field) => requireInteger(value, field, minimum); @@ -169,12 +188,15 @@ function checkUnits(raw, lengths) { function checkConfiguration(raw, length) { if (raw == null) return; const configuration = requireObject(raw, '/configuration'); + requireKnownFields(configuration, '/configuration', CONFIGURATION_FIELDS); optional(configuration, 'solver_profile', '/configuration', (v, f) => requireOneOf(v, f, SOLVER_PROFILES)); + optional(configuration, 'objective', '/configuration', (v, f) => requireOneOf(v, f, OBJECTIVES)); 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); if (configuration.effort_budget == null) return; const budget = requireObject(configuration.effort_budget, '/configuration/effort_budget'); + requireKnownFields(budget, '/configuration/effort_budget', EFFORT_LIMITS); for (const name of EFFORT_LIMITS) optional(budget, name, '/configuration/effort_budget', atLeast(1)); } @@ -204,11 +226,17 @@ function checkContainer(raw, where, length, weight) { optional(container, 'max_items', where, atLeast(1)); optional(container, 'cost_minor', where, atLeast(0)); optional(container, 'void_fill_reserve_ratio', where, requireRatio); + optional(container, 'access_directions', where, accessDirections); optional(container, 'tag_limits', where, tagLimits); optional(container, 'rate_table', where, rateTable); optional(container, 'obstacles', where, (v, f) => obstacles(v, f, length)); } +function accessDirections(raw, where) { + const directions = requireList(raw, where); + directions.forEach((direction, index) => requireOneOf(direction, where + pointer(index), ACCESS_DIRECTIONS)); +} + function dimensions(raw, where, length) { const sides = requireObject(raw, where); for (const side of SIDES) length(required(sides, side, where), where + pointer(side)); diff --git a/test/access-directions.test.mjs b/test/access-directions.test.mjs index e659318..a30af39 100644 --- a/test/access-directions.test.mjs +++ b/test/access-directions.test.mjs @@ -21,6 +21,10 @@ import { packFallback } from '../fallback.js'; * way in, and therefore the only place these rules can be checked at all. */ +// Counted work, not a clock, decides where the search stops: under the default wall-clock +// limit a loaded host truncated one of two otherwise identical solves, and the comparisons +// below reported the host rather than the engine. The time limit is only a fuse, far above +// what one cube needs. const request = (doors) => { const container = { id: 'van', @@ -29,6 +33,7 @@ const request = (doors) => { if (doors !== undefined) container.access_directions = doors; return { units: { length: 'mm' }, + configuration: { effort_budget: { max_search_nodes: 20000 }, time_limit_ms: 60000 }, items: [{ id: 'cube', quantity: 1, dimensions: { length: '100', width: '100', height: '100' }, @@ -90,8 +95,9 @@ test('an unknown direction is refused rather than dropped', () => { for (const direction of ['north', 'x', '+X', '+w', '', '-x ']) { const error = refusal([direction]); assert.ok(error, `${JSON.stringify(direction)} should be refused, not dropped`); - assert.equal(error.code, 'invalid_direction'); - assert.match(error.message, /unknown movement direction/); + assert.equal(error.code, 'invalid_request'); + assert.equal(error.reason, 'not_allowed'); + assert.equal(error.field, '/containers/0/access_directions/0'); } }); @@ -100,13 +106,36 @@ test('an unknown direction is refused rather than dropped', () => { test('one bad direction refuses the whole list', () => { const error = refusal(['-x', 'sideways', '+z']); assert.ok(error); - assert.equal(error.code, 'invalid_direction'); + assert.equal(error.code, 'invalid_request'); + assert.equal(error.reason, 'not_allowed'); + assert.equal(error.field, '/containers/0/access_directions/1'); }); test('a non-string entry is refused', () => { for (const entry of [1, null, ['-x'], {}]) { const error = refusal([entry]); assert.ok(error, `${JSON.stringify(entry)} should be refused`); - assert.equal(error.code, 'invalid_direction'); + assert.equal(error.code, 'invalid_request'); + assert.equal(error.reason, 'not_allowed'); + assert.equal(error.field, '/containers/0/access_directions/0'); } }); + +test('with access_directions -x and stops, all items are packed', () => { + const req = { + units: { length: 'mm', weight: 'g' }, + configuration: { solver_profile: 'fast' }, + containers: [{ + id: 'truck', + inner_dimensions: { length: '5000', width: '2000', height: '2000' }, + access_directions: ['-x'], + }], + items: [ + { id: 'stop1', quantity: 2, stop_index: 1, dimensions: { length: '1000', width: '1000', height: '1000' } }, + { id: 'stop2', quantity: 3, stop_index: 2, dimensions: { length: '500', width: '500', height: '500' } }, + ], + }; + const res = packFallback(req); + assert.equal(res.status, 'feasible'); + assert.equal(res.containers[0].placements.length, 5); +}); diff --git a/test/commerce-native-dispatch.test.mjs b/test/commerce-native-dispatch.test.mjs index 56f26c8..89e593f 100644 --- a/test/commerce-native-dispatch.test.mjs +++ b/test/commerce-native-dispatch.test.mjs @@ -3,7 +3,7 @@ * * `commerce.test.mjs` proves the real Rust addon and the JavaScript fallback agree, but * it loads the addon itself, by its in-workspace build path. The package's own probe - * list is `['./packvium-native.node', '@packvium/native']`, and neither resolves in this + * list is `['./packvium-native.node']`, and it does not resolve in this * workspace -- so `viaNative`, the branch every installed user with the addon takes, was * never executed by any test or measured by any coverage run. * @@ -28,7 +28,7 @@ const stub = {}; const previousLoad = Module._load; Module._load = function loadWithStubbedNativeBackend(request, parent, isMain) { - if (request === '@packvium/native') return stub; + if (request === './packvium-native.node') return stub; return previousLoad.call(this, request, parent, isMain); }; const engine = await import('../index.js'); diff --git a/test/commerce.test.mjs b/test/commerce.test.mjs index 81b4f36..ed88aea 100644 --- a/test/commerce.test.mjs +++ b/test/commerce.test.mjs @@ -111,8 +111,7 @@ if (hasFixtures) { let native = null; // The same specifiers `force-fallback.cjs` blocks, so a coverage run pinned to the // fallback cannot accidentally load -- and measure -- the native addon here. - for (const candidate of ['./packvium-native.node', '@packvium/native', - '../../packvium-rust/bindings/node']) { + for (const candidate of ['./packvium-native.node', '../../packvium-rust/bindings/node']) { try { native = require(candidate); break; } catch { /* not installed or blocked */ } } if (!native?.commerceQuoteJson) { diff --git a/test/fallback.test.mjs b/test/fallback.test.mjs index f89f72d..c611b91 100644 --- a/test/fallback.test.mjs +++ b/test/fallback.test.mjs @@ -169,6 +169,73 @@ test('randomized weight rebalancing never loses an item or widens payload spread } }); +test('rebalancing reads axles, obstacles, groups and unpacked items back from the result', () => { + // Each of these is parsed from the request and re-checked on every trial scene, so a + // rebalance of a packing that carries them must round-trip them rather than drop them. + const payload = request( + [ + cube('heavy', 10, { weight: '500', priority: 2 }), + cube('light', 10, { weight: '100', priority: 1 }), + cube('alone', 10, { weight: '100', group: 'g' }), + cube('huge', 50, { weight: '1' }), + ], + [box('box', 40, 10, 10, { + quantity: 2, max_items: 2, + axles: [{ position: '0', max_load: '100000' }, { position: '40', max_load: '100000' }], + obstacles: [{ origin: { x: '30', y: '0', z: '0' }, dimensions: mm(10, 10, 10) }], + })], + { configuration: { time_limit_ms: 60_000 } }, + ); + const original = packSound(payload); + assert.deepEqual(original.unpacked_items.map(item => item.item_id), ['huge#1']); + const balanced = rebalanceWeight(payload, original); + assert.deepEqual(balanced.moves, + [{ item_id: 'light#1', from_container_id: 'box#1', to_container_id: 'box#2' }]); + assert.deepEqual( + balanced.containers.map(container => container.placements.map(placement => placement.item_id)), + [['heavy#1'], ['alone#1', 'light#1']]); + for (const container of balanced.containers) { + assert.equal(container.axle_reactions.basis, 'gross'); + } + assert.deepEqual(validate(payload, { ...original, containers: balanced.containers }), []); +}); + +test('rebalanceWeight must not strand a supported item in the source container', () => { + const payload = request( + [ + cube('base', 40, { weight: '5000' }), + cube('top', 40, { weight: '1000' }), + ], + [box('box', 200, 200, 200, { quantity: 2 })], + ); + const original = { + status: 'feasible', + containers: [ + { + id: 'box#1', + container_type: 'box', + placements: [ + { item_id: 'base#1', position: { x: { ticks: 0 }, y: { ticks: 0 }, z: { ticks: 0 } }, dimensions: { length: { ticks: 40 }, width: { ticks: 40 }, height: { ticks: 40 } }, orientation: 'LWH', support_ratio: '1.000000', top_load: { ticks: 0 } }, + { item_id: 'top#1', position: { x: { ticks: 0 }, y: { ticks: 0 }, z: { ticks: 40 } }, dimensions: { length: { ticks: 40 }, width: { ticks: 40 }, height: { ticks: 40 } }, orientation: 'LWH', support_ratio: '1.000000', top_load: { ticks: 0 } }, + ], + }, + { + id: 'box#2', + container_type: 'box', + placements: [], + }, + ], + }; + const balanced = rebalanceWeight(payload, original); + assert.deepEqual(balanced.moves, [{ item_id: 'top#1', from_container_id: 'box#1', to_container_id: 'box#2' }]); + assert.deepEqual( + balanced.containers.map(c => c.placements.map(p => p.item_id)), + [['base#1'], ['top#1']], + ); + assert.equal(balanced.containers[0].placements[0].support_ratio, '1.000000'); + assert.equal(balanced.containers[1].placements[0].support_ratio, '1.000000'); +}); + function packSound(payload) { const result = packFallback(payload); assert.deepEqual(validate(payload, result), [], 'the fallback produced an unsound packing'); @@ -250,6 +317,54 @@ test('an unrouted item cannot bury routed cargo in a seeded restart', () => { assert.deepEqual(result.unpacked_items.map(item => item.item_type), ['unrouted']); }); +test('a nesting item on a route is held to stop order against the whole column', () => { + // A nesting candidate cannot use the one-plane shortcut: an item it nests inside is below + // its top face, so the route rule walks the scene for it. + const payload = request( + [ + cube('cup', 10, { quantity: 3, stop_index: 1, nesting_height: '2', allowed_rotations: ['LWH'] }), + cube('late', 10, { stop_index: 2, allowed_rotations: ['LWH'] }), + ], + [box('bin', 20, 10, 30, { quantity: 1 })], + { configuration: { time_limit_ms: 60_000 } }, + ); + const result = packSound(payload); + assert.equal(result.status, 'feasible'); + assert.deepEqual(placements(result).map(placement => [placement.item_id, at(placement)]), [ + ['late#1', [0, 0, 0]], ['cup#1', [10 * MM, 0, 0]], ['cup#2', [10 * MM, 0, 8 * MM]], + ['cup#3', [0, 0, 10 * MM]], + ]); + + // Reversed stops: the later-stop cups fill the only column, and the earlier-stop cargo + // cannot go beneath them. + const reversed = request( + [ + cube('cup', 10, { quantity: 3, stop_index: 2, nesting_height: '2', allowed_rotations: ['LWH'] }), + cube('early', 10, { quantity: 2, stop_index: 1, allowed_rotations: ['LWH'] }), + ], + [box('bin', 10, 10, 30, { quantity: 1 })], + { configuration: { time_limit_ms: 60_000 } }, + ); + const blocked = packSound(reversed); + assert.deepEqual(placements(blocked).map(placement => placement.item_id), ['cup#1', 'cup#2', 'cup#3']); + assert.deepEqual(blocked.unpacked_items.map(item => item.item_id), ['early#1', 'early#2']); +}); + +test('a nesting item placed beneath its own column is held to that column\'s stop', () => { + // The fixed cup hangs where the request put it; the free cup's top face meets its base in + // the same nesting column, so it is a supporter rather than a nested predecessor. + const payload = request( + [cube('cup', 10, { quantity: 2, stop_index: 1, nesting_height: '2', allowed_rotations: ['LWH'] })], + [box('bin', 10, 10, 30, { quantity: 1 })], + { fixed_placements: [{ item_type: 'cup', container_type: 'bin', position: { x: '0', y: '0', z: '10' }, orientation: 'LWH' }], + configuration: { time_limit_ms: 60_000 } }, + ); + const result = packSound(payload); + assert.equal(result.status, 'feasible'); + assert.deepEqual(placements(result).map(placement => [placement.item_id, at(placement), placement.fixed === true]), + [['cup#1', [0, 0, 10 * MM], true], ['cup#2', [0, 0, 0], false]]); +}); + test('axle reactions use gross load and include centred tare', () => { const result = packFallback(request( [cube('a', 10)], @@ -463,6 +578,17 @@ test('effort budget stops at the exact counted boundary without claiming a wall- assert.ok(result.unpacked_items.every((item) => item.reason === 'effort_limit')); }); +test('quality profile effort budget reports effort_limit without claiming time_limit', () => { + const result = packFallback(request( + [cube('a', 10, { quantity: 20 })], + [box('c', 100, 100, 100)], + { configuration: { solver_profile: 'quality', time_limit_ms: 60_000, effort_budget: { max_search_nodes: 5 } } }, + )); + assert.equal(result.algorithm.time_limit_reached, false); + assert.equal(result.algorithm.effort_limit_reached, true); + assert.equal(result.termination.code, 'effort_limit'); +}); + // ------------------------------------------------------------------------- units test('millimetres convert to exact ticks', () => { @@ -1932,6 +2058,39 @@ test('a shape refuses data belonging to another shape', () => { /a compressible item requires both/); }); +test('a hull with no interior is refused', () => { + // A flat hull is separated from everything along its own normal, so it would pass through + // every other item; Python raises the same words. + const flat = [[0, 0, 0], [100, 0, 0], [0, 100, 0], [100, 100, 0]]; + assert.throws(() => __inspectHullShapeForTests(flat), + /convex hull vertices are coplanar and enclose no volume/); + assert.throws( + () => packFallback(request([{ + ...cube('flat'), shape_type: 'convex_hull', + hull_vertices: flat.map(([x, y, z]) => ({ x: String(x), y: String(y), z: String(z) })), + }], [box('c')])), + /convex hull vertices are coplanar and enclose no volume/); +}); + +test('a hull is tested against an obstacle as a solid, not as its bounding box', () => { + // An obstacle is always a box, so meeting a hull is where a box is turned into a shape. The + // wedge's bounding box overlaps both posts; only the one in its empty half lets it in. + const wedge = [[0, 0, 0], [100, 0, 0], [0, 100, 0], [0, 0, 100], [100, 0, 100], [0, 100, 100]]; + const attempt = (x, y) => { + const payload = request([{ + ...cube('wedge'), shape_type: 'convex_hull', allowed_rotations: ['LWH'], + hull_vertices: wedge.map(([vx, vy, vz]) => ({ x: String(vx), y: String(vy), z: String(vz) })), + }], [box('crate', 100, 100, 100, { + quantity: 1, obstacles: [{ origin: { x: String(x), y: String(y), z: '0' }, dimensions: mm(20, 20, 100) }], + })], { configuration: { time_limit_ms: 60_000 } }); + return packSound(payload); + }; + const beside = attempt(80, 80); + assert.equal(beside.status, 'feasible'); + assert.deepEqual(placements(beside).map(placement => at(placement)), [[0, 0, 0]]); + assert.deepEqual(attempt(0, 0).unpacked_items.map(item => item.item_id), ['wedge#1']); +}); + test('a compressible column compresses its own base', () => { // Ordering cannot vary here -- two identical items in a one-footprint crate -- so this is // the scene that makes `compression_ratio` observable in this engine as well as the other @@ -2105,6 +2264,29 @@ test('exact_small searches rather than relabelling the greedy pass', () => { assert.equal(result.summary.unpacked_item_count, 1); }); +for (const [name, configuration] of [ + ['quality beam', { solver_profile: 'quality' }], + ['exact_small', { solvers: ['exact_small'] }], +]) { + test(`a group is placed whole or not at all by the ${name} search`, () => { + // One bin holds two cubes. The two-member `wide` group fits one member and not the + // other, so it must be left out entirely; the `pair` group fills the bin exactly. + const payload = request( + [ + cube('pair', 10, { quantity: 2, group: 'g' }), + cube('single', 10, { group: 'h' }), + { id: 'wide', quantity: 2, group: 'w', dimensions: mm(20, 10, 10) }, + ], + [{ id: 'bin', quantity: 1, inner_dimensions: mm(20, 10, 10) }], + { configuration: { ...configuration, max_containers: 1, time_limit_ms: 60_000 } }, + ); + const result = packSound(payload); + assert.deepEqual(placements(result).map(placement => placement.item_id), ['pair#1', 'pair#2']); + assert.deepEqual(result.unpacked_items.map(item => item.item_id).sort(), + ['single#1', 'wide#1', 'wide#2']); + }); +} + test('exact_small evaluates later containers against the requested objective', () => { const payload = request( [cube('cube', 10, { quantity: 4, allowed_rotations: ['LWH'] })], @@ -2372,6 +2554,79 @@ test('homogeneous-block search falls back for placement-distinguishing rules', ( assert.deepEqual(validate(payload, result), []); }); +// One 60x60x40 base and three 40x60x50 tops in a 100 mm crate: the base ends on two tops +// and overhangs them. +const aBaseAndThreeTops = (configuration = {}) => request( + [{ id: 'base', dimensions: mm(60, 60, 40) }, { id: 'top', quantity: 3, dimensions: mm(40, 60, 50) }], + [box('crate', 100, 100, 100, { quantity: 1 })], + { configuration: { + solver_profile: 'quality', solvers: ['homogeneous_blocks'], time_limit_ms: 60_000, + effort_budget: { max_search_nodes: 100_000 }, ...configuration, + } }, +); + +// The share of a placement's base on the floor or on a top face, from geometry alone. +function restingShare(all, placement) { + const ticks = (p) => ({ + x1: p.position.x.ticks, y1: p.position.y.ticks, z1: p.position.z.ticks, + x2: p.position.x.ticks + p.dimensions.length.ticks, + y2: p.position.y.ticks + p.dimensions.width.ticks, + z2: p.position.z.ticks + p.dimensions.height.ticks, + }); + const base = ticks(placement); + if (base.z1 === 0) return 1; + const resting = all.map(ticks).filter((below) => below.z2 === base.z1).reduce((sum, below) => sum + + Math.max(0, Math.min(base.x2, below.x2) - Math.max(base.x1, below.x1)) + * Math.max(0, Math.min(base.y2, below.y2) - Math.max(base.y1, below.y1)), 0); + return resting / ((base.x2 - base.x1) * (base.y2 - base.y1)); +} + +test('a block set on a smaller one reports the support it really has', () => { + const payload = aBaseAndThreeTops(); + const result = packFallback(payload); + const all = placements(result); + const overhanging = all.filter((placement) => restingShare(all, placement) < 1); + assert.equal(overhanging.length, 1); + assert.equal(restingShare(all, overhanging[0]).toFixed(6), (5 / 6).toFixed(6)); + for (const placement of all) { + assert.equal(Number(placement.support_ratio).toFixed(6), restingShare(all, placement).toFixed(6)); + } + assert.deepEqual(validate(payload, result), []); +}); + +test('the block solver leaves a request that asks for support to the per-item search', () => { + const payload = aBaseAndThreeTops({ minimum_support_ratio: 1 }); + const result = packFallback(payload); + const all = placements(result); + assert.ok(all.length > 0); + for (const placement of all) assert.equal(restingShare(all, placement), 1); + assert.deepEqual(validate(payload, result), []); +}); + +test('a positive support ratio below one ppm still bypasses the block solver', () => { + const payload = aBaseAndThreeTops({ minimum_support_ratio: 0.0000001 }); + const result = packFallback(payload); + const all = placements(result); + assert.equal(all.length, 4); + assert.ok(all.every((placement) => restingShare(all, placement) === 1)); + assert.deepEqual(validate(payload, result), []); +}); + +test('a positive support ratio below one ppm still requires contact in per-item search', () => { + const payload = request([ + { id: 'i0', dimensions: mm(72, 37, 50) }, + { id: 'i1', dimensions: mm(65, 42, 36) }, + { id: 'i2', dimensions: mm(23, 89, 64) }, + { id: 'i3', dimensions: mm(38, 85, 23) }, + ], [box('box', 100, 100, 100, { quantity: 1 })], { + configuration: { solver_profile: 'balanced', minimum_support_ratio: 0.0000001 }, + }); + const result = packFallback(payload); + const all = placements(result); + assert.ok(all.some((placement) => placement.position.z.ticks > 0)); + assert.ok(all.every((placement) => restingShare(all, placement) > 0)); +}); + function compareScores(left, right) { for (let index = 0; index < left.length; index++) { if (left[index] !== right[index]) return left[index] - right[index]; diff --git a/test/fixed-placements.test.mjs b/test/fixed-placements.test.mjs index 82c0a10..6283ecd 100644 --- a/test/fixed-placements.test.mjs +++ b/test/fixed-placements.test.mjs @@ -220,7 +220,7 @@ const SHAPES = [ ['not a list', 'x', 'fixed_placements is a list', '/fixed_placements'], ['an object', { 0: entry() }, 'fixed_placements is a list', '/fixed_placements'], ['an entry that is not an object', [5], 'fixed_placements[0] is an object', '/fixed_placements/0'], - ['an unknown key', [{ ...entry(), note: 'strapped', ab: 1 }], 'fixed_placements[0] does not carry ["ab","note"]', '/fixed_placements/0'], + ['an unknown key', [{ ...entry(), note: 'strapped', ab: 1 }], 'fixed_placements[0] cannot carry ["ab","note"]', '/fixed_placements/0'], ['a missing key', [{ item_type: 'cube' }], 'fixed_placements[0] needs ["container_type","orientation"]', '/fixed_placements/0'], ['an empty item type', [{ ...entry(), item_type: '' }], 'fixed_placements[0].item_type is a non-empty string', '/fixed_placements/0/item_type'], ['a numeric container type', [{ ...entry(), container_type: 5 }], 'fixed_placements[0].container_type is a non-empty string', '/fixed_placements/0/container_type'], @@ -233,7 +233,7 @@ const SHAPES = [ ['an instance beyond 2^53 - 1', [{ ...entry(), container_instance: 2 ** 53 }], 'fixed_placements[0].container_instance counts from 1', '/fixed_placements/0/container_instance'], ['a position list', [{ ...entry(), position: ['100', '0', '0'] }], 'fixed_placements[0].position is a point object', '/fixed_placements/0/position'], ['a null position', [{ ...entry(), position: null }], 'fixed_placements[0].position is a point object', '/fixed_placements/0/position'], - ['an extra axis', [{ ...entry(), position: { x: '100', w: '5' } }], 'fixed_placements[0].position does not carry ["w"]', '/fixed_placements/0/position'], + ['an extra axis', [{ ...entry(), position: { x: '100', w: '5' } }], 'fixed_placements[0].position cannot carry ["w"]', '/fixed_placements/0/position'], ['a boolean axis', [{ ...entry(), position: { x: true } }], 'fixed_placements[0].position.x is a measure', '/fixed_placements/0/position/x'], ['a null axis', [{ ...entry(), position: { y: null } }], 'fixed_placements[0].position.y is a measure', '/fixed_placements/0/position/y'], ['a list axis', [{ ...entry(), position: { z: [0] } }], 'fixed_placements[0].position.z is a measure', '/fixed_placements/0/position/z'], @@ -287,6 +287,9 @@ const RULES = [ ['top load', [item('a', { max_top_load: '500' }), item('b')], room(), [at('a'), at('b', '0', '100')], 'top_load_exceeded: box#1: a#1'], ['stacked limit', [item('a', { max_stacked_items: 1 })], room(), [at('a'), at('a', '0', '100'), at('a', '0', '200')], 'stacked_item_limit_exceeded: box#1: a#1'], + // Two 1 kg cubes on a 0.01 m² base load the lower one at 200 kg/m². + ['stack density', [item('a')], room({ max_stack_density: '150000' }), [at('a'), at('a', '0', '100')], + 'stack_density_exceeded: box#1: a#1'], ['single contact', [item('a'), item('b', { ground_contact_rule: 'single', dimensions: wide })], room(), [at('a'), at('a', '100'), at('b', '0', '100')], 'ground_contact_violation: b#1: single: rests on 2 item(s)'], ['covered contact', [item('a'), item('b', { ground_contact_rule: 'covered', dimensions: wide })], room(), @@ -307,6 +310,28 @@ for (const [name, items, container, placements, detail] of RULES) { }); } +// The rules above each refuse; the same rules must also let a lawful set through, or a check +// that always refuses would pass the table. +const LAWFUL = [ + ['tags within their limit', [item('a', { tags: ['food'] }), item('b', { incompatible_tags: ['chem'] })], + room({ tag_limits: { food: 2 } }), [at('a'), at('b', '100')]], + ['a route that unloads in stop order', [item('a', { stop_index: 2 }), item('b', { stop_index: 1 })], + { id: 'box', quantity: 3, inner_dimensions: { length: '100', width: '100', height: '200' } }, + [at('a'), at('b', '0', '100')]], + ['a stack under its density limit', [item('a')], room({ max_stack_density: '250000' }), + [at('a'), at('a', '0', '100')]], +]; + +for (const [name, items, container, placements] of LAWFUL) { + test(`a fixed set that keeps the rules is accepted: ${name}`, () => { + const data = { items, containers: [container], fixed_placements: placements }; + const result = packFallback(data); + assert.equal(result.status, 'feasible'); + assert.equal(fixedRows(result).length, placements.length); + assert.deepEqual(validate(data, result), []); + }); +} + test('the validator catches a moved fixed item', () => { const packed = packFallback(request()); const moved = request({ placements: [fixed('0')] }); diff --git a/test/force-fallback.cjs b/test/force-fallback.cjs index fd444e1..aced2a9 100644 --- a/test/force-fallback.cjs +++ b/test/force-fallback.cjs @@ -17,7 +17,7 @@ const Module = require('node:module'); // Exactly what `index.js` probes, and nothing else -- `force-fallback.test.mjs` asserts // the two lists stay identical, which is what makes "this hook blocks the package's // native backend" a checkable claim rather than a comment. -const NATIVE_CANDIDATES = ['./packvium-native.node', '@packvium/native']; +const NATIVE_CANDIDATES = ['./packvium-native.node']; // Paths only a test reaches for: the in-workspace build directory the commerce suite // loads when it compares the native and fallback backends against each other. Blocked diff --git a/test/force-fallback.test.mjs b/test/force-fallback.test.mjs index cf1910d..472f08e 100644 --- a/test/force-fallback.test.mjs +++ b/test/force-fallback.test.mjs @@ -26,10 +26,12 @@ const HOOK = path.join(HERE, 'force-fallback.cjs'); const PROBE = "import {backend} from './index.js'; process.stdout.write(backend());"; /** - * A copy of the package with a resolvable `@packvium/native` beside it. The stand-in - * is plain JavaScript rather than a compiled addon because `index.js` only ever asks - * whether the module resolves and exposes `packJson` -- nothing about the block depends - * on it being real machine code, and a real one cannot be built inside a unit test. + * A copy of the package with a resolvable `./packvium-native.node` beside it. The + * stand-in is plain JavaScript rather than a compiled addon because `index.js` only ever + * asks whether the module resolves and exposes `packJson` -- nothing about the block + * depends on it being real machine code, and a real one cannot be built inside a unit + * test. It is a *directory* of that name: a file ending in `.node` is handed to + * `dlopen`, while a directory resolves through its `package.json` like any package. */ function packageWithAFakeNativeBackend() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'packvium-forced-fallback-')); @@ -42,8 +44,8 @@ function packageWithAFakeNativeBackend() { fs.copyFileSync(path.join(PACKAGE, name), path.join(root, name)); } fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ type: 'module' })); - const fake = path.join(root, 'node_modules/@packvium/native'); - fs.mkdirSync(fake, { recursive: true }); + const fake = path.join(root, 'packvium-native.node'); + fs.mkdirSync(fake); fs.writeFileSync(path.join(fake, 'package.json'), JSON.stringify({ main: 'index.cjs' })); fs.writeFileSync(path.join(fake, 'index.cjs'), 'module.exports={packJson:(s)=>s};'); fs.writeFileSync(path.join(root, 'probe.mjs'), PROBE); diff --git a/test/request-errors.test.mjs b/test/request-errors.test.mjs index da68c12..7086cf8 100644 --- a/test/request-errors.test.mjs +++ b/test/request-errors.test.mjs @@ -107,6 +107,10 @@ const CASES = [ ['units that are not an object', (r) => { r.units = 'mm'; }, 'wrong_type', '/units', 'must be an object'], ['an unknown profile', (r) => { r.configuration.solver_profile = 'fastest'; }, 'not_allowed', '/configuration/solver_profile', 'must be one of ["fast","balanced","quality","exact_small"]'], + ['an unknown configuration key', (r) => { Object.assign(r.configuration, { top_k: 2, profile: 'balanced' }); }, + 'not_allowed', '/configuration/profile', 'is not a known field'], + ['an unknown effort key', (r) => { r.configuration.effort_budget = { max_nodes: 1 }; }, 'not_allowed', + '/configuration/effort_budget/max_nodes', 'is not a known field'], ['a configuration that is not an object', (r) => { r.configuration = [1]; }, 'wrong_type', '/configuration', 'must be an object'], ['an effort budget that is not an object', (r) => { r.configuration.effort_budget = 5; }, 'wrong_type', @@ -152,8 +156,19 @@ test('null optional fields are absent, and a valid request is admitted', () => { assert.equal(packFallback({ ...base(), units: null }).status, 'feasible'); }); +test('an unknown objective or access direction is refused with not_allowed and its pointer', () => { + const objError = refusalOf((r) => { r.configuration.objective = 'cheapest'; }); + assert.ok(objError instanceof InvalidRequestError); + assert.equal(objError.reason, 'not_allowed'); + assert.equal(objError.field, '/configuration/objective'); + + const dirError = refusalOf((r) => { r.containers[0].access_directions = ['+w']; }); + assert.ok(dirError instanceof InvalidRequestError); + assert.equal(dirError.reason, 'not_allowed'); + assert.equal(dirError.field, '/containers/0/access_directions/0'); +}); + test('an error the rule table does not name still reaches the caller as a request error', () => { - assertRefusedWith((r) => { r.configuration.objective = 'cheapest'; }, 'invalid_request: unknown objective "cheapest"'); assertRefusedWith((r) => { r.items[0].nesting_height = '100'; }, "invalid_request: nesting_height must be at least zero and strictly less than the item's own height"); }); @@ -165,11 +180,9 @@ function assertRefusedWith(edit, message) { } test('an error that carries its own code is not relabelled', () => { - const direction = refusalOf((r) => { r.containers[0].access_directions = ['+w']; }); - assert.equal(direction.code, 'invalid_direction'); - assert.ok(!(direction instanceof InvalidRequestError)); const unsupported = refusalOf((r) => { r.containers[0].pallet_overhang_limit = '10'; }); assert.ok(unsupported instanceof UnsupportedFeatureError); + assert.equal(unsupported.code, 'unsupported_feature'); }); test('an unsupported field is refused ahead of a malformed shape, without crashing on it', () => { diff --git a/test/revisions.test.mjs b/test/revisions.test.mjs index cfc0ec4..ea9f077 100644 --- a/test/revisions.test.mjs +++ b/test/revisions.test.mjs @@ -385,7 +385,7 @@ for (const [name, events, message] of [ ['a sequence beyond 2^53 - 1', [locked({ sequence: 2 ** 53 })], 'event sequence an out-of-range number does not continue the chain at 1'], ['a text sequence', [locked({ sequence: '1' })], 'event sequence "1" does not continue the chain at 1'], ['an extra axis', [{ ...locked(), placement: { ...locked().placement, position: { w: '5' } } }], - 'placement.position does not carry ["w"]'], + 'placement.position cannot carry ["w"]'], ['a boolean axis', [{ ...locked(), placement: { ...locked().placement, position: { x: true } } }], 'placement.position.x is a measure'], ['a null instance', [{ ...locked(), placement: { ...locked().placement, container_instance: null } }], diff --git a/test/sequence.test.mjs b/test/sequence.test.mjs index da3f868..998a52b 100644 --- a/test/sequence.test.mjs +++ b/test/sequence.test.mjs @@ -56,6 +56,19 @@ test('replay independently rejects outside and colliding geometry',()=>{ ],container,[0,1]),SequenceReplayError); }); +test('a built dependency graph is acyclic and a hand-made cycle is not',()=>{ + // Support strictly decreases height, so a graph built from a scene cannot cycle. The + // constructor accepts any adjacency, which is what lets a cycle be asked about at all. + const boxes=[box(0,0,0,10,10,10),box(0,0,10,10,10,10),box(0,0,20,10,10,10)]; + assert.equal(LoadingDependencyGraph.build(boxes).isAcyclic(),true); + assert.equal(UnloadingDependencyGraph.build(boxes).isAcyclic(),true); + assert.equal(new LoadingDependencyGraph([]).isAcyclic(),true); + // A diamond revisits a finished node, which is not a cycle. + assert.equal(new LoadingDependencyGraph([[],[0],[0],[1,2]]).isAcyclic(),true); + assert.equal(new LoadingDependencyGraph([[1],[2],[0]]).isAcyclic(),false); + assert.equal(new UnloadingDependencyGraph([[0]]).isAcyclic(),false); +}); + test('shared fixtures pin the canonical graph and evidence across four languages',(t)=>{ const fixture=new URL('../../../../conformance/scene/sequence-fixtures.json',import.meta.url); if(!existsSync(fixture)){t.skip('the shared cross-language scene fixture is not part of this package');return;}