diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yml b/.github/DISCUSSION_TEMPLATE/q-a.yml index 8ff9dc8..4ad2731 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yml @@ -10,7 +10,7 @@ body: id: package-version attributes: label: Package and version - placeholder: "@packvium/engine 1.9.0 (JavaScript)" + placeholder: "@packvium/engine 1.10.0 (JavaScript)" validations: required: true - type: textarea diff --git a/.github/DISCUSSION_TEMPLATE/show-and-tell.yml b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml index e8258a0..4a20f0a 100644 --- a/.github/DISCUSSION_TEMPLATE/show-and-tell.yml +++ b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml @@ -16,7 +16,7 @@ body: id: package-version attributes: label: Package and version - placeholder: "@packvium/engine 1.9.0 (JavaScript)" + placeholder: "@packvium/engine 1.10.0 (JavaScript)" validations: required: false - type: textarea diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0362d36..d75e278 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,8 +33,8 @@ body: id: version attributes: label: Package version - description: The exact version installed, for example 1.9.0. - placeholder: "1.9.0" + description: The exact version installed, for example 1.10.0. + placeholder: "1.10.0" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 6f093e0..70c818c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -29,7 +29,7 @@ body: id: version attributes: label: Package version - placeholder: "1.9.0" + placeholder: "1.10.0" validations: required: true - type: textarea diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f13914..063c34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,68 @@ 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.10.0] + +Nested packing, overhanging pallets, gusseted bags, 24 rotations for a hull, a declared currency +and an id on every placement. + +### Added + +- **`packNested({ levels, levelBeamWidth })`**, this package's first nested packer: each level is + a JSON request, and each packed container is handed up as one upright item. A `levelBeamWidth` + above 1 (default 1, the greedy chain) carries that many carton sets up a level, so a slightly + worse carton level can win by needing fewer pallets. +- **`container.pallet_overhang_limit`** is honoured instead of refused: a load may pass the deck by + the stated length at each end and width at each side. Positions are measured from the corner of + the overhang zone, only the deck bears weight, and the packed pallet gains + `achieved_dimensions`. Refused beside `access_directions`, `axles`, `made_to_measure`, + `obstacles`, a non-zero `void_fill_reserve_ratio` or any `stop_index`. +- **`deformable_boundary: { kind: 'gusseted_bag', side_gusset_depth }`**: a mailer whose side + gussets let the load's width plus its thickness reach the flat width plus the gusset depth. +- **18 signed rotations for a `convex_hull` item** (`-L-W+H` and the rest), 24 in all, in + `allowed_rotations`, a placement's `orientation`, `fixed_placements` and revision events. A + signed code on any other item is `invalid_request` / `invalid_value`. +- **An optional `currency`** on the request and on each `rate_table`, three capital letters, + echoed as the result's `currency`. It never changes the packing or the score; a rate table in + another currency is `not_allowed`, and one with no request currency is `missing_field`. +- **`placement_id` on every placement**: the canonical JSON of `[container id, x, y, z, + item_type, orientation]`, positions in ticks. A fixed placement or a locked revision event + may quote it, and the execution plan's placement reference copies it. +- **`optimality.lower_bound` and the gap under `open_dimension_height`, `maximum_value` and + `maximum_volume`**, besides `default` and `lowest_cost`; `validate` checks them. +- **A made-to-measure box may keep a `void_fill_reserve_ratio`**: when the box cut tight to the + load would break the reserve, a larger one is cut. +- **`stockCutBoxes`** in `revisions.js`, and the matching `artifact_required` revision issue. +- **`validate` reports `placement_id_mismatch`, `duplicate_placement_id` and + `currency_mismatch`.** + +### Changed + +- **Breaking for requests with a `units` key other than `length`:** it was ignored, so + `units.weight` silently read bare weights as grams; it is now `invalid_request` / `not_allowed` + at `/units/`. Write the unit on each weight instead. +- **`deriveRevision` stocks the boxes a plan already cut:** a locked or verified placement in a + made-to-measure box used to derive a request every engine refused. The box now becomes a stock + container `@#` at its cut size, and `verifyRevisionChain` checks that + revision only when given its approved artifact. +- **`keep_upright` narrows an explicit `allowed_rotations` list** in the fallback and in + `validate`, not only the default list. +- **The fallback breaks rotation ties by the rotation table's order**, not by string order; a hull + answer can move, and three now match the other engines. +- **On the native backend, containers are numbered per type** (`pair#1`, `large#1`) on every + path, as the fallback always did. +- **`FixedPlacementError` has a third reason, `invalid_value`**, for a `placement_id` that is not + the one its entry spells, or a signed orientation on an item that is not a hull. + +### Fixed + +- **An explicit rotation list emptied by `keep_upright` is refused at `allowed_rotations`**, + with the same named request error the independent validator checks. +- **Stocking cut boxes indexes artifact lookups once** and reports malformed consumed + machine fields as structured `invalid_revision` errors. +- **Nested beams keep fixed placements across container offers and handoffs** instead of + aborting when a different carried branch cannot supply a locked item or container. + ## [1.9.0] The fallback now checks what the addon checks, spends an effort budget the same way and reports diff --git a/CITATION.cff b/CITATION.cff index 2e22be0..90efd6f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - name: "Packvium contributors" website: "https://packvium.com" -version: "1.9.0" +version: "1.10.0" license: MIT url: "https://packvium.com" repository-code: "https://github.com/toxakara/packvium-node" diff --git a/README.md b/README.md index e59e44d..b160307 100644 --- a/README.md +++ b/README.md @@ -144,11 +144,13 @@ and `''` means the request as a whole. The message is `: : 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. + (`field` points at the bad value), `invalid_value` (a `placement_id` that is not the one the + entry's own fields spell, or a signed orientation on an item that is not a `convex_hull`) or + `cannot_hold` (the fixed items are not a valid packing on their own; `field` is + `/fixed_placements`). One `catch` for `InvalidRequestError` covers all three. - `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. + request uses a field, or a combination of fields, this engine does not implement yet, such as + `container.pallet_overhang_limit+axles`. 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. @@ -233,21 +235,22 @@ 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) | Six of the seven objectives (all but `maximum_volume`) on scenes where they genuinely disagree about which container to open. | +| [`objectives.mjs`](examples/objectives.mjs) | Six of the seven objectives (all but `maximum_volume`) on scenes where they genuinely disagree about which container to open, a declared `currency` echoed on the answer, and the `optimality.lower_bound` five of them state. | | [`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. | +| [`limits.mjs`](examples/limits.mjs) | Wheel arches as `obstacles` with `additional_boxes`, `max_items` and `tag_limits` per container, what happens when container stock or `max_containers` runs out, a crate overhanging its pallet under `pallet_overhang_limit`, and a flat bag against a gusseted one. | | [`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. | +| [`shapes.mjs`](examples/shapes.mjs) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, two identical wedges that need one of the 18 signed rotations to share it, and a cushion that compresses under load until the crush limit refuses it. | | [`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. | +| [`fixed_placements.mjs`](examples/fixed_placements.mjs) | Pack around items already loaded, lock half of an earlier plan by quoting its placements and their `placement_id`, a fixed set that cannot hold, and an id that names another placement. | | [`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 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. | +| [`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, a hash-chained record that notices an edit, and a box cut to measure (with a void-fill reserve) that a revision turns into stock. | +| [`nested.mjs`](examples/nested.mjs) | `packNested`: cartons, then a pallet, and the carton set the greedy chain picks against the one `levelBeamWidth: 2` finds the pallet can take. | ```bash node examples/basic.mjs @@ -280,15 +283,26 @@ step's placement the way its scene item is named, to match one to the other. ## Features - Exact, deterministic placement with no floating-point geometry decisions. -- Rotation, payload, stackability, support, clearance, obstacle and tag constraints. +- Rotation, payload, stackability, support, clearance, obstacle and tag constraints; a + `convex_hull` item may also take the 18 signed rotations (`-L-W+H` and the rest), 24 in all. +- Pallets a load may overhang (`pallet_overhang_limit`), flat and gusseted mailer bags + (`deformable_boundary`), and boxes cut to measure (`made_to_measure`), with a void-fill + reserve if you want one. +- A declared `currency` on the request and its rate tables, echoed on the result, so a price is + never read in the wrong money. +- A `placement_id` on every placement, the same string in every engine, to key a lock or match a + plan step to the box it places. - Multiple container types and clear explanations for unpacked items. -- A proof, or the distance to one: under the `default` and `lowest_cost` objectives every result - states `optimality.lower_bound`; a complete result that attains it reports `optimal`, and any - other carries the gap to the bound. +- A proof, or the distance to one: under the `default`, `lowest_cost`, `open_dimension_height`, + `maximum_value` and `maximum_volume` objectives every result states `optimality.lower_bound`; a + complete result that attains it reports `proven_optimal`, and any other carries the gap to the bound. - `validate(request, result)`, the reference validator, on every backend: a plain `npm install` checks results without the compiled addon. - JSON input/output through `pack()` or `packJson()`. - Optional payload rebalancing with `rebalanceWeight()`. +- Nested packing with `packNested({ levels, levelBeamWidth })`: cartons, then pallets, each + level a JSON request; a width above 1 carries the best carton sets up a level so the pallet + decides between them. - Loading and removal sequence helpers for already placed boxes. - Execution plans through `buildExecutionPlan()` / `canonicalPlanJson()`: a work-order view of a result, with solver facts separated from the text that cites them. @@ -319,7 +333,7 @@ Documentation, the constraint reference and the benchmarks are at | --- | --- | --- | | Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) | | PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) | -| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.9"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | +| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.10"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | | Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) | | Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) | | PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) | @@ -350,7 +364,7 @@ CodeMeta form. @software{packvium_node, author = {{Packvium contributors}}, title = {Packvium for Node.js}, - version = {1.9.0}, + version = {1.10.0}, license = {MIT}, url = {https://packvium.com} } diff --git a/artifacts.js b/artifacts.js index 74e7738..a0acf46 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.9.0'; +export const SUITE_VERSION = '1.10.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']; @@ -52,7 +52,7 @@ export function buildOperationalArtifact(request, result, { loadingOrders } = {} if (!isMapping(request)) throw refusal('invalid_request', 'a request is a JSON object'); if (!isMapping(result)) throw refusal('invalid_result', 'a result is a JSON object'); requireObjects(result); - const plan = planOf(request, result, loadingOrders); + const plan = planOf(request, withoutPlacementIds(result), loadingOrders); const containers = list(get(result, 'containers'), 'result.containers'); const { lengthUnit, weightUnit } = unitsOf(containers); @@ -94,6 +94,27 @@ function planOf(request, result, loadingOrders) { } } +/** + * The result as the v1 artifact reads it: every placement without its 1.10 `placement_id`. + * v1 is closed, and its embedded plan predates the id; the id stays recoverable from what v1 + * carries -- the canonical JSON of `containers[container_index].id` and the reference's + * `position_ticks`, `item_type` and `orientation` (docs/OPERATIONAL-ARTIFACTS.md). Read after + * `requireObjects`, so every entry is an object; copies only what it changes, O(P). + */ +function withoutPlacementIds(result) { + const containers = list(get(result, 'containers'), 'result.containers'); + return { + ...result, + containers: containers.map((container, index) => ({ + ...container, + placements: placementsOf(index, container).map((placement) => { + const { placement_id: _identifier, ...rest } = placement; + return rest; + }), + })), + }; +} + // ------------------------------------------------------------------------------ provenance function provenanceOf(request, result) { diff --git a/canonical-json.js b/canonical-json.js index 34fe252..61de8b3 100644 --- a/canonical-json.js +++ b/canonical-json.js @@ -37,6 +37,18 @@ export function canonicalJson(value) { return parts.join(''); } +/** + * `canonicalJson` of an array, except that a lone UTF-16 surrogate in a string is written as its + * lowercase `\udxxx` escape instead of refused, as `packvium._canonical_json.canonical_identifier` + * writes it. For an identifier built from caller-chosen ids (`placement_id`, 1.10): a request may + * name a container that way, and an answer must still name its placements. O(length). + */ +export function canonicalIdentifier(values) { + // ES2019's well-formed `JSON.stringify` escapes a lone surrogate as lowercase `\udxxx` and + // every other character exactly as `spellString` does. + return `[${values.map((value) => (typeof value === 'string' ? JSON.stringify(value) : canonicalJson(value))).join(',')}]`; +} + /** * The integer a JSON value is, judged by value as every engine can judge it: `1.0` is `1` * (JavaScript cannot tell them apart once the text is parsed); `true`, `"1"` and `1.5` are diff --git a/codemeta.json b/codemeta.json index e93488d..3322dd5 100644 --- a/codemeta.json +++ b/codemeta.json @@ -3,7 +3,7 @@ "@type": "SoftwareSourceCode", "name": "Packvium for Node.js", "description": "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.", - "version": "1.9.0", + "version": "1.10.0", "license": "https://spdx.org/licenses/MIT", "author": [ { diff --git a/contact-graph.js b/contact-graph.js index 16b10e3..37f566e 100644 --- a/contact-graph.js +++ b/contact-graph.js @@ -58,6 +58,15 @@ function levelIndex(boxes, indices, cell) { function overlapsOnPlane(graph, buckets, cache, plane, box, overlapXY) { const indices = buckets.get(plane); if (!indices) return []; + if (indices.length <= 32) { + const found = []; + for (let i = 0; i < indices.length; i++) { + const other = indices[i]; + const area = overlapXY(graph.boxes[other], box); + if (area > 0) found.push([other, area]); + } + return found; + } let level = cache.get(plane); if (level == null) { level = levelIndex(graph.boxes, indices, graph.cell); @@ -75,6 +84,14 @@ function overlapsOnPlane(graph, buckets, cache, plane, box, overlapXY) { return found; } +export function contactsBelow(graph, box, overlapXY) { + return overlapsOnPlane(graph, graph.byTop, graph.topLevels, box.z, box, overlapXY); +} + +export function contactsAbove(graph, box, overlapXY) { + return overlapsOnPlane(graph, graph.byBottom, graph.bottomLevels, topOf(box), box, overlapXY); +} + /** * `cellHint` is an upper bound on the footprint of any box that may later be appended * with `appendContactBox`. diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 0d40bf9..f0b05e9 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -61,7 +61,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.9.0` freezes the public API. Field names, status codes, the objective +Version `1.10.0` freezes the public API. Field names, status codes, the objective vector, the numeric policy and the validation rules do not change without a major version, so any `1.x` is a safe upgrade from any earlier `1.x`. A caret or tilde constraint on `1.0` is enough; an exact pin is no longer required. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index 7eb418e..c0f98e6 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -32,6 +32,15 @@ pack validly but less densely; any such difference is pinned in `conformance/native-quality-budget.json` rather than left to drift. + **24 rotations for a hull (1.10).** `allowed_rotations` and a placement's `orientation` + take 24 codes: the six legacy codes, unchanged and still the default list, then 18 signed + codes such as `-L-W+H` -- per output axis a sign and the declared side -- that only a + `convex_hull` may list (elsewhere `invalid_request` / `invalid_value` at + `/items//allowed_rotations/`). `keep_upright` keeps the codes whose z entry is `+H`. + Python's `Rotation` enum gains the 18 members in canonical table order (`NL_NW_PH = + "-L-W+H"`, ...), `Rotation.every()` for all 24 and the `is_signed` / `is_upright` + properties; `Rotation.all()` and `Rotation.upright()` keep returning the legacy six and two. + Where they are implemented: a `convex_hull` item's collisions are decided by an exact integer separating-axis test rather than by its box, and its occupied volume is the hull's own — two complementary wedges share a crate that fits one of their bounding boxes. A @@ -54,7 +63,8 @@ statics, rejected as a constraint rather than merely reported), and an optional `rate_table` carrier tariff (ascending `weight_brackets_g` paired with `prices_minor`, an optional `minimum_charge_minor` floor and an optional - `fuel_surcharge_permille`). The rate card is request data rather than a registered + `fuel_surcharge_permille`, and since 1.10 an optional `currency`, which must equal the + request's). The rate card is request data rather than a registered scorer precisely so all four engines can be held to the same price; only the `lowest_landed_cost` objective reads it, and a request that omits it behaves exactly as it did before the field existed. @@ -75,6 +85,14 @@ `billedDimensions()`, Rust `Container::deformable_boundary: Option` with `PackedContainer::achieved_dimensions()` / `billed_dimensions()`, and the TypeScript request type. +- **Gusseted bags (1.10).** `deformable_boundary: {"kind": "gusseted_bag", "side_gusset_depth": G}` + is a flat bag whose side gussets open to depth `G`, a length measure in the request's units, + required for this kind, positive and at most `inner_dimensions.width`; a `flat_bag` refuses the + key as an unknown field. The width girth becomes `b + c <= W + G`; the frame (`b <= W`), the + length girth, billing on `achieved_dimensions`, the refused fields, the solver fallbacks and + the validator issue `bag_girth_exceeded` are the flat bag's. Bottom gussets are not modelled. + Python's `Container` keeps `deformable_boundary: str` (`"gusseted_bag"`) and gains a trailing + `side_gusset_depth: Length | None = None`, refused unless the kind is `gusseted_bag`. - **Made-to-measure containers.** `made_to_measure` (absent or `null`: a stock container) makes the container an on-demand box machine whose largest box is `inner_dimensions`: `minimum` and `increment` per axis (required; `minimum` equal to @@ -87,12 +105,33 @@ and `tare_weight` (declared tare plus the board, rounded up the same way), both inside every objective and `gross_weight`; `achieved_dimensions` reports the extent before rounding. `outer_dimensions` or `deformable_boundary` next to it is `invalid_request` / `not_allowed`; - `axles`, non-empty `obstacles`, a positive `void_fill_reserve_ratio` or a fixed placement in - the container is `unsupported_feature`; a largest box priced above `2^53 - 1` is + `axles`, non-empty `obstacles` or a fixed placement in the container is + `unsupported_feature`. A positive `void_fill_reserve_ratio` is kept in the box that ships: when + the box cut tight to the load would break it, the box is the solving envelope instead. + A largest box priced above `2^53 - 1` is `invalid_value`. The engines expose it as Python `Container.made_to_measure` (a `packvium.MadeToMeasure`, last field), PHP the trailing constructor parameter `?MadeToMeasure $madeToMeasure` (`Packvium\Domain\MadeToMeasure`), Rust `Container::made_to_measure: Option`, and the TypeScript request type. +- **Pallet overhang.** `pallet_overhang_limit: {length, width}` (both required, + non-negative lengths, no default; zero forbids overhang) lets a load pass the deck, the + container's `inner_dimensions`, by `length` at each end and `width` at each side. Positions + are measured from the corner of the overhang zone, so the deck lies at `[length, length + L]` + by `[width, width + W]`, fixed placements included. Only the deck bears weight: a floor + placement's `support_ratio` is the share of its base on the deck, held to + `minimum_support_ratio` and the centre-of-gravity rule like any support. A load the deck + holds is never overhung; one that needs the overhang is seated with the least of it. The + packed pallet reports its declared `inner_dimensions` and `outer_dimensions` (else inner), + `achieved_dimensions` -- the deck grown by the overhang used, at the load's height -- and its + `volume_utilization` against the widened frame; it is billed, and handed to an outer nesting + level, at the larger of its declared footprint and `achieved_dimensions` per horizontal axis + and its declared height. Beside non-empty `access_directions`, `axles`, `made_to_measure`, + non-empty `obstacles`, a positive `void_fill_reserve_ratio` or any item with a `stop_index` + it is `unsupported_feature` (`container.pallet_overhang_limit+`); beside + `deformable_boundary` it is `invalid_request` / `not_allowed`; a frame past the admission + ceiling is `above_maximum`. Python `Container.pallet_overhang_limit` (a + `packvium.OverhangLimit`, last field); the object APIs refuse the same combinations as + `not_allowed`. - `PackingConfig`: solver profile, deadline, alternatives, deterministic seed, clearance, support threshold (`minimum_support_ratio`, default 0: support is not required, and an item may rest on nothing above the floor; set it above 0 to require that share of the @@ -135,7 +174,18 @@ search with `invalid_fixed_placement`. Python `Packer.pack(items, containers, fixed_placements)` and PHP `Packer::pack($items, $containers, $fixedPlacements)` take `FixedPlacement` values; Rust takes them in `PackingRequest.fixed_placements`, and every - JSON entry point reads the field; all four engines implement it. + JSON entry point reads the field; all four engines implement it. Since 1.10 an entry may + restate the result placement's `placement_id`, the canonical JSON of + `["#", x, y, z, "", ""]` with the + position in ticks; any other string is refused as `invalid_fixed_placement` / `invalid_value` at + `/fixed_placements//placement_id`, and `null` is absent. Python's `FixedPlacement` takes it + as a trailing `placement_id: str | None = None`. +- `currency` (1.10): optional ISO 4217 code, three capital letters, that every money + field of the request is in. It is echoed by the result and never priced, converted or + ranked by; a `rate_table.currency` must equal it. Python takes it as the trailing + `currency=None` of `Packer.pack(items, containers, fixed_placements, currency)` and of + `PackingRequest`, and `RateTable` gains a trailing `currency=None`; the object path refuses + it with the JSON path's reason and field. - `catalog_versions_used`: optional immutable catalog references resolved before packing. Each reference has exactly `catalog_id`, positive `version`, non-negative `published_at_epoch_ms`, and `content_digest`; catalog IDs are unique. @@ -163,7 +213,8 @@ request field. extension point remains open: closing the object would make a previously valid producer fail a validator, which is a breaking change. Every engine emits these fields since, on the primary result and on each alternative, for the `default` and - `lowest_cost` objectives; a complete result that attains its bound reports `status: + `lowest_cost` objectives, and since 1.10.0 also for `maximum_value`, + `open_dimension_height` and `maximum_volume`; a complete result that attains its bound reports `status: optimal` and `code: proven_optimal`, and nothing else does; - legacy `status`: `optimal`, `feasible`, `best_found`, `time_limit`, `infeasible`, or `invalid_result`; - packed containers with exact coordinates, dimensions, rotations, and a @@ -179,6 +230,14 @@ request field. - objective score — a five-key lexicographic vector of exact integers, lower is better, computed by the same formula in every implementation — and solver statistics. Under the default objective the keys are `unpacked_count`, `container_count`, `total_cost_minor`, `unused_volume_ppm` (per container `floor((V - used) x 10^6 / V)` of inner volume `V`) and `stack_height_ppm` (per container `floor(top x 10^6 / H)` of inner height `H`); `configuration.objective` selects one of seven orderings (`default`, `lowest_cost`, `shipping_cost`, `lowest_landed_cost`, `open_dimension_height`, `maximum_value`, `maximum_volume`), and the result echoes it in `objective`; - the exact `catalog_versions_used` lineage from the request (an empty array when no catalog was involved), preserved by Python, PHP, Rust and JavaScript; +- a `placement_id` on every placement of the answer and every alternative (1.10): + the canonical JSON of `[container id, x, y, z, item_type, orientation]` with the position in + ticks, for example `["truck#1",0,0,1600000,"crate","LWH"]`, a pure function of the placement; + `validate` reports a missing or wrong one as `placement_id_mismatch` and a repeat as + `duplicate_placement_id`; +- the request's `currency`, present exactly when the request declares one, on the answer and + every alternative (1.10); `validate` reports a missing, extra or different one as + `currency_mismatch`; - warnings and top-K alternatives. With `minimum_support_ratio` at its default 0, a result with a placement resting on nothing above the floor carries one `unsupported_placements: N placement(s) rest on nothing above the container floor; ...` @@ -246,7 +305,10 @@ Metrics are diagnostics: they never participate in feasibility, ranking or the objective vector. `algorithm.time_limit_reached` and `algorithm.effort_limit_reached` identify distinct, -mutually exclusive causes. The former is only a wall-clock deadline. The latter means +mutually exclusive causes. The former is only a wall-clock deadline, read when the start +ends: it says the deadline had passed by then, not that the clock cut work, so a start that +finished its search just as the clock passed reads it too, in every engine, as +`global_deadline_reached`, which requires it, does. The latter means the selected start was stopped by one of the request's counted-work boundaries: the search had more work to do and the budget refused it. A start that finished having used exactly its budget was not stopped, and reports neither flag. No counter in `algorithm.metrics` ends @@ -645,7 +707,10 @@ otherwise refuse**, and the strongest thing it can do is reserve its own slot. A solve cannot honour comes back on `LockedResolve` as unpreserved and names the lock — it is an outcome, not an exception — while a lock set that contradicts itself, by overlapping or by naming an item the request does not contain, is refused with `LockSetError` before any -solve runs. +solve runs. Since 1.10 a lock read by `locks_from_plan` carries its reference's +`placement_id` (`PlacementLock` gains a trailing `placement_id: str | None = None`), and +preservation is checked by it rather than by `container_index`, the emitted container order +a re-solve need not repeat. Python only, and deliberately so: a lock has no representation in the request schema, since that field is held for the next contract freeze, so there is nothing to hand another engine. @@ -702,14 +767,15 @@ approved operational artifact and its events, and calls no solver or clock. | Language | Surface | | --- | --- | -| Python | `packvium.revisions.root_revision(request)`, `.derive_revision(parent, approved_artifact, events)`, `.verify_revision_chain(revisions, artifacts=None)`, `.document_digest(document)`, `.canonical_revision_json(document)`; `packvium.revision_outcomes.record_revision(ledger, revision, decision_id, recorded_at)` | +| Python | `packvium.revisions.root_revision(request)`, `.derive_revision(parent, approved_artifact, events)`, `.apply_events(request, events)`, `.stock_cut_boxes(request, approved_artifact, revision)`, `.verify_revision_chain(revisions, artifacts=None)`, `.document_digest(document)`, `.canonical_revision_json(document)`; `packvium.revision_outcomes.record_revision(ledger, revision, decision_id, recorded_at)` | | Rust | `packvium_core::revisions::root_revision_json(request)`, `derive_revision_json(parent, artifact, events)`, `apply_events_json(request, events)`, `verify_revision_chain_json(revisions, artifacts)`, `document_digest_json(document)`, `canonical_revision_json(document)`; SHA-256 from the `sha2` crate, so the WASM package hashes too | | JavaScript | `rootRevision`, `deriveRevision`, `applyEvents`, `verifyRevisionChain`, `documentDigest`, `canonicalRevisionJson` from `@packvium/engine`'s `revisions.js`; SHA-256 from `node:crypto` | | PHP | `Packvium\Revisions\PlanRevision::root($request)`, `::derive($parent, $approvedArtifact, $events)`, `::verifyChain($revisions, $artifacts)`, `::digest($document)`, `::canonicalJson($document)`, `::parse($json)`. Documents are `stdClass` trees, so `{}` and `[]` keep their digests | Digests are SHA-256 over the RFC 8785 canonical bytes. The audit reports `revision_number`, -`parent_mismatch`, `sequence_gap`, `request_mismatch` and `artifact_mismatch` without -stopping at the first. Refusals use `invalid_revision`, `invalid_event`, `event_conflict`, +`parent_mismatch`, `sequence_gap`, `request_mismatch`, `artifact_mismatch` and +`artifact_required` (a revision that stocks a made-to-measure box, audited without its +artifact) without stopping at the first. Refusals use `invalid_revision`, `invalid_event`, `event_conflict`, `invalid_artifact` and the canonical-form codes. ## Scenario and recommendation API @@ -746,12 +812,10 @@ credited to the treatment. paired cohort is too small. Neither writes to a registry: publishing goes only through `approve_catalog` / `approve_policy`, and a proposal is handed no registry to write to. -**Supported, and not yet frozen.** This surface and `packvium.locks` are reached by -importing the submodule, which puts them outside the frozen API snapshot the packing and -commerce surfaces are held to. They work, they are tested, and they will not be withdrawn -inside 1.x — but a parameter may be renamed or a returned value gain a field in a minor -release, announced in the changelog rather than blocked by a gate. Pin the version if you -depend on the exact shape. +**Frozen since 1.10.0.** The six modules `packvium.simulation`, `packvium.recommendations`, +`packvium.outcomes`, `packvium.holdout`, `packvium.pareto` and `packvium.locks` each declare +`__all__` and are held to the API snapshot, as the packing and commerce surfaces are. From 1.2.0 +to 1.9.0 they were supported but provisional, so a signature could change in a minor release. ## JSON API @@ -762,7 +826,6 @@ Python, PHP, Rust and the JavaScript fallback accept the same top-level keys: `u `units` holds one key, `length`: the unit of every length written without one, millimetres when `units`, or `units.length`, is absent or `null`. There is no request-wide weight unit. A weight takes its unit from the measure itself (`"2.5 kg"`, `{"value": "2.5", "unit": "kg"}`), and a -bare number is grams. `units.weight` is not a field: today every engine ignores it without an -error, so `{"length": "cm", "weight": "kg"}` still reads a bare weight `2` as 2 g. Write the unit -on each weight instead. A future release that changes the request schema will refuse unknown -keys in `units`, as it already refuses them in `configuration`. +bare number is grams. `units` is closed: since 1.10.0 a key other than `length` is refused as +`not_allowed` at `/units/` (docs/ERRORS.md), so `{"length": "cm", "weight": "kg"}`, which +used to read a bare weight `2` as 2 g without a word, is an error. Write the unit on each weight. diff --git a/examples/errors.mjs b/examples/errors.mjs index c5c5ef2..a22e887 100644 --- a/examples/errors.mjs +++ b/examples/errors.mjs @@ -140,10 +140,11 @@ try { 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. +// A field, or a combination of fields, that 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' }; +ahead.containers[0].axles = [{ position: '0' }, { position: '100' }]; try { pack(ahead); } catch (error) { diff --git a/examples/execution.mjs b/examples/execution.mjs index d46801e..4b205dd 100644 --- a/examples/execution.mjs +++ b/examples/execution.mjs @@ -131,8 +131,8 @@ const canonical = canonicalPlanJson(plan); console.log(` canonical form: ${canonical.length} bytes, first 68 of them`); console.log(` ${canonical.slice(0, 68)}...`); console.log(); -console.log(' Run the Python and PHP files on this same scene and you get 1877 bytes, not'); -console.log(` ${canonical.length}. Nothing is broken. The two claims are different, and this is the`); +console.log(' Run the Python and PHP files on this same scene and you get another byte count'); +console.log(' than this one. Nothing is broken. The two claims are different, and this is the'); console.log(' clearest place to see it:'); console.log(); console.log(' * Given the *same result*, all four adapters emit the same bytes. That is what'); diff --git a/examples/fixed_placements.mjs b/examples/fixed_placements.mjs index 0158594..27d43f2 100644 --- a/examples/fixed_placements.mjs +++ b/examples/fixed_placements.mjs @@ -11,7 +11,8 @@ * * `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: + * so a placement from an earlier result can be fixed by quoting it, its `placement_id` + * included. 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`. * @@ -69,7 +70,13 @@ 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. +// Every result placement carries a `placement_id`: the canonical JSON of its container, +// position in ticks, item type and orientation. It names one placement in a result, so it +// is the key to store a lock under or to match a plan step to the box on the van. +console.log(` first placement's id: ${loaded[0].placement_id}`); + +// A result placement already has every field a fixed placement needs. Quote them, the id +// too: the engine checks that the id is the one those fields spell. const locked = loaded.map((placement) => ({ item_type: placement.item_type, container_type: 'van', @@ -80,6 +87,7 @@ const locked = loaded.map((placement) => ({ z: { value: placement.position.z.value, unit: placement.position.z.unit }, }, orientation: placement.orientation, + placement_id: placement.placement_id, })); const lateOrder = { id: 'late-box', quantity: 3, weight: '8 kg', dimensions: { length: '400', width: '400', height: '300' } }; @@ -102,3 +110,20 @@ try { console.log(` ${error.reason} at ${error.field}`); console.log(` ${error.message}`); } + +// ------------------------------------------------------------------------------------ +section('4. A placement id that names another placement is refused'); + +// The id is not a free-form label. Copy the fridge's id onto a box and the entry +// contradicts itself, so it is refused at the id, before any search. +const fridgeId = locked.find((entry) => entry.item_type === 'fridge').placement_id; +const mislabelled = locked.map((entry) => (entry.item_type === 'box' + ? { ...entry, placement_id: fridgeId } : entry)); +try { + pack({ units: { length: 'mm' }, configuration, items: [fridge, box], containers: [van], + fixed_placements: mislabelled }); +} 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 index 69ed1c1..22cf635 100644 --- a/examples/limits.mjs +++ b/examples/limits.mjs @@ -12,10 +12,13 @@ * - `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. + * - `quantity` on a container, and `configuration.max_containers`: how many may be opened; + * - `pallet_overhang_limit` on a pallet: how far a load may pass the deck's edges; + * - `deformable_boundary` on a mailer: a flat or gusseted bag, bounded by its film. * - * 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. + * The first three change *where* things go. The next two decide what happens when there + * is nowhere left, and there the answer is an item left out, never an error. The last two + * move the edge of the container itself. */ import { pack } from '../index.js'; @@ -107,3 +110,47 @@ console.log(' answer across every type. Either way one battery is left out, the 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.'); + +// ------------------------------------------------------------------------------------ +section('4. A load that overhangs its pallet'); + +// A crate 50 mm longer and wider than the deck. Without a limit it does not go on at all. +// `pallet_overhang_limit` lets the load pass the deck by the stated length at each end and +// width at each side. Positions are measured from the corner of that overhang zone, so the +// deck starts at x = 50, y = 50 here, and the crate centred on it sits at (25, 25). +const pallet = { id: 'pallet', inner_dimensions: { length: '1200', width: '800', height: '1000' }, + max_payload: '1000 kg' }; +const palletLoad = [ + { id: 'crate', quantity: 1, weight: '10 kg', allowed_rotations: ['LWH'], + dimensions: { length: '1250', width: '850', height: '400' } }, + { id: 'carton', quantity: 4, weight: '5 kg', dimensions: { length: '400', width: '300', height: '200' } }, +]; +const overhang = { minimum_support_ratio: 0.75 }; +summarize('deck only', solve(palletLoad, [pallet], overhang)); +const overhung = solve(palletLoad, [{ ...pallet, pallet_overhang_limit: { length: '50', width: '50' } }], overhang); +summarize('50 mm overhang allowed', overhung); +const [deck] = overhung.containers; +const crateOnDeck = deck.placements.find((placement) => placement.item_type === 'crate'); +const side = (dimensions) => `${dimensions.length.value} x ${dimensions.width.value}`; +console.log(` crate at (${crateOnDeck.position.x.value}, ${crateOnDeck.position.y.value}), ` + + `support_ratio ${crateOnDeck.support_ratio}: only the deck bears weight`); +console.log(` declared ${side(deck.inner_dimensions)} mm, achieved ${side(deck.achieved_dimensions)} mm`); +console.log('\n The pallet reports its declared size and the size the load reached. It is billed,'); +console.log(' and handed up a nested level, at the larger of the two. A load the deck holds is'); +console.log(' never overhung.'); + +// ------------------------------------------------------------------------------------ +section('5. Flat and gusseted bags'); + +// A mailer bag is bounded by its film, not by rigid walls: the film that wraps round a +// thicker load is film the load cannot spread across, so the load's width plus its +// thickness may not pass the bag's flat width (and its length plus thickness, the flat +// length). A side gusset is film folded into each edge; it opens by `side_gusset_depth` +// and the width rule becomes `load width + thickness <= flat width + gusset`. +const pouch = (deformableBoundary) => ({ id: 'pouch', quantity: 1, deformable_boundary: deformableBoundary, + inner_dimensions: { length: '300', width: '150', height: '100' } }); +const books = [{ id: 'book', quantity: 4, weight: '300 g', allowed_rotations: ['LWH'], + dimensions: { length: '200', width: '100', height: '20' } }]; +summarize('flat bag', solve(books, [pouch({ kind: 'flat_bag' })])); +summarize('25 mm side gusset', solve(books, [pouch({ kind: 'gusseted_bag', side_gusset_depth: '25' })])); +console.log('\n The flat bag closes over two books; the gusset takes a third.'); diff --git a/examples/nested.mjs b/examples/nested.mjs new file mode 100644 index 0000000..1a2731a --- /dev/null +++ b/examples/nested.mjs @@ -0,0 +1,68 @@ +/** + * Nested packing: items into cartons, cartons onto a pallet, in one call. + * + * Run it: + * + * node examples/nested.mjs + * + * Fulfilment is rarely one level. Units go into cartons and cartons go onto a pallet. + * `packNested` runs the levels in order, each an ordinary request, and hands every packed + * container of one level to the next as an item: upright, at its outer dimensions and its + * gross weight, named by its container id. A level that lists no items packs what the + * level before it handed up. + * + * The levels stay independent: a pallet never repacks a carton already taped shut. That + * is exactly why the first answer for the cartons can be the wrong one for the pallet, + * and `levelBeamWidth` is the remedy: it carries that many of the best carton sets up a + * level, and the outermost level's score picks between them. + */ + +import { packNested } 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 sides = (length, width, height) => ({ length, width, height }); + +const levels = [ + { + name: 'carton', + request: { + units: { length: 'mm' }, configuration, + items: [{ id: 'cube', quantity: 2, weight: '1 kg', dimensions: sides('100', '100', '100') }], + // One long carton holds both cubes, so on its own it is the better carton level. + containers: [ + { id: 'long', inner_dimensions: sides('200', '100', '100') }, + { id: 'single', inner_dimensions: sides('100', '100', '100') }, + ], + }, + }, + { + // A narrow, tall stand: two single cartons stack on it, the long carton does not fit + // upright. + name: 'pallet', + request: { + units: { length: 'mm' }, configuration, items: [], + containers: [{ id: 'tower', inner_dimensions: sides('100', '100', '200') }], + }, + }, +]; + +for (const levelBeamWidth of [1, 2]) { + console.log(`levelBeamWidth ${levelBeamWidth}`); + for (const { name, result } of packNested({ levels, levelBeamWidth }).levels) { + const used = result.containers.map((container) => container.id).join(', ') || 'none'; + console.log(` ${name.padEnd(7)} complete ${String(result.complete).padEnd(5)} containers: ${used}`); + for (const unpacked of result.unpacked_items) { + console.log(` left out: ${unpacked.item_id} (${unpacked.reason})`); + } + } +} + +console.log(` + Width 1 is the greedy chain: the long carton wins its own level and then cannot go on + the tower. Width 2 carries both carton sets up, and the one the tower can finish wins. + Each level runs at most levelBeamWidth x (container types + 1) packs, so a wider beam + costs time in proportion to its width, never an exponential search.`); diff --git a/examples/objectives.mjs b/examples/objectives.mjs index 27dfbd0..830c8e3 100644 --- a/examples/objectives.mjs +++ b/examples/objectives.mjs @@ -81,6 +81,22 @@ const cheapPerGram = box('roomy', '400', { const byMoney = { seed: 42, objective: 'lowest_landed_cost', time_limit_ms: SAFETY_FUSE_MS, ...weightPricing }; console.log('lowest_landed_cost ', solve(byMoney, [dearPerGram, cheapPerGram])); +// A rate card is written in one currency, and the request can say which. Declare it on +// the request and on each rate table: the answer echoes it, so a price is never read in +// the wrong money. It never changes the packing or the score; a table in another +// currency than the request's is refused rather than converted. +const inEuros = (rateCurrency) => pack({ + units: { length: 'mm' }, configuration: byMoney, items: widgets, currency: 'EUR', + containers: [{ ...cheapPerGram, rate_table: { ...cheapPerGram.rate_table, currency: rateCurrency } }], +}); +const euros = inEuros('EUR'); +console.log('lowest_landed_cost ', `${euros.currency} ${euros.score[1]} minor units, score=${JSON.stringify(euros.score)}`); +try { + inEuros('USD'); +} catch (refusal) { + console.log('lowest_landed_cost* ', `refused: ${refusal.message}`); +} + // A rate card that stops short of the shipment is a refusal, never a silent clamp to the // top bracket -- you would otherwise be quoted a price the carrier never published. const tooNarrow = box('roomy', '400', { @@ -117,3 +133,23 @@ const scarce = pack({ const kept = scarce.containers.flatMap((c) => c.placements.map((p) => p.item_type)).sort(); const left = (scarce.unpacked_items ?? []).map((u) => u.item_type ?? u.item_id).sort(); console.log('maximum_value ', `packed=${JSON.stringify(kept)} left behind=${JSON.stringify(left)}`); + +// How far from the best possible answer? Under `default`, `lowest_cost`, and since 1.10 +// `open_dimension_height`, `maximum_value` and `maximum_volume`, every result states +// `optimality.lower_bound`: no packing can score below it. An answer that attains it is +// proven best; any other names the first key that falls short and the gap on it. The +// bound is a cheap volume argument, so on the roomy box `default` keeps a gap on unused +// volume that no search could close -- read it as "at most this far", never "this far". +// `shipping_cost` and `lowest_landed_cost` state no bound. The scarce `maximum_value` +// scene attains its bound, its 20 units of gravel left behind being the least any packing +// leaves, and still reads `best_found`: `proven_optimal` is only ever said of a complete +// result. +console.log(); +for (const objective of ['default', 'open_dimension_height', 'maximum_volume']) { + const { optimality } = pack({ units: { length: 'mm' }, items: widgets, containers: [snug, roomy], + configuration: { seed: 42, objective, time_limit_ms: SAFETY_FUSE_MS } }); + console.log(`bound ${objective.padEnd(22)}`, `${optimality.code} lower_bound=${JSON.stringify(optimality.lower_bound)} ` + + `gap on key ${optimality.gap_key}: ${optimality.absolute_gap}`); +} +console.log(`bound ${'maximum_value'.padEnd(22)}`, `${scarce.optimality.code} lower_bound=${JSON.stringify(scarce.optimality.lower_bound)} ` + + `score=${JSON.stringify(scarce.score)}`); diff --git a/examples/revisions.mjs b/examples/revisions.mjs index 20f87aa..2ff7154 100644 --- a/examples/revisions.mjs +++ b/examples/revisions.mjs @@ -121,3 +121,59 @@ console.log(` An edited request no longer equals what its parent's request and its own events produce, and any later revision would stop naming it as a parent. An event that contradicts the request is refused by name rather than applied as a best guess.`); + +// -------------------------------------------------------------------------------------- +section('5. A box already cut to measure becomes stock'); + +// A box machine cuts each carton to its load. `void_fill_reserve_ratio` keeps a share of +// the box free for dunnage: when the box cut tight to the load would break it, the +// machine cuts a larger one instead. +const machine = (reserve) => ({ + id: 'machine', quantity: 3, cost_minor: 40, + inner_dimensions: { length: '610', width: '610', height: '610' }, + made_to_measure: { + minimum: { length: '60', width: '60', height: '60' }, + increment: { length: '25', width: '25', height: '25' }, + cost_per_square_metre_minor: 1000, + outer_allowance: { length: '3', width: '3', height: '3' }, + }, + void_fill_reserve_ratio: reserve, +}); +const cutRequest = (reserve) => ({ + ...request, + items: [{ id: 'cube', quantity: 4, weight: '1 kg', dimensions: { length: '100', width: '100', height: '100' } }], + containers: [machine(reserve)], +}); +const cutSize = (result) => { + const { length, width, height } = result.containers[0].inner_dimensions; + return `${length.value} x ${width.value} x ${height.value} mm`; +}; +console.log(` no reserve: cut ${cutSize(pack(cutRequest(0)))}`); +const cutRoot = rootRevision(cutRequest(0.3)); +const cutResult = pack(structuredClone(cutRoot.request)); +console.log(` 30% reserve: cut ${cutSize(cutResult)}`); + +// The box is cut and a cube is already in it when the order changes. A revision that +// locks the cube cannot ask the machine to cut that box again: it turns the box into a +// stock container named `@#`, at the size, price and tare the +// approved artifact recorded, and takes it from the machine's stock. +const cutApproved = buildOperationalArtifact(cutRoot.request, cutResult); +const inBox = cutResult.containers[0].placements[0]; +const cutRevision = deriveRevision(cutRoot, cutApproved, [{ sequence: 1, type: 'placement_locked', placement: { + item_type: 'cube', container_type: 'machine', container_instance: 1, + position: { x: inBox.position.x.value, y: inBox.position.y.value, z: inBox.position.z.value }, + orientation: inBox.orientation, placement_id: inBox.placement_id, +} }]); +for (const container of cutRevision.request.containers) { + console.log(` container ${container.id.padEnd(11)} quantity ${container.quantity}` + + `${container.made_to_measure ? ', cut to measure' : ''}`); +} +const restocked = pack(structuredClone(cutRevision.request)); +console.log(` replan: ${restocked.containers.map((container) => `${container.id} holding ` + + `${container.placements.length}, ${container.placements.filter((placement) => placement.fixed).length} fixed`).join('; ')}`); +console.log(` audit with the artifact: ${codes(verifyRevisionChain([cutRoot, cutRevision], [null, cutApproved]))}`); +console.log(` audit without the artifact: ${codes(verifyRevisionChain([cutRoot, cutRevision]))}`); +console.log(` + The size of a box already cut is read from the approved artifact, so the audit can + only confirm that revision when it is given the artifact, and says so by name rather + than passing it unchecked. \`stockCutBoxes\` is the same step on its own.`); diff --git a/examples/serialization.mjs b/examples/serialization.mjs index a9ef730..d5b61d2 100644 --- a/examples/serialization.mjs +++ b/examples/serialization.mjs @@ -101,13 +101,16 @@ try { 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. +// A field or combination 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' }; })); + packJson(variant((request) => { + request.containers[0].pallet_overhang_limit = { length: '50', width: '50' }; + request.containers[0].axles = [{ position: '0' }, { position: '100' }]; + })); } catch (error) { if (!(error instanceof UnsupportedFeatureError)) throw error; - console.log(` reserved, unimplemented -> ${error.code}: ${error.fields.join(', ')}`); + console.log(` not implemented yet -> ${error.code}: ${error.fields.join(', ')}`); } // And text that is not JSON at all never reaches the engine. diff --git a/examples/shapes.mjs b/examples/shapes.mjs index ec9da3f..8dc96b8 100644 --- a/examples/shapes.mjs +++ b/examples/shapes.mjs @@ -87,6 +87,48 @@ summarise('as hulls', { // One crate instead of two, for the same goods and the same crate. Nothing about the // request changed except the claim that the items are wedges rather than blocks. +// --------------------------------------------------------- 24 rotations (1.10) +// +// Now two *identical* lower wedges. The six legacy codes turn an item so that the void +// beside the first wedge is always on the wrong side for the second: what it needs is the +// wedge spun half a turn about the vertical, and no swap of length, width and height does +// that. A hull may list the 18 signed codes too -- `-L-W+H` reads "length along -x, width +// along -y, height along +z" -- and with them the pair shares the crate. +// +// Signed codes are proper rotations, never mirror images, so a left-handed part is still +// never packed as a right-handed one. They are opt-in: a hull that lists no signed code is +// packed as before, and a signed code on a box is refused, because a box turned 180 degrees +// occupies exactly what it did. + +const LEGACY_ROTATIONS = ['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']; +const SIGNED_ROTATIONS = [ + '+L-W-H', '-L+W-H', '-L-W+H', '+L-H+W', '+L+H-W', '-L-H-W', + '+W-L+H', '+W+L-H', '-W-L-H', '+W-H-L', '-W+H-L', '-W-H+L', + '+H-L-W', '-H+L-W', '-H-L+W', '+H-W+L', '+H+W-L', '-H-W-L', +]; + +const twoLowerWedges = (allowedRotations) => pack({ + ...MM, + ...SAFETY_FUSE, + items: [{ ...wedge('wedge', LOWER_WEDGE), quantity: 2, allowed_rotations: allowedRotations }], + containers: [{ ...crate('100', '100', '100')[0], quantity: 1 }], +}); + +console.log('\n24 rotations -- two copies of the same wedge'); +for (const [label, rotations] of [['6 legacy codes', LEGACY_ROTATIONS], + ['all 24 codes', [...LEGACY_ROTATIONS, ...SIGNED_ROTATIONS]]]) { + const result = twoLowerWedges(rotations); + const orientations = result.containers.flatMap((c) => c.placements.map((p) => p.orientation)); + console.log(` ${label.padEnd(22)} ${result.summary.packed_item_count} placed, ` + + `orientations ${JSON.stringify(orientations)}`); +} +try { + pack({ ...MM, ...SAFETY_FUSE, items: [{ ...wedge('block', null), allowed_rotations: ['-L-W+H'] }], + containers: crate('100', '100', '100') }); +} catch (refusal) { + console.log(` ${'signed code on a box'.padEnd(22)} ${refusal.message}`); +} + // ----------------------------------------------------------------- compressible // // `compression_ratio` is the fraction of its own height an item may lose when something diff --git a/execution.d.ts b/execution.d.ts index 8ed28e3..1afe11a 100644 --- a/execution.d.ts +++ b/execution.d.ts @@ -14,7 +14,8 @@ export class ExecutionPlanError extends Error {} export interface PositionTicks{x:number;y:number;z:number} /** How a step names a placement. Ticks are the exact integer coordinate, never a rendering. */ -export interface PlacementReference{container_index:number;item_type:string;orientation:string;position_ticks:PositionTicks} +/** `placement_id` (1.10) is copied from the result placement when it carries one; `placementReference` alone never sets it. */ +export interface PlacementReference{container_index:number;item_type:string;orientation:string;position_ticks:PositionTicks;placement_id?:string} /** `sequence` is present only when a loading order was supplied; it is never derived. */ export interface ExecutionStep{sequence?:number;placement:PlacementReference} diff --git a/execution.js b/execution.js index 7d70ed1..03e0cf6 100644 --- a/execution.js +++ b/execution.js @@ -68,6 +68,28 @@ export function placementReference(containerIndex, placement) { }; } +/** How Python's `repr` spells the values a malformed `placement_id` can be. */ +const reprOf = (value) => { + if (value === null) return 'None'; + if (typeof value === 'boolean') return value ? 'True' : 'False'; + return typeof value === 'string' ? `'${value}'` : JSON.stringify(value); +}; + +/** + * `placementReference` plus the result placement's `placement_id` (1.10), copied rather than + * derived, when the result carries one. A canonical projection (a golden) has no ids, so its + * plan keeps the bytes it always had. + */ +function stepReference(containerIndex, placement) { + const reference = placementReference(containerIndex, placement); + if (!Object.prototype.hasOwnProperty.call(placement, 'placement_id')) return reference; + const identifier = placement.placement_id; + if (typeof identifier !== 'string' || !identifier) { + throw new ExecutionPlanError(`placement_id ${reprOf(identifier)} is not a non-empty string`); + } + return { ...reference, placement_id: identifier }; +} + /** * The operator sequence for one container, or an honest absence of one. * @@ -80,7 +102,7 @@ function steps(containerIndex, placements, loadingOrder) { return { order: 'unavailable', steps: placements.map((placement) => ({ - placement: placementReference(containerIndex, placement), + placement: stepReference(containerIndex, placement), })), }; } @@ -95,7 +117,7 @@ function steps(containerIndex, placements, loadingOrder) { order: 'loading', steps: loadingOrder.map((index, step) => ({ sequence: step + 1, - placement: placementReference(containerIndex, placements[index]), + placement: stepReference(containerIndex, placements[index]), })), }; } diff --git a/fallback.js b/fallback.js index baa81bb..266e48d 100644 --- a/fallback.js +++ b/fallback.js @@ -1,15 +1,18 @@ -import { jsonInteger, jsonSpelling } from './canonical-json.js'; -import { appendContactBox, buildContactGraph } from './contact-graph.js'; +import { canonicalIdentifier, jsonInteger, jsonSpelling } from './canonical-json.js'; +import { appendContactBox, buildContactGraph, contactsAbove, contactsBelow } from './contact-graph.js'; import { CommerceInputError, compareCodePoints } from './commerce-model.js'; import { parsePolicy, policyRejection, provesUnplaceable, tagOccurrences } from './policy.js'; -import { InvalidRequestError, checkRequest, pointer } from './request-errors.js'; +import { InvalidRequestError, ORIENTATION_TABLE, ROTATIONS, checkRequest, hullItemTypes, itemRotations, pointer, refuseOrientation } from './request-errors.js'; import { fabricate, solvingEnvelopes } from './made-to-measure.js'; +import { achievedDimensions, billedDimensions, centringOffset, deckAlone, deckContactArea, deckSlab, declaredInner, movedPlacements, solvingFrame } from './pallet-overhang.js'; export { InvalidRequestError }; const LEN={mm:16000,millimeter:16000,millimeters:16000,cm:160000,m:16000000,in:406400,inch:406400,inches:406400,ft:4876800,tick:1,ticks:1}; const WT={g:8000000,kg:8000000000,mg:8000,lb:3628738960,lbs:3628738960,oz:226796185,tick:1,ticks:1}; -const ROT={LWH:[0,1,2],LHW:[0,2,1],WLH:[1,0,2],WHL:[1,2,0],HLW:[2,0,1],HWL:[2,1,0]}; +// Each code's source axes and signs, and its index in the canonical table (the tie-break order). +const ROT=Object.fromEntries(ORIENTATION_TABLE.map(([code,axes])=>[code,axes])),ROT_SIGNS=Object.fromEntries(ORIENTATION_TABLE.map(([code,,signs])=>[code,signs])); +const ROT_INDEX=Object.fromEntries(ORIENTATION_TABLE.map(([code],index)=>[code,index])); const SUPPORT_SCALE=1000000; /** A ratio in parts per million by the published rule, `floor(ratio * 1000000 + 0.5)`, Python's * `scaled_ratio`. */ @@ -44,12 +47,10 @@ const UNSUPPORTED={ // in, the last engine to gain both the solver behaviour and the independent // validation the staged rollout requires. item:[], - // `pallet_overhang_limit` was reserved in the schema by at the 1.1.0 contract - // freeze and is refused everywhere until an engine implements it from a request: a field - // a caller can set and the solver ignores is worse than a refusal. // `access_directions` left this list in, which wired the reserved field through - // to the stop-accessibility rule in all four engines at once. - container:['pallet_overhang_limit'], + // to the stop-accessibility rule in all four engines at once, and `pallet_overhang_limit` + // in, which did the same for the pallet deck. + container:[], obstacle:[], // `item.shape_type` values this engine does not implement. Presence is the // wrong test for this one field: `rigid_cuboid` is the default and is implemented, so a @@ -136,11 +137,23 @@ function madeToMeasureCombinations(req){const fields=[],measured=new Set(); for(const raw of objectsIn(req.containers)){if(raw.made_to_measure==null)continue; if(typeof raw.id==='string')measured.add(raw.id); if(raw.axles!=null)fields.push('container.made_to_measure+axles'); - if(Array.isArray(raw.obstacles)&&raw.obstacles.length)fields.push('container.made_to_measure+obstacles'); - if(typeof raw.void_fill_reserve_ratio==='number'&&raw.void_fill_reserve_ratio>0)fields.push('container.made_to_measure+void_fill_reserve_ratio')} + if(Array.isArray(raw.obstacles)&&raw.obstacles.length)fields.push('container.made_to_measure+obstacles')} if(objectsIn(req.fixed_placements).some(entry=>typeof entry.container_type==='string'&&measured.has(entry.container_type))) fields.push('fixed_placements+container.made_to_measure'); return fields} +// Obstacles and axles are positioned in the declared frame the widened frame moves; +// doors and route corridors would need a wall the overhang crosses; a reserve is a share of a +// volume the limit leaves undefined; a made-to-measure box has no deck. A flat bag beside the +// limit is a request error instead. Checked on the raw request. O(items + containers). +function palletOverhangCombinations(req){const fields=[];let pallets=false; + for(const raw of objectsIn(req.containers)){if(raw.pallet_overhang_limit==null)continue;pallets=true; + if(Array.isArray(raw.access_directions)&&raw.access_directions.length)fields.push('container.pallet_overhang_limit+access_directions'); + if(raw.axles!=null)fields.push('container.pallet_overhang_limit+axles'); + if(raw.made_to_measure!=null)fields.push('container.pallet_overhang_limit+made_to_measure'); + if(Array.isArray(raw.obstacles)&&raw.obstacles.length)fields.push('container.pallet_overhang_limit+obstacles'); + if(typeof raw.void_fill_reserve_ratio==='number'&&raw.void_fill_reserve_ratio>0)fields.push('container.pallet_overhang_limit+void_fill_reserve_ratio')} + if(pallets&&objectsIn(req.items).some(item=>item.stop_index!=null))fields.push('container.pallet_overhang_limit+item.stop_index'); + return fields} function rejectUnsupported(req){const fields=[]; for(const key of UNSUPPORTED.request)if(hasOwn(req,key))fields.push(key); for(const key of UNSUPPORTED.configuration)if(isObject(req.configuration)&&hasOwn(req.configuration,key))fields.push(`configuration.${key}`); @@ -151,7 +164,7 @@ function rejectUnsupported(req){const fields=[]; } for(const raw of objectsIn(req.items)){const shape=raw.shape_type; if(typeof shape==='string'&&UNSUPPORTED.shapeType.includes(shape))fields.push(`item.shape_type=${shape}`)} - fields.push(...madeToMeasureCombinations(req)); + fields.push(...madeToMeasureCombinations(req),...palletOverhangCombinations(req)); if(fields.length)throw new UnsupportedFeatureError([...new Set(fields)].sort()); } /** @@ -166,20 +179,20 @@ export class FixedPlacementError extends InvalidRequestError{ constructor(detail,reason='cannot_hold',field='/fixed_placements'){super(reason,field,detail);this.name='FixedPlacementError'} } const malformed=(detail,field)=>new FixedPlacementError(detail,'malformed',field); -const FIXED_REQUIRED=['item_type','container_type','orientation'],FIXED_FIELDS=[...FIXED_REQUIRED,'container_instance','position'],AXES=['x','y','z']; +const FIXED_REQUIRED=['item_type','container_type','orientation'],FIXED_FIELDS=[...FIXED_REQUIRED,'container_instance','position','placement_id'],AXES=['x','y','z']; const unknownKeys=(value,allowed)=>Object.keys(value).filter(key=>!allowed.includes(key)).sort(compareCodePoints); /** * The request's `fixed_placements` as JSON gave them, or a refusal naming the first entry * that is not the schema's shape. Nothing is coerced: `"1"` and `true` are not instances, and * a list is not a position. An absent or null field is no fixed placements. O(f). */ -function requireFixedPlacementShapes(raw,u){ +function requireFixedPlacementShapes(raw,u,hullTypes=new Set()){ if(raw==null)return []; if(!Array.isArray(raw))throw malformed('fixed_placements is a list','/fixed_placements'); - raw.forEach((entry,index)=>requireFixedEntryShape(entry,`fixed_placements[${index}]`,pointer('fixed_placements',index),u)); + raw.forEach((entry,index)=>requireFixedEntryShape(entry,`fixed_placements[${index}]`,pointer('fixed_placements',index),u,hullTypes)); return raw } -function requireFixedEntryShape(entry,where,field,u){ +function requireFixedEntryShape(entry,where,field,u,hullTypes){ if(!isObject(entry))throw malformed(`${where} is an object`,field); const unknown=unknownKeys(entry,FIXED_FIELDS); if(unknown.length)throw malformed(`${where} cannot carry ${jsonSpelling(unknown)}`,field); @@ -187,12 +200,38 @@ function requireFixedEntryShape(entry,where,field,u){ if(missing.length)throw malformed(`${where} needs ${jsonSpelling(missing)}`,field); for(const name of ['item_type','container_type']) if(typeof entry[name]!=='string'||!entry[name])throw malformed(`${where}.${name} is a non-empty string`,`${field}/${name}`); - if(!Object.keys(ROT).includes(entry.orientation))throw malformed(`${where}.orientation must be one of ${jsonSpelling(Object.keys(ROT))}`,`${field}/orientation`); + requireFixedOrientation(entry.orientation,hullTypes.has(entry.item_type),where,`${field}/orientation`); const instance=fixedInstance(entry); if(instance===null||instance<1)throw malformed(`${where}.container_instance counts from 1`,`${field}/container_instance`); const position=hasOwn(entry,'position')?entry.position:{}; requirePointShape(position,`${where}.position`,`${field}/position`,malformed); for(const axis of AXES)if(hasOwn(position,axis))requireCoordinate(position[axis],`${where}.position.${axis}`,`${field}/position/${axis}`,u); + const ticks=AXES.map(axis=>scalar(hasOwn(position,axis)?position[axis]:0,u,LEN)); + requirePlacementId(entry.placement_id,`${entry.container_type}#${instance}`,ticks,entry.item_type,entry.orientation,where,field); +} +/** + * A placement's cross-language id (1.10), `packvium.models.placement_id`: the canonical JSON + * text of `[container id, x, y, z, item type, orientation]` with the physical position in + * ticks. A pure function of the placement, so every validator recomputes it. O(length of ids). + */ +const placementId=(containerId,[x,y,z],itemType,orientation)=>canonicalIdentifier([containerId,x,y,z,itemType,orientation]); +/** A fixed placement's optional `placement_id` (1.10) restates the id its result placement will + * carry; null is absent. A non-string is `malformed`; a string naming another placement is + * `invalid_value`. A coordinate past 2^53 - 1 has no canonical spelling and no container holds + * it, so admission refuses that entry as `outside_container` and the id is not judged. O(1). */ +function requirePlacementId(value,containerId,ticks,itemType,orientation,where,field){ + if(value==null)return; + const at=`${field}/placement_id`; + if(typeof value!=='string')throw malformed(`${where}.placement_id is a string`,at); + if(Math.max(...ticks)>Number.MAX_SAFE_INTEGER)return; + const expected=placementId(containerId,ticks,itemType,orientation); + if(value!==expected)throw new FixedPlacementError(`${where}.placement_id must be ${jsonSpelling(expected)}`,'invalid_value',at); +} +/** A fixed orientation from its item's vocabulary (1.10): an unknown code is `malformed` with the + * vocabulary listed -- six codes for a box, as in every release before -- and a signed code + * fixing a non-hull is `invalid_value`. Whether the item allows it is admission's rule. O(1). */ +function requireFixedOrientation(value,hull,where,field){ + refuseOrientation(value,hull,field,(reason,at,detail)=>new FixedPlacementError(detail,reason==='not_allowed'?'malformed':reason,at),`${where}.orientation`); } // Parsed here, before admission, so a coordinate the length parser cannot read is refused as // the entry's own fault, naming the value, rather than surfacing later as a bare parse error. @@ -224,7 +263,7 @@ export function requirePointShape(point,where,field,error){ * collision sweep. It runs once per solve frame, before search. */ function admitFixedPlacements(req,{items,templates,u,clear}){ - const entries=requireFixedPlacementShapes(req.fixed_placements,u);if(!entries.length)return []; + const entries=requireFixedPlacementShapes(req.fixed_placements,u,hullItemTypes(req.items));if(!entries.length)return []; const rawItems=new Map((req.items??[]).map(raw=>[raw.id,raw])),byId=new Map(templates.map(t=>[t.id,t])); const instances=new Map(items.map(item=>[item.id,item])),order=new Map((req.containers??[]).map((raw,index)=>[raw.id,index])); const parsed=entries.map(entry=>{const point=hasOwn(entry,'position')?entry.position:{}; @@ -233,7 +272,7 @@ function admitFixedPlacements(req,{items,templates,u,clear}){ for(const entry of parsed){const raw=rawItems.get(entry.itemType); if(raw===undefined)throw new FixedPlacementError(`unknown item type ${jsonSpelling(entry.itemType)}`); if(!byId.has(entry.containerType))throw new FixedPlacementError(`unknown container type ${jsonSpelling(entry.containerType)}`); - if(!(raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT))).includes(entry.orientation)) + if(!itemRotations(raw).includes(entry.orientation)) throw new FixedPlacementError(`${entry.itemType} may not be placed in orientation ${entry.orientation}`)} const taken=new Map(),assigned=parsed.map(entry=>{const count=(taken.get(entry.itemType)??0)+1,quantity=rawItems.get(entry.itemType).quantity??1;taken.set(entry.itemType,count); if(count>quantity)throw new FixedPlacementError(`${count} ${entry.itemType} fixed, ${quantity} requested`); @@ -250,7 +289,7 @@ function admitFixedPlacements(req,{items,templates,u,clear}){ requireContiguousInstances(groups,req.configuration?.max_containers??null); // Support is measured against every other fixed item in the container, not only the ones // listed before it: the set is one arrangement, and listing order is not physics. - for(const group of groups)for(const placement of group.placements)placement.fixedSupport=placement.z===0?[1,1] + for(const group of groups)for(const placement of group.placements)placement.fixedSupport=placement.z===0?floorSupport(placement,group.tmpl.deck) :[directSupporters(placement,group.placements).reduce((total,support)=>total+support.area,0),placement.ed[0]*placement.ed[1]]; const issue=fixedSetIssue(groups,req,clear); if(issue!==null)throw new FixedPlacementError(issue); @@ -287,7 +326,7 @@ function fixedSetIssue(groups,req,clear){ if(item.raw.must_be_on_floor&&p.z!==0)return `must_be_on_floor: ${id}: `; if(item.eligibleTags.length&&!item.eligibleTags.some(tag=>(tmpl.tags??[]).includes(tag)))return `container_ineligible: ${id}`; if(compatibility){const rule=compatibilityIssue(item,others,tmpl);if(rule!==null)return `${rule[0]}: ${id}: ${rule[1]}`} - if(support){const rule=supportIssue(p,others,globalRatio,globalPpm);if(rule!==null)return `${rule[0]}: ${id}: ${rule[1]}`} + if(support){const rule=supportIssue(p,others,globalRatio,globalPpm,tmpl.deck);if(rule!==null)return `${rule[0]}: ${id}: ${rule[1]}`} if(stack){const lower=graph.supporters[index].find(([k])=>stackingRefusal(placements[k].item,item)!==null); if(lower!==undefined)return `${stackingRefusal(placements[lower[0]].item,item)}: ${id}: ${placements[lower[0]].item.raw.id}`; const upper=refusesLoad(item)?graph.children[index].find(k=>stackingRefusal(item,placements[k].item)!==null):undefined; @@ -311,7 +350,8 @@ function fixedSetIssue(groups,req,clear){ function bagExtent(placements){let a=0,b=0,c=0; for(const p of placements){a=Math.max(a,p.x+p.ed[0]);b=Math.max(b,p.y+p.ed[1]);c=Math.max(c,p.z+p.ed[2])} return [a,b,c]} -function bagAdmits(tmpl,placements){const [a,b,c]=bagExtent(placements);return a+c<=tmpl.d[0]&&b+c<=tmpl.d[1]} +// 1.10: a gusseted bag's side gusset G widens the width girth to b + c <= W + G; G = 0 is the flat bag. +function bagAdmits(tmpl,placements){const [a,b,c]=bagExtent(placements);return a+c<=tmpl.d[0]&&b+c<=tmpl.d[1]+tmpl.gusset} function compatibilityIssue(item,others,tmpl){ if(item.tags.length||item.incompatible.length)for(const other of others) if(item.incompatible.some(tag=>other.item.tags.includes(tag))||other.item.incompatible.some(tag=>item.tags.includes(tag))) @@ -321,9 +361,12 @@ function compatibilityIssue(item,others,tmpl){ if(count+1>limit)return ['tag_count_exceeded',`${tag}: limit ${limit}, would be ${count+1}`]} return null } -function supportIssue(p,others,globalRatio,globalPpm){ +// On a pallet's floor the deck is the one supporter, judged by the ratio and centroid +// rule alone; the ground-contact rules name supporting items, and the floor is none. +function supportIssue(p,others,globalRatio,globalPpm,deck=null){ const rule=p.item.groundRule,required=Math.max(globalRatio,p.item.raw.minimum_support_ratio??0); - if(p.z===0||((rule==null||rule==='free')&&required<=0))return null; + if(p.z===0)return deck==null||required<=0?null:ratioAndCentroidIssue(p,[deckSupport(deck,p)],required,globalPpm); + if((rule==null||rule==='free')&&required<=0)return null; const supporters=directSupporters(p,others),count=supporters.length; if(rule==='covered'){const surfaces=others.filter(o=>o.z+o.ed[2]===p.z),x2=p.x+p.ed[0],y2=p.y+p.ed[1]; if(![[p.x,p.y],[x2,p.y],[p.x,y2],[x2,y2]].every(([x,y])=>surfaces.some(s=>s.x<=x&&x<=s.x+s.ed[0]&&s.y<=y&&y<=s.y+s.ed[1]))) @@ -331,6 +374,9 @@ function supportIssue(p,others,globalRatio,globalPpm){ if(rule==='single'&&count!==1)return ['ground_contact_violation',`single: rests on ${count} item(s)`]; if(rule==='multiple'&&count<2)return ['ground_contact_violation',`multiple: rests on ${count} item(s)`]; if(required<=0)return null; + return ratioAndCentroidIssue(p,supporters,required,globalPpm) +} +function ratioAndCentroidIssue(p,supporters,required,globalPpm){ const area=supporters.reduce((total,support)=>total+support.area,0),base=p.ed[0]*p.ed[1]; if(areascalar(value,unit,LEN); +export const weightTicks=value=>scalar(value,'g',WT); function outputLengthUnit(unit){const multiplier=LEN[unit.trim().toLowerCase()];return ['mm','cm','m','in','ft'].find(code=>LEN[code]===multiplier)??'mm'} function dims(v,u){return [scalar(v.length,u,LEN),scalar(v.width,u,LEN),scalar(v.height,u,LEN)]} /** @@ -563,7 +613,7 @@ export function __inspectHullShapeForTests(vertices){ return {volume:shape.volume.toString(),faceAxes:copy(shape.faces),edgeDirections:copy(shape.edges)}; } /** How many rotated hulls stay resident, before the memo is dropped and refilled. A request is - * bounded by its distinct hull items times the six orientations, so this holds far more than + * bounded by its distinct hull items times the orientations they allow (at most 24), so this holds far more than * any request the solver is sized for -- and bounded, rather than growing for the life of the * process. */ const SHAPE_CACHE_ENTRIES=1024; @@ -783,9 +833,59 @@ function objectiveBounds(instances,containers){ return objectiveBoundsFromRuns(boundRuns(instances.map(i=>[i.volume,1n])), boundRuns(instances.map(i=>[i.weight,1n])),instances.some(i=>i.shrinks),containers); } -/** Where each covered objective reads the five bounds: the position in the default-order - * vector of each of the objective's own score keys. Any other objective has no bound. */ -const BOUND_ORDERS=new Map([['default',[0,1,2,3,4]],['lowest_cost',[0,2,1,3,4]]]); +/** Where each covered objective reads its bounds: the position in the default-order vector of + * each of the objective's own score keys, or the name of the one key only that objective + * ranks by. Any other objective has no bound. */ +const BOUND_ORDERS=new Map([['default',[0,1,2,3,4]],['lowest_cost',[0,2,1,3,4]], + ['maximum_value',[0,'value_forgone',1,2,3]],['open_dimension_height',[0,'achieved_height',1,2,3]], + ['maximum_volume',['unpacked_volume_ppm',0,1,2,3]]]); +/** The sum of the `count` smallest values of unsorted `[value, count]` runs, without sorting + * them: a max-heap holds only the runs the `count` smallest can still reach, and the run on + * top contributes just the part of it they need. The bound already paid for its only sorts + * (finding F6 doubled that cost once), so this is `O(t log k)` for the `k <= t` runs held. */ +function runSmallestUnsorted(pairs,count){ + if(count<=0n)return 0n; + const heap=[];let held=0n,total=0n; + const swap=(a,b)=>{const kept=heap[a];heap[a]=heap[b];heap[b]=kept}; + const sink=()=>{for(let at=0;;){const l=2*at+1,r=l+1;let top=at; + if(lheap[top][0])top=l;if(rheap[top][0])top=r; + if(top===at)return;swap(at,top);at=top}}; + for(const pair of pairs){ + if(pair[1]<=0n||(held>=count&&pair[0]>=heap[0][0]))continue; + heap.push(pair);held+=pair[1];total+=pair[0]*pair[1]; + for(let at=heap.length-1;at>0;){const parent=(at-1)>>1;if(heap[parent][0]>=heap[at][0])break;swap(at,parent);at=parent} + while(held-heap[0][1]>=count){const [value,taken]=heap[0];held-=taken;total-=value*taken;heap[0]=heap[heap.length-1];heap.pop();sink()} + } + return held>count?total-(held-count)*heap[0][0]:total; +} +/** The bound on the one key an objective ranks by and the default does not, each + * conditional on `L0` as the five are: the value and height terms hold for every packing that + * leaves exactly `unpacked` instances behind, and the volume term leads its objective and + * holds for every packing. Values are non-negative, so leaving the cheapest is the best any + * packing does; a container holding volume `v` on floor `A` stands at least `v / A` tall; and + * whatever is packed fits the usable inventory. An instance that occupies less than its box + * drops both volume terms. No term sorts: the volume runs are the ones the five bounds sorted, + * and the values are selected, so the bound stays `O(t log t + c log c)`; `BigInt` until + * `boundOutput`. */ +function objectiveTerm(name,volumes,values,shrinks,containers,unpacked){ + if(name==='value_forgone')return runSmallestUnsorted(values,unpacked); + if(shrinks)return 0n; + if(name==='achieved_height'){ + const placed=runCount(volumes)-unpacked,widest=bigMax(containers.map(c=>c.baseArea)); + return placed>0n&&widest>0n?boundCeilDiv(runSmallest(volumes,placed),widest):0n; + } + const requested=volumes.reduce((total,[value,count])=>total+value*count,0n),capacity=boundCapacity(containers,c=>c.usable); + if(requested===0n||capacity===null)return 0n; + return boundCeilDiv((requested>capacity?requested-capacity:0n)*BOUND_PPM,requested); +} +/** The bound in a covered objective's score order. Throws `BoundOverflowError` where a + * ceiling withholds it -- the one refusal predicate's, or the objective's own term's, which + * withholds that objective's statement and no other's. */ +function objectiveVector(order,volumes,weights,values,shrinks,containers){ + const vector=objectiveBoundsFromRuns(volumes,weights,shrinks,containers); + return order.map(position=>typeof position==='number'?vector[position] + :boundOutput(objectiveTerm(position,volumes,values,shrinks,containers,BigInt(vector[0])),position)); +} /** The request's bound in its objective's own score order, or null where none is stated. * * Read from the request itself, as every engine reads it: every item type with its whole @@ -795,6 +895,9 @@ const BOUND_ORDERS=new Map([['default',[0,1,2,3,4]],['lowest_cost',[0,2,1,3,4]]] * container (D13), and for a request past a declared ceiling, which withholds the * bound and nothing else (OPTIMALITY-GAP.md § 1.5). `O(t log t + c log c)` for `t` item * types and `c` container types. */ +/** The inner size every solver packs: a pallet's widened frame, else the declared one. */ +function solvingDimensions(raw,unit,measures){const declared=measures.sides(raw.inner_dimensions,unit),limit=raw.pallet_overhang_limit; + return limit==null?declared:solvingFrame(declared,measures.length(limit.length,unit),measures.length(limit.width,unit)).d} function requestBound(req,measures){ const order=BOUND_ORDERS.get(req.configuration?.objective??'default'); if(order===undefined)return null; @@ -804,19 +907,18 @@ function requestBound(req,measures){ const items=(req.items??[]).map(raw=>({raw,count:BigInt(raw.quantity??1)})).filter(item=>item.count>0n); const volumes=boundRuns(items.map(({raw,count})=>[volume(measures.sides(raw.dimensions,unit)),count])); const weights=boundRuns(items.map(({raw,count})=>[BigInt(measures.weight(raw.weight??0)),count])); + const values=items.map(({raw,count})=>[BigInt(raw.value??0),count]); const shrinks=items.some(({raw})=>occupiesLessThanItsBox({nestingHeight:raw.nesting_height??null,shapeType:raw.shape_type??'rigid_cuboid'})); const containers=rawContainers.map(raw=>{ - const d=measures.sides(raw.inner_dimensions,unit),inner=volume(d); + const d=solvingDimensions(raw,unit,measures),inner=volume(d); return {usable:inner-inner*BigInt(scaledRatio(raw.void_fill_reserve_ratio??0))/BigInt(SUPPORT_SCALE),inner, baseArea:BigInt(d[0])*BigInt(d[1]),height:BigInt(d[2]), payload:raw.max_payload==null?null:BigInt(measures.weight(raw.max_payload)), maxItems:raw.max_items==null?null:BigInt(raw.max_items), quantity:raw.quantity==null?null:BigInt(raw.quantity),costMinor:BigInt(raw.cost_minor??0)}; }); - let vector; - try{vector=objectiveBoundsFromRuns(volumes,weights,shrinks,containers)} + try{return objectiveVector(order,volumes,weights,values,shrinks,containers)} catch(error){if(error instanceof BoundOverflowError)return null;throw error} - return order.map(position=>vector[position]); } /** What one score states against `bound`, both in the result's own score order * (OPTIMALITY-GAP.md § 1.1): the bound through the first missed position with the gap @@ -853,6 +955,12 @@ function stateOptimality(result,bound){ } /** Exposed for the cross-language scene test only, like `__inspectHullShapeForTests`. */ export function __objectiveBoundsForTests(instances,containers){return objectiveBounds(instances,containers);} +export function __runSmallestUnsortedForTests(pairs,count){return runSmallestUnsorted(pairs,count);} +export function __objectiveVectorForTests(instances,containers,objective){ + const runs=field=>boundRuns(instances.map(i=>[i[field],1n])); + try{return objectiveVector(BOUND_ORDERS.get(objective),runs('volume'),runs('weight'),instances.map(i=>[i.value,1n]),instances.some(i=>i.shrinks),containers)} + catch(error){if(error instanceof BoundOverflowError)return null;throw error} +} export function __requestBoundForTests(req){return requestBound(req,requestMeasures().close());} export function __occupiesLessThanItsBoxForTests(item){return occupiesLessThanItsBox(item);} function boxShape(dx,dy,dz){ @@ -860,17 +968,15 @@ function boxShape(dx,dy,dz){ for(const x of [0n,BigInt(dx)])for(const y of [0n,BigInt(dy)])for(const z of [0n,BigInt(dz)])v.push([x,y,z]); return {v,faces:UNIT_AXES,edges:UNIT_AXES,volume:BigInt(dx)*BigInt(dy)*BigInt(dz)}; } -/** Reorient a hull the way a rotation reorients its box, never mirroring it. +/** Reorient a hull by one of the 24 proper rotations, never mirroring it (1.10). * - * Three of the six rotations are odd permutations of the coordinate axes. On a cuboid that is - * invisible; on a hull a bare permutation returns the item's mirror image, a shape the caller - * does not own. One axis therefore changes sign when the permutation is odd. */ + * `v'[i] = s[i] * v[src[i]]` with the code's source axes and signs from the canonical table. + * A bare odd permutation would return the item's mirror image, a shape the caller does not + * own; every code in the table has determinant +1, so none is a reflection. Translated so the + * rotated hull's bounding box starts at the origin. */ function hullRotate(vertices,code){ - const axes=ROT[code]; - let inversions=0; - for(let i=0;i<3;i++)for(let j=i+1;j<3;j++)if(axes[i]>axes[j])inversions++; - const sign=inversions%2?-1:1; - const turned=vertices.map(v=>[sign*v[axes[0]],v[axes[1]],v[axes[2]]]); + const axes=ROT[code],signs=ROT_SIGNS[code]; + const turned=vertices.map(v=>[signs[0]*v[axes[0]],signs[1]*v[axes[1]],signs[2]*v[axes[2]]]); const low=[0,1,2].map(a=>Math.min(...turned.map(v=>v[a]))); return turned.map(v=>[v[0]-low[0],v[1]-low[1],v[2]-low[2]]); } @@ -1233,7 +1339,7 @@ function axleBalancedPoints(container,placements,points,item,clearance){ const {front,rear}=axleBalancedOrigins(container,load,item.w,length),last=BigInt(container.d[0]-length); for(const origin of [front,rear])if(origin!=null&&origin>=0n&&origin<=last)for(const y of floorYs)out.push([Number(origin),y,0])} return out} -function fitsTemplateAlone(c,d){return d.every((edge,k)=>edge<=c.d[k])&&(!c.bag||(d[0]+d[2]<=c.d[0]&&d[1]+d[2]<=c.d[1]))} +function fitsTemplateAlone(c,d){return d.every((edge,k)=>edge<=c.d[k])&&(!c.bag||(d[0]+d[2]<=c.d[0]&&d[1]+d[2]<=c.d[1]+c.gusset))} // The container limit that left an item out, for the search_exhausted // fallback, or null when none explains it. The same rule, in the same order, as every // engine: an axle that refuses the item alone in every container otherwise admitting it, @@ -1478,16 +1584,17 @@ function constraintBox(placement){return {x:placement.x,y:placement.y,z:placemen // Ties between equal scores fall to enumeration order unless a solver ranks them further. const noTieBreak=()=>0; // The extreme-point rank after the score, as Rust's `candidate_order_key`: the envelope's -// far corner z2, y2, x2, then its origin z, y, x and the rotation name. Compared field by +// far corner z2, y2, x2, then its origin z, y, x and the rotation. Compared field by // field, never folded into one weighted number: on a long container x2 outweighs a // sub-millimetre step in y2 under any fixed weights, and the sum leaves 2^53. // A full tie between two cuboid rotations means the same box, which Rust sweeps once under // the first allowed rotation; enumeration order is that choice, so only a hull, whose -// rotations Rust keeps apart, compares the names. +// rotations Rust keeps apart, compares rotations -- by canonical table index, never by name +// (1.10: `+`/`-` codes would sort by punctuation, and JS strings order by UTF-16 units). function compareFarCorner(a,b){const ae=a.ed,be=b.ed; return (a.z+ae[2])-(b.z+be[2])||(a.y+ae[1])-(b.y+be[1])||(a.x+ae[0])-(b.x+be[0]) ||a.z-b.z||a.y-b.y||a.x-b.x - ||(a.item.shapeType==='convex_hull'?(a.rb.r?1:0):0)} + ||(a.item.shapeType==='convex_hull'?ROT_INDEX[a.r]-ROT_INDEX[b.r]:0)} // Keep only the requested prefix: the root is the worst score, latest tie first. // Ordinals make heap eviction preserve the stable full-sort order exactly. function compareRetained(a,b,tieBreak=noTieBreak){return a.score-b.score||tieBreak(a.candidate,b.candidate)||a.ordinal-b.ordinal} @@ -1791,6 +1898,7 @@ function allowed(candidate,placed,container,globalSupportPpm,metrics,loadBase=nu const ratio=Math.max(globalSupportPpm,candidate.item.supportPpm); const supports=box.z===0?NO_SUPPORTERS:directSupporters(candidate,placed,sweep===null?null:sweep.topPlane()); for(let index=0;index0&&container.deck!=null&&!deckBears(container.deck,box,ratio))return false; if(box.z!==0&&ratio>0){ const area=supports.reduce((total,support)=>total+support.area,0); if(areaBigInt(container.maxStackDensity)*BigInt(box.d[0])*BigInt(box.d[1]))return false; + return accessBase===null||accessBase.inert||accessibleAgainst(accessBase,corridorBox(candidate)); + } + if(above.length===0&&!scene.placedMaxStacked){ + const baseBoxes=base.graph.boxes,baseSupports=base.graph.supporters,baseLoads=base.loads; + const delta=new Map(),pending=[]; + for(let i=0;i0n&&downward>0n){ + let assigned=0n; + for(let i=0;i(baseBoxes[b].z+baseBoxes[b].d[2])-(baseBoxes[a].z+baseBoxes[a].d[2])||baseBoxes[b].z-baseBoxes[a].z||a-b); + for(let idx=0;idxBigInt(b.maxTop))return false; + if(b.maxCompressionKpa!=null&&pressureExceeds(appliedPressure(newLoad,b.footprint??BigInt(b.d[0])*BigInt(b.d[1])),b.maxCompressionKpa))return false; + if(maxDensity!==null&&(BigInt(b.w)+newLoad)*squareMetre>maxDensity*BigInt(b.d[0])*BigInt(b.d[1]))return false; + } + if(maxDensity!==null&&downward*squareMetre>maxDensity*BigInt(box.d[0])*BigInt(box.d[1]))return false; + } + return accessBase===null||accessBase.inert||accessibleAgainst(accessBase,corridorBox(candidate)); + } + } + const candidateBox=constraintBox(candidate); const boxes=base===null?[...placed.map(constraintBox),candidateBox]:[...base.graph.boxes,candidateBox]; const graph=base===null?contactGraph(boxes):appendContactBox(base.graph,candidateBox,overlapXY); const loads=!needsLoads?null:base===null?topLoads(boxes,graph):topLoads(boxes,graph,settleOrderWith(base.order,boxes)); return !overloaded(boxes,loads)&&!crushed(boxes,loads)&&!stackLimitsExceeded(boxes,graph)&&!stackDensityExceeded(boxes,container.maxStackDensity,loads) - &&groundContactAllowed(candidate,placed,supports)&&singleSupportsKept(candidate,placed)&&routeContactAllowed(candidate,placed,supports,scene) &&(accessBase===null||accessBase.inert||accessibleAgainst(accessBase,corridorBox(candidate))); } // `topPlane` is `placed` bucketed by top face, as a sweep holds it; a caller reading every // placement of one container builds it once, so the writer is O(p + supporters), not O(p^2). -/** The supported share of a placement's base as the exact [area, base area]; a floor placement is whole. */ -function supportFraction(placement,placed,topPlane=null){ - if(placement.z===0)return [1,1]; +// A pallet deck as the one supporter of a floor placement, shaped as `directSupporters` +// returns one. O(1). +function deckSupport(deck,placement){const d=placementDimensions(placement); + return {placement:deckSlab(deck),area:deckContactArea(deck,placement.x,placement.y,d[0],d[1])}} +/** A floor placement's support as the exact [area, base area]: whole, or its overlap with a pallet's deck. */ +function floorSupport(placement,deck){const d=placementDimensions(placement); + return deck==null?[1,1]:[deckSupport(deck,placement).area,d[0]*d[1]]} +/** Whether a pallet's deck carries a floor box at `ratioPpm`: the area, then the centroid at or + * below half the base, as a supporting box would be judged. O(1). */ +function deckBears(deck,box,ratioPpm){const support=deckSupport(deck,box),base=box.d[0]*box.d[1]; + if(support.areabase||centreOfGravitySupported(box,[support])} +/** The supported share of a placement's base as the exact [area, base area]; a floor placement + * is whole, or on a pallet its overlap with the deck. */ +function supportFraction(placement,placed,topPlane=null,deck=null){ + if(placement.z===0)return floorSupport(placement,deck); const dimensions=placementDimensions(placement),area=directSupporters(placement,placed,topPlane).reduce((total,support)=>total+support.area,0); return [area,dimensions[0]*dimensions[1]]; } @@ -1949,7 +2125,7 @@ function admitConfiguration(cfg){ * floor or fully seated on the one below it, and the load above it is however many items * of its own column were actually placed. */ -function latticePlacements({best,count,firstIndex,raw,weight,clear,ou,ow}){ +function latticePlacements({best,count,firstIndex,raw,weight,clear,ou,ow,containerId}){ const perLayer=best.nx*best.ny,placements=[]; for(let index=0;indexROT[rotation]===undefined))return null; + if(raw.allowed_rotations!=null&&!Array.isArray(raw.allowed_rotations))return null; + const rotations=itemRotations(raw); + if(rotations.some(rotation=>ROT[rotation]===undefined))return null; const templates=[]; for(const container of req.containers??[]){ if((container.obstacles??[]).length||container.axles!=null||(container.void_fill_reserve_ratio??0)>0 @@ -2002,7 +2181,10 @@ function compactGridResult(req,{measures,u,ou,ow,clear,objective,dimensionalWeig // the axes, which the per-candidate check applies exactly. ||container.deformable_boundary!=null // A lattice sized against the machine maximum describes no box cut to measure (D12). - ||container.made_to_measure!=null)return null; + ||container.made_to_measure!=null + // The lattice rests every floor cell on the whole floor; a pallet bears weight only on + // its deck, which the per-candidate support check reads. + ||container.pallet_overhang_limit!=null)return null; const inner=measures.sides(container.inner_dimensions,u),outer=container.outer_dimensions?measures.sides(container.outer_dimensions,u):inner; templates.push({...container,d:inner,outerD:outer,max:container.max_payload==null?null:measures.weight(container.max_payload), tare:measures.weight(container.tare_weight??0),rate:parseRateTable(container.rate_table)}) @@ -2144,7 +2326,7 @@ function compactGridResult(req,{measures,u,ou,ow,clear,objective,dimensionalWeig volume_utilization:sixPlaces(used,volume(template.d)), centre_of_mass_offset_ppm:latticeCentreOfMassOffsetPpm(summary,template.d), void_fill_reserve_ticks3:'0', - placements:wantCoordinates?latticePlacements({best,count,firstIndex,raw,weight,clear,ou,ow}):[], + placements:wantCoordinates?latticePlacements({best,count,firstIndex,raw,weight,clear,ou,ow,containerId:`${template.id}#${plan.opened}`}):[], ...(wantCoordinates?{}:{lattice_summary:{ item_type:raw.id,orientation:best.rotation,physical_dimensions:outDims(best.physical,ou), envelope_dimensions:outDims(best.envelope,ou),nx:best.nx,ny:best.ny,layers_used:layers, @@ -2190,7 +2372,7 @@ function compactGridResult(req,{measures,u,ou,ow,clear,objective,dimensionalWeig // so it is computed here rather than guessed from the restricted pass above. const fitsUnrestricted=(()=>{ for(const template of templates) - for(const [,physical] of uniqueRotations(itemDimensions,Object.keys(ROT))){ + for(const [,physical] of uniqueRotations(itemDimensions,ROTATIONS)){ const envelope=physical.map(edge=>edge+2*clear); if(template.d[0]>=envelope[0]&&template.d[1]>=envelope[1]&&template.d[2]>=envelope[2])return true; } @@ -2459,8 +2641,23 @@ export function packFallback(req,clock=monotonicNow,solverAlias=null,startIndex= validateResultGroups(result,req.items); stateOptimality(result,bound); reportDuration(result,Math.max(0,Math.floor(clock()-started))); + echoCurrency(result,req.currency); return result; } +// A declared currency is echoed by the answer and by every alternative, right after +// `catalog_versions_used` as every engine orders it; it never reaches a solver or a score. +// O(alternatives * result keys). +function echoCurrency(result,currency){ + if(currency==null)return; + for(const answer of [result,...(result.alternatives??[])])insertAfterCatalogVersions(answer,'currency',currency); +} +function insertAfterCatalogVersions(answer,key,value){ + const keys=Object.keys(answer); + const trailing=keys.slice(keys.indexOf('catalog_versions_used')+1).map(name=>[name,answer[name]]); + for(const [name] of trailing)delete answer[name]; + answer[key]=value; + for(const [name,kept] of trailing)answer[name]=kept; +} // `algorithm.duration_ms` is the wall-clock milliseconds the pack took, as every engine reports // it, on the clock the deadline reads; it was a constant 0 here. It is the one field // a determinism comparison masks, so nothing the answer is judged by reads it. @@ -2722,7 +2919,9 @@ const dimensionalWeight=d=>volume(d)*BigInt(WT[dimWeightUnit])/(BigInt(LEN[dimLe // A flat bag is billed on the package its placements make, which only grows as items // are added, so the current extent is also a lower bound for any completion; an empty bag has // no parcel yet and bills nothing by volume. O(p). -const billedDimensionalWeight=(tmpl,placements)=>!tmpl.bag?dimensionalWeight(tmpl.outerD) +// A pallet is billed at the footprint its load reached, O(p) as a bag is. +const billedDimensionalWeight=(tmpl,placements)=>tmpl.deck!=null?dimensionalWeight(billedDimensions(tmpl.outerD,tmpl.deck,placements)) + :!tmpl.bag?dimensionalWeight(tmpl.outerD) :placements.length?dimensionalWeight(bagExtent(placements)):0n; const globalSupportRatio=req.configuration?.minimum_support_ratio??0; const globalSupportPpm=supportPpm(globalSupportRatio); @@ -2780,7 +2979,7 @@ const policyRules=parsePolicy(req.policy); // registered policy rule. const compact=(policyRules.length||objective==='lowest_landed_cost')?null:compactGridResult(req,{measures,u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortStops,effortStopped:()=>effortStopped,effortRemaining,deadline}); if(compact!==null)return compact; -const items=[];for(const raw of req.items){const d=measures.sides(raw.dimensions,u),w=measures.weight(raw.weight??0),rots=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)),nesting=raw.nesting_height==null?null:measures.length(raw.nesting_height,u),accepts=acceptedStackingGroups(raw); +const items=[];for(const raw of req.items){const d=measures.sides(raw.dimensions,u),w=measures.weight(raw.weight??0),rots=itemRotations(raw),nesting=raw.nesting_height==null?null:measures.length(raw.nesting_height,u),accepts=acceptedStackingGroups(raw); const maxTop=raw.max_top_load==null?null:measures.weight(raw.max_top_load); // The ordering keys below are functions of the item alone, computed once per type here // rather than as fresh BigInts on both sides of every comparison the sort makes. @@ -2857,12 +3056,15 @@ if(startOrder?.seen!=null){const typeOf=new Map(req.items.map((raw,index)=>[raw, if(startOrder.seen.has(key))return null; startOrder.seen.add(key);startOrder.sequence=sequence; for(let at=0;at+1{const d=measures.sides(c.inner_dimensions,u),axleSpec=c.axles==null?null:c.axles.map(a=>({position:measures.length(a.position,u),max:a.max_load==null?null:measures.weight(a.max_load)})); +// A pallet becomes its solving frame here, once, with the deck inside it; every solver +// below packs the frame, and only the floor-support rule and the reporting read the deck. +const templates=req.containers.map(c=>{const declared=measures.sides(c.inner_dimensions,u),limit=c.pallet_overhang_limit; + const frame=limit==null?null:solvingFrame(declared,measures.length(limit.length,u),measures.length(limit.width,u)),d=frame===null?declared:frame.d,axleSpec=c.axles==null?null:c.axles.map(a=>({position:measures.length(a.position,u),max:a.max_load==null?null:measures.weight(a.max_load)})); if(axleSpec&&(axleSpec.length!==2||axleSpec[0].position>=axleSpec[1].position||axleSpec[0].position<0||axleSpec[1].position>d[0]))throw new RangeError('axles must be [front, rear] inside the container'); const reservePpm=scaledRatio(c.void_fill_reserve_ratio??0); if(c.tag_limits!=null&&(typeof c.tag_limits!=='object'||Array.isArray(c.tag_limits)||Object.values(c.tag_limits).some(limit=>!Number.isSafeInteger(limit)||limit<1)))throw new RangeError('tag_limits must map strings to positive safe integers'); const maxStackDensity=c.max_stack_density==null?null:measures.weight(c.max_stack_density);if(maxStackDensity!=null&&maxStackDensity<0)throw new RangeError('max_stack_density must be non-negative'); - const outerD=c.outer_dimensions?measures.sides(c.outer_dimensions,u):d; + const outerD=c.outer_dimensions?measures.sides(c.outer_dimensions,u):declared; // innerVolume/reserve are pure functions of the immutable template, hoisted out of // candidatesFor's innermost (point x rotation) loop where they were recomputed as // fresh BigInts per orientation. @@ -2872,7 +3074,7 @@ const templates=req.containers.map(c=>{const d=measures.sides(c.inner_dimensions // Python, PHP and Rust decoders apply, and the reason all four agree on the answer. validateDirections(c.access_directions??[]); const doors=Object.freeze(ALL_DIRECTIONS.filter(d=>(c.access_directions??[]).includes(d))); - return {...c,d,outerD,doors,bag:c.deformable_boundary!=null,machine:parseMachine(c.made_to_measure,u,measures),max:c.max_payload==null?null:measures.weight(c.max_payload),tare:measures.weight(c.tare_weight??0),axleSpec,reservePpm, + return {...c,d,outerD,deck:frame===null?null:frame.deck,doors,bag:c.deformable_boundary!=null,gusset:c.deformable_boundary?.side_gusset_depth==null?0:measures.length(c.deformable_boundary.side_gusset_depth,u),machine:parseMachine(c.made_to_measure,u,measures),max:c.max_payload==null?null:measures.weight(c.max_payload),tare:measures.weight(c.tare_weight??0),axleSpec,reservePpm, innerVolume:volume(d),reserve:volume(d)*BigInt(reservePpm)/BigInt(SUPPORT_SCALE), rate:parseRateTable(c.rate_table),tagLimits:c.tag_limits??{},maxStackDensity, obs:(c.obstacles??[]).flatMap(o=>[o,...(o.additional_boxes??[])]).map(o=>({x:measures.length(o.origin?.x??0,u),y:measures.length(o.origin?.y??0,u),z:measures.length(o.origin?.z??0,u),d:measures.sides(o.dimensions,u)}))}}).sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost'?(compareWeights(dimensionalWeight(a.outerD),dimensionalWeight(b.outerD))||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))):((a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d)))); @@ -2943,9 +3145,12 @@ const sweepCandidates=(tmpl,item,state,candidates,index,used,width,supportRatioP const widest=Math.max(1,...item.rots.flatMap(r=>{const pd=rotate(item.d,r); return [pd[0]+2*clear,pd[1]+2*clear]})); const graph=buildContactGraph(state.placements.map(constraintBox),overlapXY,widest); - // The settled order of the base scene, sorted once here: each candidate then slots - // itself in rather than re-sorting the scene (`settleOrderWith`). - loadBaseGraph={graph,order:settleOrder(graph.boxes)}; + // The settled order and base loads of the placed scene, computed once here so + // candidate checks only propagate deltas through affected supporters. + const order=settleOrder(graph.boxes); + const loads=topLoads(graph.boxes,graph,order); + const holds=!overloaded(graph.boxes,loads)&&!crushed(graph.boxes,loads)&&!stackLimitsExceeded(graph.boxes,graph)&&!stackDensityExceeded(graph.boxes,tmpl.maxStackDensity,loads); + loadBaseGraph={graph,order,loads,holds}; } return loadBaseGraph; }; @@ -2962,7 +3167,9 @@ const sweepCandidates=(tmpl,item,state,candidates,index,used,width,supportRatioP const routeSensitive=tmpl.doors.length>0&&(item.stopIndex!=null||state.placements.some(p=>p.item.stopIndex!=null)); // JavaScript registers no candidate scorers, so the extreme-point score is Rust's empty // scorer sum and the far-corner order decides alone. - const farCornerRanked=solverAlias!=='grid'&&solverAlias!=='layer'&&solverAlias!=='maximal_spaces'&&!routeSensitive; + // The grid stands down for a pallet as the reference's does, to the extreme-point order. + const alias=solverAlias==='grid'&&tmpl.deck!=null?'extreme_points':solverAlias; + const farCornerRanked=alias!=='grid'&&alias!=='layer'&&alias!=='maximal_spaces'&&!routeSensitive; const tieBreak=farCornerRanked?compareFarCorner:noTieBreak; // Everything that is a function of (item, rotation) alone -- the rotated envelope, the // volume it occupies, its hull -- is computed once per sweep here rather than once per @@ -2985,16 +3192,18 @@ const sweepCandidates=(tmpl,item,state,candidates,index,used,width,supportRatioP orientations.push({r,pd,ed,shape:exactHull?shapeFor(item.hullVertices,r):null, volumeBlocked:boxVolumeBlocked??(volumeGated&&occupiedVolume({pd,r,item})>volumeRoom)})} const [innerX,innerY,innerZ]=tmpl.d,obstacles=tmpl.obs; - // A flat bag also needs a + c <= L and b + c <= W over the extents grown by the candidate - //: O(p) once per sweep, O(1) per candidate, and a verdict never flips later. + // A bag also needs a + c <= L and b + c <= W + G over the extents grown by the candidate + // (G is a gusseted bag's side gusset, 0 for a flat one): O(p) once per sweep, + // O(1) per candidate, and a verdict never flips later. // Read as scalars, not destructured, so a rigid container pays nothing for the rule. const bag=Boolean(tmpl.bag),bagExtents=bag?bagExtent(placements):null,bagX=bag?bagExtents[0]:0,bagY=bag?bagExtents[1]:0,bagZ=bag?bagExtents[2]:0; + const girthY=innerY+(bag?tmpl.gusset:0); const baseAxle=tmpl.axleSpec==null?null:axleLoad(tmpl,placements); candidatePoints:for(let pointIndex=0;pointIndex=attemptCeiling){effortStopped=true;break}metrics.candidate_points_considered++;const x=pt[0],y=pt[1],z=pt[2]; for(let orientationIndex=0;orientationIndex=attemptCeiling){effortStopped=true;break candidatePoints}metrics.orientations_considered++;if((candidateClockTick++&63)===0&&deadline.expired()){timeLimitReached=true;break candidatePoints} const {r,pd,ed}=orientation,x2=x+ed[0],y2=y+ed[1],z2=z+ed[2]; if(x2>innerX||y2>innerY||z2>innerZ)continue; - if(bag&&(Math.max(x2,bagX)+Math.max(z2,bagZ)>innerX||Math.max(y2,bagY)+Math.max(z2,bagZ)>innerY))continue; + if(bag&&(Math.max(x2,bagX)+Math.max(z2,bagZ)>innerX||Math.max(y2,bagY)+Math.max(z2,bagZ)>girthY))continue; if(payloadBlocked||countBlocked||orientation.volumeBlocked)continue; let candidate=nests?{x,y,z,pd,ed,r,item}:null,box=null; if(nests&&!compressionSensitive&&usedVolumeDelta(placements,candidate)>volumeRoom)continue; @@ -3046,11 +3255,11 @@ const sweepCandidates=(tmpl,item,state,candidates,index,used,width,supportRatioP // not the attempts before it that find nothing feasible. if(metrics.feasible_candidates>=candidateCeiling){effortStopped=true;break candidatePoints} metrics.feasible_candidates++; - const score=farCornerRanked?0:solverAlias==='grid' + const score=farCornerRanked?0:alias==='grid' ?z*1e12+y*1e6+x - :solverAlias==='layer' + :alias==='layer' ?z2*1e12+z*1e8+y*1e4+x - :solverAlias==='maximal_spaces' + :alias==='maximal_spaces' ?x2+y2+z2*1e6 :x2*1e9+y2*1e4+z2; if(width===1){if(!found.length||(score-bestScore||tieBreak(candidate,found[0]))<0){found[0]=candidate;bestScore=score}continue} @@ -3374,7 +3583,7 @@ const blockModes=addedBlockStart&&profile==='fast'?[byVolume]:[false,true]; // A block set on a smaller one overhangs it, so a request that asks for support is left // to the per-item search. 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&&!t.bag) + &&Object.keys(t.tagLimits).length===0&&t.maxStackDensity==null&&t.reservePpm===0&&!t.bag&&t.deck==null) &&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 &&i.supportPpm===0&&(i.groundRule==null||i.groundRule==='free')&&i.nesting==null&&i.stopIndex==null @@ -3546,7 +3755,26 @@ const roundScore=(trial,itemsRemaining)=>{const tmpl=trial.state.tmpl,placed=tri const compareRound=(left,right)=>{for(let i=0;itmpl.machine==null?{trial:packIntoTemplate(tmpl,itemsRemaining),trials:1}:madeToMeasureTrial(tmpl,itemsRemaining); +const trialFor=(tmpl,itemsRemaining)=>tmpl.machine!=null?madeToMeasureTrial(tmpl,itemsRemaining) + :tmpl.deck!=null?palletTrial(tmpl,itemsRemaining):{trial:packIntoTemplate(tmpl,itemsRemaining),trials:1}; +// Every solver starts at the frame's origin, the corner of the overhang zone, so a frame +// solve overhangs even a load the deck holds. The deck alone is packed first and moved onto the +// frame; the frame is packed only when the deck left something behind, and used only when it +// places strictly more, seated with the least overhang. At most two solves, O(p) moves. +const palletTrial=(tmpl,itemsRemaining)=>{const deck=tmpl.deck; + const alone=deckAlone(tmpl),onDeck=packIntoTemplate({...alone,innerVolume:volume(alone.d),reserve:0n},itemsRemaining),deckTrial=()=>movedTrial(tmpl,onDeck,deck.x,deck.y); + if(!onDeck.next.length||timeLimitReached||deadline.expired())return {trial:deckTrial(),trials:1}; + // A further solve, never after a spent budget, as a made-to-measure rung. + if(effortStopped)return {trial:deckTrial(),trials:1}; + const framed=packIntoTemplate(tmpl,itemsRemaining); + return {trial:framed.state.placements.length>onDeck.state.placements.length?seatedTrial(tmpl,framed):deckTrial(),trials:2}}; +const movedTrial=(tmpl,trial,dx,dy)=>({...trial,state:{...trial.state,tmpl,placements:movedPlacements(trial.state.placements,dx,dy)}}); +// Kept where the solver left it when the move would leave a floor placement short of the deck. +const seatedTrial=(tmpl,trial)=>{const [dx,dy]=centringOffset(tmpl.deck,trial.state.placements); + if(dx===0&&dy===0)return trial; + const seated=movedTrial(tmpl,trial,dx,dy); + const bears=p=>{const ratio=Math.max(globalSupportPpm,p.item.supportPpm);return p.z!==0||ratio<=0||deckBears(tmpl.deck,{x:p.x,y:p.y,z:p.z,d:p.ed},ratio)}; + return seated.state.placements.every(bears)?seated:trial}; // Try each solving envelope in ascending order and keep the first that places every item; // failing that, the trial the round's own key prefers, the smaller rung on a tie. At most four // solves. A rung choice is a heuristic, so nothing here is ever claimed exhaustive (D13). @@ -3556,15 +3784,23 @@ const madeToMeasureTrial=(tmpl,itemsRemaining)=>{ for(const rung of rungs){ // Before a further rung, never after the last one; a rung's own guards refuse a spent budget. if(trials>0&&effortStopped)break; - const trial=packIntoTemplate({...tmpl,d:rung,innerVolume:volume(rung),reserve:0n},itemsRemaining);trials++;last=trial; - if(trial.state.placements.length){const cut=fabricatedTrial(tmpl,trial);if(!trial.next.length)return {trial:cut,trials}; + const trial=packIntoTemplate({...tmpl,d:rung,...reserveOf(rung,tmpl.reservePpm)},itemsRemaining);trials++;last=trial; + if(trial.state.placements.length){let cut=fabricatedTrial(tmpl,trial); + // The reserve is a share of the box that ships, so a box cut tight to its load can + // break a reserve the rung kept; the trial then cuts the rung itself, on the grid by + // construction and solved under its own reserve. + if(!keepsReserve(cut.state))cut=fabricatedTrial(tmpl,trial,rung);if(!trial.next.length)return {trial:cut,trials}; const score=roundScore(cut,itemsRemaining);if(bestScore==null||compareRound(score,bestScore)<0){best=cut;bestScore=score}} if(deadline.expired()){timeLimitReached=true;break}} return {trial:best??last,trials}}; // A rung trial as the box cut for it: the achieved extent of its placements, envelope far faces // with clearance, rounded up onto the machine grid (D14). O(p). -const fabricatedTrial=(tmpl,trial)=>{const box=fabricate(tmpl.machine,tmpl.d,bagExtent(trial.state.placements),tmpl.cost_minor??0,tmpl.tare); - return {...trial,state:{...trial.state,tmpl:{...tmpl,d:box.sides,outerD:box.outer,innerVolume:volume(box.sides),reserve:0n,cost_minor:box.cost,tare:box.tare}}}}; +const fabricatedTrial=(tmpl,trial,extent=bagExtent(trial.state.placements))=>{const box=fabricate(tmpl.machine,tmpl.d,extent,tmpl.cost_minor??0,tmpl.tare); + return {...trial,state:{...trial.state,tmpl:{...tmpl,d:box.sides,outerD:box.outer,...reserveOf(box.sides,tmpl.reservePpm),cost_minor:box.cost,tare:box.tare}}}}; +// A box's volume and the share of it its void-fill reserve keeps empty. O(1). +const reserveOf=(sides,reservePpm)=>{const inner=volume(sides);return {innerVolume:inner,reserve:inner*BigInt(reservePpm)/BigInt(SUPPORT_SCALE)}}; +// Whether a fabricated box keeps its void-fill reserve. O(p) for the used volume. +const keepsReserve=state=>state.tmpl.reservePpm===0||usedVolume(state.placements)<=state.tmpl.innerVolume-state.tmpl.reserve; // Containers holding fixed items open first, in request order then instance order, each // filled by one ordinary single-container search from its seeded state. They are kept // whatever that search gets -- nothing, or nothing at all once the deadline or effort is @@ -3624,7 +3860,7 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ for(const c of packed){const n=(counts.get(c.tmpl.id)??0)+1;counts.set(c.tmpl.id,n);c.seq=n}} const containers=packed.map(c=>{const loads=topLoads(c.placements.map(constraintBox)),used=usedVolume(c.placements),topPlane=bucketByPlane(c.placements,p=>p.z+placementDimensions(p)[2]); const reaction=axleReactions(c.tmpl,c.placements); - return {id:`${c.tmpl.id}#${c.seq}`,container_type:c.tmpl.id,inner_dimensions:outDims(c.tmpl.d,ou),outer_dimensions:outDims(c.tmpl.machine?c.tmpl.outerD:c.tmpl.outer_dimensions?dims(c.tmpl.outer_dimensions,u):c.tmpl.d,ou),...(c.tmpl.bag||c.tmpl.machine?{achieved_dimensions:outDims(bagExtent(c.placements),ou)}:{}),...(c.tmpl.machine?{made_to_measure:{cost_minor:c.tmpl.cost_minor,tare_weight:outWeight(c.tmpl.tare,ow)}}:{}),payload_weight:outWeight(c.payload,ow),gross_weight:outWeight(c.payload+c.tmpl.tare,ow),used_volume_ticks3:used.toString(),volume_utilization:sixPlaces(used,volume(c.tmpl.d)),centre_of_mass_offset_ppm:centreOfMassOffsetPpm(c.tmpl,c.placements,clear),...(reaction==null?{}:{axle_reactions:{basis:'gross',denominator:reaction.denominator.toString(),front_numerator:reaction.front.toString(),rear_numerator:reaction.rear.toString()}}),void_fill_reserve_ticks3:(volume(c.tmpl.d)*BigInt(c.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)).toString(),placements:c.placements.map((p,i)=>({item_id:p.item.id,item_type:p.item.raw.id,position:outPoint({x:p.x+clear,y:p.y+clear,z:p.z+clear},ou),dimensions:outDims(p.pd,ou),orientation:p.r,support_ratio:sixPlaces(...(p.fixed?p.fixedSupport:supportFraction(p,c.placements,topPlane))),top_load:outWeight(loads[i],ow),...(p.fixed?{fixed:true}:{})}))}}); + return {id:`${c.tmpl.id}#${c.seq}`,container_type:c.tmpl.id,inner_dimensions:outDims(declaredInner(c.tmpl.d,c.tmpl.deck),ou),outer_dimensions:outDims(c.tmpl.machine?c.tmpl.outerD:c.tmpl.outer_dimensions?dims(c.tmpl.outer_dimensions,u):declaredInner(c.tmpl.d,c.tmpl.deck),ou),...(c.tmpl.deck!=null?{achieved_dimensions:outDims(achievedDimensions(c.tmpl.deck,c.placements),ou)}:c.tmpl.bag||c.tmpl.machine?{achieved_dimensions:outDims(bagExtent(c.placements),ou)}:{}),...(c.tmpl.machine?{made_to_measure:{cost_minor:c.tmpl.cost_minor,tare_weight:outWeight(c.tmpl.tare,ow)}}:{}),payload_weight:outWeight(c.payload,ow),gross_weight:outWeight(c.payload+c.tmpl.tare,ow),used_volume_ticks3:used.toString(),volume_utilization:sixPlaces(used,volume(c.tmpl.d)),centre_of_mass_offset_ppm:centreOfMassOffsetPpm(c.tmpl,c.placements,clear),...(reaction==null?{}:{axle_reactions:{basis:'gross',denominator:reaction.denominator.toString(),front_numerator:reaction.front.toString(),rear_numerator:reaction.rear.toString()}}),void_fill_reserve_ticks3:(volume(c.tmpl.d)*BigInt(c.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)).toString(),placements:c.placements.map((p,i)=>({placement_id:placementId(`${c.tmpl.id}#${c.seq}`,[p.x+clear,p.y+clear,p.z+clear],p.item.raw.id,p.r),item_id:p.item.id,item_type:p.item.raw.id,position:outPoint({x:p.x+clear,y:p.y+clear,z:p.z+clear},ou),dimensions:outDims(p.pd,ou),orientation:p.r,support_ratio:sixPlaces(...(p.fixed?p.fixedSupport:supportFraction(p,c.placements,topPlane,c.tmpl.deck))),top_load:outWeight(loads[i],ow),...(p.fixed?{fixed:true}:{})}))}}); const fitsWithRotations=(i,rots)=>templates.some(c=>rots.some(r=>fitsTemplateAlone(c,rotate(i.d,r).map(x=>x+2*clear)))); const openedByType=new Map();for(const c of packed)openedByType.set(c.tmpl.id,(openedByType.get(c.tmpl.id)??0)+1); // Python's and PHP's `support_is_the_blocker`. Each packed container's final scene is @@ -3652,7 +3888,7 @@ const unpacked=remaining.map(i=>{ // item's own restricted set: distinguishes "genuinely too big in any rotation" // from "this exact rotation restriction, and only it, rules every container out" //. Both are pure geometry, so both are provable without a complete search. - const fitsAnyRotation=fitsWithRotations(i,Object.keys(ROT)),dimensionFit=fitsWithRotations(i,i.rots),weightFit=templates.some(c=>c.max==null||i.w<=c.max); + const fitsAnyRotation=fitsWithRotations(i,ROTATIONS),dimensionFit=fitsWithRotations(i,i.rots),weightFit=templates.some(c=>c.max==null||i.w<=c.max); const eligibleFit=!i.eligibleTags.length||templates.some(c=>i.eligibleTags.some(tag=>(c.tags??[]).includes(tag))); // Ranked after the geometric proofs: an item too big for every container is impossible // whatever a policy says, and naming the policy first would send a caller to change the @@ -3716,7 +3952,7 @@ function rebalanceContext(req,result){ const d=dims(raw.dimensions,unit),w=scalar(raw.weight??0,'g',WT),accepts=acceptedStackingGroups(raw); for(let sequence=1;sequence<=(raw.quantity??1);sequence++)items.set(`${raw.id}#${sequence}`,{ raw,d,w,id:`${raw.id}#${sequence}`, - rots:raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)), + rots:itemRotations(raw), stackable:raw.stackable!==false,maxTop:raw.max_top_load==null?null:scalar(raw.max_top_load,'g',WT), supportPpm:supportPpm(raw.minimum_support_ratio??0), tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null, @@ -3729,12 +3965,14 @@ function rebalanceContext(req,result){ }) } const templates=new Map((req.containers??[]).map(raw=>{ - const d=dims(raw.inner_dimensions,unit); + const declared=dims(raw.inner_dimensions,unit),limit=raw.pallet_overhang_limit; + const frame=limit==null?null:solvingFrame(declared,scalar(limit.length,unit,LEN),scalar(limit.width,unit,LEN)),d=frame===null?declared:frame.d; const axleSpec=raw.axles==null?null:raw.axles.map(axle=>({ position:scalar(axle.position,unit,LEN), max:axle.max_load==null?null:scalar(axle.max_load,'g',WT), })); - const template={...raw,d,outerD:raw.outer_dimensions?dims(raw.outer_dimensions,unit):d,bag:raw.deformable_boundary!=null, + const template={...raw,d,outerD:raw.outer_dimensions?dims(raw.outer_dimensions,unit):declared,deck:frame===null?null:frame.deck,bag:raw.deformable_boundary!=null, + gusset:raw.deformable_boundary?.side_gusset_depth==null?0:scalar(raw.deformable_boundary.side_gusset_depth,unit,LEN), max:raw.max_payload==null?null:scalar(raw.max_payload,'g',WT), tare:scalar(raw.tare_weight??0,'g',WT),axleSpec, reservePpm:scaledRatio(raw.void_fill_reserve_ratio??0), @@ -3837,14 +4075,18 @@ function publicRebalancedContainers(req,context){ volume_utilization:sixPlaces(used,volume(state.tmpl.d)), centre_of_mass_offset_ppm:centreOfMassOffsetPpm(state.tmpl,state.placements,context.clear), placements:state.placements.map((placement,index)=>({...placement.publicPlacement, + // A moved item's id names where it now is, in the container it now sits in (1.10). + placement_id:placementId(state.publicContainer.id,[placement.x+context.clear,placement.y+context.clear,placement.z+context.clear],placement.item.raw.id,placement.r), position:outPoint({x:placement.x+context.clear,y:placement.y+context.clear,z:placement.z+context.clear},outputLength), dimensions:outDims(placement.pd,outputLength),orientation:placement.r, - support_ratio:sixPlaces(...supportFraction(placement,state.placements)), + support_ratio:sixPlaces(...supportFraction(placement,state.placements,null,state.tmpl.deck)), top_load:outWeight(loads[index],outputWeight), })), }; // A moved item changes a bag's package, so its extent is reported afresh. if(state.tmpl.bag)container.achieved_dimensions=outDims(bagExtent(state.placements),outputLength); + // A moved item can change how far a pallet's load overhangs. + if(state.tmpl.deck!=null)container.achieved_dimensions=outDims(achievedDimensions(state.tmpl.deck,state.placements),outputLength); if(reaction==null)delete container.axle_reactions; else container.axle_reactions={basis:'gross',denominator:reaction.denominator.toString(),front_numerator:reaction.front.toString(),rear_numerator:reaction.rear.toString()}; return container @@ -3883,7 +4125,9 @@ export function rebalanceWeight(req,result,{maxMoves=64}={}){ return entry}; const unpriceableState=state=>{ const entry=priceEntry(state.tmpl),payload=state.placements.reduce((total,placement)=>total+placement.item.w,0n); - const grams=billedGrams(heavier(payload+state.tmpl.tare,entry.dimTicks)); + // A pallet bills at the footprint its load reached, which a move can change. + const dimTicks=state.tmpl.deck==null?entry.dimTicks:dimensionalTicks(billedDimensions(state.tmpl.outerD,state.tmpl.deck,state.placements)); + const grams=billedGrams(heavier(payload+state.tmpl.tare,dimTicks)); if(entry.rate!=null&&chargeMinor(entry.rate,grams)!=null)return null; return {id:state.tmpl.id,grams,bound:entry.rate==null?0:entry.rate.brackets[entry.rate.brackets.length-1]}}; statesPriceable=()=>context.states.every(state=>unpriceableState(state)==null); diff --git a/index.d.ts b/index.d.ts index 5d690ca..b4bfdb0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -4,12 +4,16 @@ export interface Item{id:string;quantity?:number;dimensions:Dimensions;weight?:E export interface Axle{position:ExactScalar;max_load?:ExactScalar} /**: an on-demand box machine; `inner_dimensions` is the largest box it cuts (docs/MADE-TO-MEASURE-CONTAINERS.md). */ export interface MadeToMeasure{minimum:Dimensions;increment:Dimensions;cost_per_square_metre_minor?:number;tare_per_square_metre?:ExactScalar;outer_allowance?:Dimensions} -export interface Container{id:string;inner_dimensions:Dimensions;outer_dimensions?:Dimensions;max_payload?:ExactScalar;tare_weight?:ExactScalar;quantity?:number;cost_minor?:number;max_items?:number;max_stack_density?:ExactScalar;axles?:[Axle,Axle];tags?:string[];tag_limits?:Record;void_fill_reserve_ratio?:number;metadata?:Record;deformable_boundary?:{kind:'flat_bag'}|null;made_to_measure?:MadeToMeasure|null;obstacles?:Array<{id:string;origin:{x:ExactScalar;y:ExactScalar;z:ExactScalar};dimensions:Dimensions;additional_boxes?:Array<{origin?:{x:ExactScalar;y:ExactScalar;z:ExactScalar};dimensions:Dimensions}>}>} +/** A carrier tariff; `currency`, when stated, must equal the request's (1.10). */ +export interface RateTable{weight_brackets_g:number[];prices_minor:number[];minimum_charge_minor?:number;fuel_surcharge_permille?:number;currency?:string|null} +export interface Container{id:string;inner_dimensions:Dimensions;outer_dimensions?:Dimensions;max_payload?:ExactScalar;tare_weight?:ExactScalar;quantity?:number;cost_minor?:number;max_items?:number;max_stack_density?:ExactScalar;axles?:[Axle,Axle];tags?:string[];tag_limits?:Record;void_fill_reserve_ratio?:number;metadata?:Record;rate_table?:RateTable|null;deformable_boundary?:{kind:'flat_bag'}|{kind:'gusseted_bag';side_gusset_depth:ExactScalar}|null;made_to_measure?:MadeToMeasure|null;pallet_overhang_limit?:{length:ExactScalar;width:ExactScalar}|null;obstacles?:Array<{id:string;origin:{x:ExactScalar;y:ExactScalar;z:ExactScalar};dimensions:Dimensions;additional_boxes?:Array<{origin?:{x:ExactScalar;y:ExactScalar;z:ExactScalar};dimensions:Dimensions}>}>} export interface PackingConfiguration{solver_profile?:'fast'|'balanced'|'quality'|'exact_small';time_limit_ms?:number;alternatives?:number;seed?:number;max_containers?:number;clearance?:ExactScalar;minimum_support_ratio?:number;exact_item_limit?:number;multi_start_orders?:number;max_candidates_per_item?:number;max_candidate_points?:number;require_placement_coordinates?:boolean;solvers?:Array<'grid'|'extreme_points'|'homogeneous_blocks'|'layer'|'maximal_spaces'|'exact_small'>;objective?:'default'|'lowest_cost'|'shipping_cost'|'open_dimension_height'|'maximum_value'|'maximum_volume';dimensional_weight_divisor?:number;dimensional_weight_length_unit?:'mm'|'cm'|'m'|'in'|'ft';dimensional_weight_weight_unit?:'mg'|'g'|'kg'|'oz'|'lb';effort_budget?:{max_candidates_evaluated?:number;max_placement_attempts?:number;max_search_nodes?:number;max_restarts?:number}} export interface CatalogReference{catalog_id:string;version:number;effective_at:number;resolved_at:number} /** An item already in a known place before the solve (docs/PLAN-REVISIONS.md). A result placement can be copied in unchanged. */ -export interface FixedPlacement{item_type:string;container_type:string;container_instance?:number;position?:{x?:ExactScalar;y?:ExactScalar;z?:ExactScalar};orientation:'LWH'|'LHW'|'WLH'|'WHL'|'HLW'|'HWL'} -export interface PackingRequest{units?:{length?:string};configuration?:PackingConfiguration;items:Item[];containers:Container[];fixed_placements?:FixedPlacement[];catalog_versions_used?:CatalogReference[];output?:{length_unit?:string;weight_unit?:string}} +/** One of the 24 proper rotations, in canonical table order (1.10): the six legacy codes, then the signed codes only a `convex_hull` item may use. */ +export type Orientation='LWH'|'LHW'|'WLH'|'WHL'|'HLW'|'HWL'|'+L-W-H'|'-L+W-H'|'-L-W+H'|'+L-H+W'|'+L+H-W'|'-L-H-W'|'+W-L+H'|'+W+L-H'|'-W-L-H'|'+W-H-L'|'-W+H-L'|'-W-H+L'|'+H-L-W'|'-H+L-W'|'-H-L+W'|'+H-W+L'|'+H+W-L'|'-H-W-L' +export interface FixedPlacement{item_type:string;container_type:string;container_instance?:number;position?:{x?:ExactScalar;y?:ExactScalar;z?:ExactScalar};orientation:Orientation;/** 1.10: when given, the id its result placement will carry: the canonical JSON of `["#",x,y,z,"",""]`, position in ticks. */placement_id?:string|null} +export interface PackingRequest{units?:{length?:string};configuration?:PackingConfiguration;items:Item[];containers:Container[];fixed_placements?:FixedPlacement[];catalog_versions_used?:CatalogReference[];output?:{length_unit?:string;weight_unit?:string};currency?:string|null} export interface ResultFact{code:string;[key:string]:unknown} export interface StartRecord{id:string;started:boolean;completed:boolean;truncated:boolean;selected:boolean;global_deadline_reached:boolean} export interface TerminationFact extends ResultFact{any_start_truncated:boolean;all_required_starts_completed:boolean;winning_start_truncated:boolean;global_deadline_reached:boolean;starts:StartRecord[]} @@ -18,7 +22,7 @@ export interface RejectionObservation{code:string;count:number;details:string[]; export interface ReasonProof{level:'proven'|'observed'|'inferred'|'unknown_due_to_limit';observations:RejectionObservation[];[key:string]:unknown} export interface UnpackedItemResult{item_id:string;item_type:string;reason:string;details:string[];proof:ReasonProof} export interface ExplanationDescriptor{message_key:string;arguments:{item_id:string;evidence_level:string;details:string};default_message:string} -export interface PackingResult{status:string;feasibility:ResultFact;termination:TerminationFact;optimality:ResultFact;complete:boolean;algorithm:{profile:string;solver:string;duration_ms:number;seed:number;time_limit_reached:boolean;effort_limit_reached:boolean;candidates_evaluated:number;placements_attempted:number;metrics:SolverMetrics};summary:{container_count:number;packed_item_count:number;unpacked_item_count:number};containers:unknown[];unpacked_items:UnpackedItemResult[];catalog_versions_used:CatalogReference[];[key:string]:unknown} +export interface PackingResult{status:string;feasibility:ResultFact;termination:TerminationFact;optimality:ResultFact;complete:boolean;algorithm:{profile:string;solver:string;duration_ms:number;seed:number;time_limit_reached:boolean;effort_limit_reached:boolean;candidates_evaluated:number;placements_attempted:number;metrics:SolverMetrics};summary:{container_count:number;packed_item_count:number;unpacked_item_count:number};containers:unknown[];unpacked_items:UnpackedItemResult[];catalog_versions_used:CatalogReference[];currency?:string;[key:string]:unknown} export interface WeightMove{item_id:string;from_container_id:string;to_container_id:string} export interface RebalanceResult{containers:unknown[];moves:WeightMove[];improved:boolean} export class UnsupportedFeatureError extends Error{readonly code:'unsupported_feature';readonly fields:string[]} @@ -26,8 +30,8 @@ export class UnsupportedFeatureError extends Error{readonly code:'unsupported_fe export type InvalidRequestReason='missing_field'|'wrong_type'|'below_minimum'|'above_maximum'|'negative_measure'|'invalid_unit'|'duplicate_id'|'not_allowed'|'invalid_value'; /** The request is not one any engine may answer; nothing was solved. `field` is the RFC 6901 JSON Pointer of the offending value, `''` for the request as a whole. */ export class InvalidRequestError extends RangeError{readonly code:string;readonly reason:string;readonly field:string;readonly detail:string;constructor(reason:InvalidRequestReason,field:string,detail:string)} -/** The request's fixed placements are malformed (`reason` `malformed`, `field` the bad value) or cannot all hold (`cannot_hold`, `field` `/fixed_placements`); refused before any search. */ -export class FixedPlacementError extends InvalidRequestError{readonly code:'invalid_fixed_placement';readonly reason:'malformed'|'cannot_hold';constructor(detail:string,reason?:'malformed'|'cannot_hold',field?:string)} +/** The request's fixed placements are malformed (`reason` `malformed`, `field` the bad value), name a value outside its rule (`invalid_value`: a signed orientation on a non-hull, or a `placement_id` naming another placement), or cannot all hold (`cannot_hold`, `field` `/fixed_placements`); refused before any search. */ +export class FixedPlacementError extends InvalidRequestError{readonly code:'invalid_fixed_placement';readonly reason:'malformed'|'invalid_value'|'cannot_hold';constructor(detail:string,reason?:'malformed'|'invalid_value'|'cannot_hold',field?:string)} export type MovementDirection='+x'|'-x'|'+y'|'-y'|'+z'|'-z'; export interface SequencePoint{x:number;y:number;z:number} export interface SequenceDimensions{length:number;width:number;height:number} @@ -58,6 +62,12 @@ export function explanationForUnpackedItem(item:UnpackedItemResult):ExplanationD export function explainUnpackedItem(item:UnpackedItemResult):string; export function pack(request:PackingRequest):PackingResult; export function packJson(input:string):string; +/** One level of a nested pack: an ordinary request; one listing no items packs the previous level's containers. */ +export interface NestedLevel{name:string;request:PackingRequest} +/** The levels of the best chain, innermost first; it stops at the level no chain could complete. */ +export interface NestedPackingResult{levels:Array<{name:string;result:PackingResult}>} +/** Packs `levels` in order, carrying the `levelBeamWidth` (default 1, a positive integer) best chains between levels. */ +export function packNested(options:{levels:readonly NestedLevel[];levelBeamWidth?:number}):NestedPackingResult; /** `JSON.stringify` that writes a `BigInt` (a weight's `ticks` past 2^53 - 1) as the integer it is. */ export function stringifyExact(value:unknown):string; /** `JSON.parse` that reads a `ticks` past 2^53 - 1 as an exact `BigInt`. */ diff --git a/index.js b/index.js index cdedaff..73ef773 100644 --- a/index.js +++ b/index.js @@ -23,6 +23,7 @@ export { parseExact, stringifyExact } from './exact-json.js'; import { SUITE_VERSION } from './artifacts.js'; import { validateDocuments } from './result-validation/index.js'; import { parseExact, stringifyExact } from './exact-json.js'; +import { packNestedWith } from './nested.js'; const require = createRequire(import.meta.url); /** The largest `maxMoves` the addon's `u32` parameter holds; napi wraps anything above it. */ @@ -131,6 +132,15 @@ export function pack(request) { return parseExact(packJson(stringifyExact(request))); } +/** + * Levels packed in order, each level's packed containers becoming the next level's items, + * with the `levelBeamWidth` best chains carried between levels. Every level packs + * through `pack`, so through whichever backend is loaded. + */ +export function packNested(options) { + return packNestedWith(pack, options); +} + export function rebalanceWeight(request, result, options) { return rebalanceWeightWith(native, request, result, options); } @@ -152,7 +162,7 @@ export function rebalanceWeightWith(binding, request, result, { maxMoves = 64 } return rebalanceFallback(request, result, { maxMoves }); } -export const version = () => native?.version?.() ?? '1.9.0-js-fallback'; +export const version = () => native?.version?.() ?? '1.10.0-js-fallback'; /** * The result is not a document the reference validator can judge (docs/VALIDATION-CONTRACT.md): diff --git a/nested.js b/nested.js new file mode 100644 index 0000000..e94bef1 --- /dev/null +++ b/nested.js @@ -0,0 +1,193 @@ +/** + * Nested packing: levels packed in order, each level's packed containers becoming the next + * level's items, the same beam as Python's `NestedPacker` over JSON requests. + * + * `levelBeamWidth` K carries the K best chains from one level to the next. A chain is a + * sequence of level results; it extends by packing its handoff against the level's whole + * container list and, when K > 1 and the level offers more than one, against each container + * alone. Chains rank by their outermost result's score, then each earlier level's outward in, + * then generation order; two chains handing up the same multiset of (container type, billed + * dimensions, gross weight) keep only the better. A level + * with no complete candidate ends every chain, unless the next level lists its own items, and + * the best incomplete one is the answer. K = 1 is exactly the greedy chain. + * + * At most L * K * (T + 1) level packs for L levels offering at most T containers each; ranking + * sorts the K * (T + 1) extensions of one level, comparing up to L scores per pair. + * Locks remain fixed: singleton offers omitting a locked container type are skipped, and + * K > 1 skips inherited handoffs missing a locked item instance. If none can supply the + * locks, the first handoff receives the ordinary packer's refusal. Filtering is O(F + T) + * per level and O(H + F) per chain for F locks and H handed-up item types. + */ + +/** Length ticks per millimetre and weight ticks per gram: both powers of 2 and 5 only, so every + * tick count is a terminating decimal in these units and re-parses to the same ticks. */ +const LENGTH_TICKS_PER_MM = 16000n; +const WEIGHT_TICKS_PER_GRAM = 8000000n; +const SIDES = ['length', 'width', 'height']; + +/** + * The levels `packLevel` packed, as `{levels: [{name, result}]}`. `packLevel` packs one JSON + * request; the package passes its own `pack`, so each level uses whichever backend is loaded. + */ +export function packNestedWith(packLevel, { levels, levelBeamWidth = 1 }) { + if (!Number.isSafeInteger(levelBeamWidth) || levelBeamWidth < 1) { + throw new RangeError('level_beam_width must be a positive integer'); + } + let beam = [[]]; + for (const [index, level] of levels.entries()) { + const ranked = rankedExtensions(packLevel, level, beam, levelBeamWidth); + const complete = ranked.filter((chain) => chain[chain.length - 1].result.complete); + if (complete.length > 0) { + beam = distinctHandoffs(complete, levelBeamWidth); + continue; + } + const next = levels[index + 1]; + if (next === undefined || !listsItems(next)) return answer(ranked[0]); + beam = [ranked[0]]; + } + return answer(beam[0]); +} + +function answer(chain) { + return { levels: chain.map(({ name, result }) => ({ name, result })) }; +} + +function listsItems(level) { + return Array.isArray(level.request.items) && level.request.items.length > 0; +} + +/** Every chain's extensions through `level`, best first; ties keep generation order. */ +function rankedExtensions(packLevel, level, beam, width) { + const extensions = []; + let firstIncompatible; + for (const chain of beam) { + const items = listsItems(level) || chain.length === 0 ? level.request.items : handoff(chain[chain.length - 1]); + if (width > 1 && chain.length > 0 && !listsItems(level) && !handoffSupportsLocks(level.request, items)) { + firstIncompatible ??= chain; + continue; + } + for (const containers of offers(level.request, width)) { + const request = { ...level.request, items, containers }; + const link = { name: level.name, request, result: packLevel(request) }; + extensions.push({ chain: [...chain, link], ordinal: extensions.length }); + } + } + // Preserve the ordinary packer's meaningful refusal when no branch can supply the locks. + if (extensions.length === 0 && firstIncompatible !== undefined) { + return rankedExtensions(packLevel, level, [firstIncompatible], 1); + } + extensions.sort((a, b) => compareChains(a.chain, b.chain) || a.ordinal - b.ordinal); + return extensions.map(({ chain }) => chain); +} + +/** A lock consumes one handed-up item instance. Only absent/exhausted types make a branch + * incompatible; malformed locks remain the flat packer's responsibility. O(H + F). */ +function handoffSupportsLocks(request, items) { + if (!Array.isArray(request.fixed_placements) || request.fixed_placements.length === 0) return true; + const available = new Map(items.map((item) => [item.id, item.quantity ?? 1])); + for (const fixed of request.fixed_placements) { + if (fixed === null || typeof fixed !== 'object' || typeof fixed.item_type !== 'string') return true; + const left = available.get(fixed.item_type) ?? 0; + if (left === 0) return false; + available.set(fixed.item_type, left - 1); + } + return true; +} + +function* offers(request, width) { + const { containers } = request; + // Validate the original request before deriving offers, including malformed locks. + yield containers; + if (width === 1 || containers.length < 2) return; + const locked = new Set((request.fixed_placements ?? []).map((fixed) => fixed.container_type)); + // A singleton cannot carry locks naming another type. Keep the locks unchanged and + // omit that offer; several locked types leave only the complete offer. O(F + T). + if (locked.size > 1) return; + for (const container of containers) { + if (locked.size === 0 || locked.has(container.id)) yield [container]; + } +} + +/** Outermost score first, then each earlier level's outward in; lower is better. */ +function compareChains(a, b) { + for (let index = a.length - 1; index >= 0; index--) { + const order = compareScores(a[index].result.score, b[index].result.score); + if (order !== 0) return order; + } + return 0; +} + +/** Lexicographic, exact: an entry is a `Number` while safe and a `BigInt` past 2^53 - 1. */ +function compareScores(a, b) { + for (let index = 0; index < Math.min(a.length, b.length); index++) { + const left = BigInt(a[index]); + const right = BigInt(b[index]); + if (left !== right) return left < right ? -1 : 1; + } + return a.length - b.length; +} + +/** The first `width` chains whose last level hands up a distinct multiset of items. */ +function distinctHandoffs(ranked, width) { + const kept = []; + const seen = new Set(); + for (const chain of ranked) { + const key = handoffKey(chain[chain.length - 1]); + if (seen.has(key)) continue; + seen.add(key); + kept.push(chain); + if (kept.length === width) break; + } + return kept; +} + +/** A multiset: instance numbers and emission order are not part of a packing's contract, so + * every engine collapses the same chains. Any fixed order serves; only equality is read. */ +function handoffKey(link) { + return JSON.stringify(link.result.containers.map((container) => JSON.stringify([ + container.container_type, + SIDES.map((side) => String(billedDimensions(link.request, container)[side].ticks)), + String(container.gross_weight.ticks), + ])).sort()); +} + +/** Each packed container as one upright item, Python's `PackedContainer.as_item()`. */ +function handoff(link) { + return link.result.containers.map((container) => ({ + id: container.id, + quantity: 1, + dimensions: Object.fromEntries(SIDES.map((side) => + [side, measure(billedDimensions(link.request, container)[side].ticks, LENGTH_TICKS_PER_MM, 'mm')])), + weight: measure(container.gross_weight.ticks, WEIGHT_TICKS_PER_GRAM, 'g'), + keep_upright: true, + metadata: { source_packed_container: container.id }, + })); +} + +/** + * What a carrier measures: a flat bag's achieved package; a pallet's declared footprint (its + * reported outer size) grown by the overhang it used, per horizontal axis the larger, at the + * declared height; else the declared outer size. + */ +function billedDimensions(request, container) { + const declared = request.containers.find((candidate) => candidate.id === container.container_type); + if (declared?.deformable_boundary != null) return container.achieved_dimensions; + if (declared?.pallet_overhang_limit == null) return container.outer_dimensions; + const outer = container.outer_dimensions, achieved = container.achieved_dimensions; + const larger = (side) => (BigInt(achieved[side].ticks) > BigInt(outer[side].ticks) ? achieved[side] : outer[side]); + return { length: larger('length'), width: larger('width'), height: outer.height }; +} + +/** `ticks` as an exact decimal of `unit`, which holds `ticksPerUnit` ticks. */ +function measure(ticks, ticksPerUnit, unit) { + const exact = BigInt(ticks); + const whole = exact / ticksPerUnit; + let remainder = exact % ticksPerUnit; + let fraction = ''; + while (remainder !== 0n) { + remainder *= 10n; + fraction += String(remainder / ticksPerUnit); + remainder %= ticksPerUnit; + } + return { value: fraction === '' ? String(whole) : `${whole}.${fraction}`, unit }; +} diff --git a/package.json b/package.json index 316ccdf..eb5432f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name":"@packvium/engine", - "version":"1.9.0", + "version":"1.10.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","box-packing","load-planning"], @@ -11,7 +11,7 @@ "main":"index.js", "types":"index.d.ts", "exports":{".":{"types":"./index.d.ts","import":"./index.js"},"./execution.js":{"types":"./execution.d.ts","import":"./execution.js"},"./artifacts.js":{"types":"./artifacts.d.ts","import":"./artifacts.js"},"./artifact-exports.js":{"types":"./artifact-exports.d.ts","import":"./artifact-exports.js"},"./revisions.js":{"types":"./revisions.d.ts","import":"./revisions.js"},"./scene.js":{"types":"./scene.d.ts","import":"./scene.js"}}, - "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","execution.js","canonical-json.js","artifacts.js","artifact-exports.js","revisions.js","scene.js","request-errors.js","made-to-measure.js","exact-json.js","result-validation","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","revisions.d.ts","scene.d.ts","README.md","SECURITY.md"], + "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","execution.js","canonical-json.js","artifacts.js","artifact-exports.js","revisions.js","scene.js","request-errors.js","made-to-measure.js","pallet-overhang.js","exact-json.js","nested.js","result-validation","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","revisions.d.ts","scene.d.ts","README.md","SECURITY.md"], "engines":{"node":">=16"}, "scripts":{ "test":"node test/run-tests.mjs", diff --git a/pallet-overhang.js b/pallet-overhang.js new file mode 100644 index 0000000..182eccd --- /dev/null +++ b/pallet-overhang.js @@ -0,0 +1,92 @@ +/** + * Pallet overhang (docs/PALLET-AND-ROBOT-RULES.md "container.pallet_overhang_limit"). + * + * A container with `pallet_overhang_limit` `(l, w)` is a deck `L x W`, its `inner_dimensions`, + * that a load may overhang by at most `l` past each end and `w` past each side. Every solver + * packs the solving frame `(L + 2l) x (W + 2w) x H`, whose origin is the corner of the overhang + * zone, so the deck lies on `[l, l + L] x [w, w + W]` and every coordinate stays non-negative. + * + * Only the deck carries weight: a placement on the floor rests on its overlap with the deck. The + * packed pallet is billed and handed up at the size it reached: its declared footprint (outer if + * stated, else inner) grown by the overhang actually used past each edge. + * + * Placements here are the solver's own: `x`, `y`, `z` the envelope origin and `ed` its sides, + * all safe-integer ticks. This module is package-internal: `fallback.js` imports it. + */ + +/** The frame every solver packs for a declared inner `[L, W, H]`, and the deck inside it. O(1). */ +export function solvingFrame(inner, overhangX, overhangY) { + return { + d: [inner[0] + 2 * overhangX, inner[1] + 2 * overhangY, inner[2]], + deck: Object.freeze({ x: overhangX, y: overhangY, length: inner[0], width: inner[1] }), + }; +} + +/** The inner size the caller declared: a frame's deck at its height, else `d` itself. */ +export const declaredInner = (d, deck) => (deck == null ? d : [deck.length, deck.width, d[2]]); + +/** The container a pallet trial packs first: the deck alone, as declared, with no deck of its own. */ +export function deckAlone(frame) { + return { ...frame, d: declaredInner(frame.d, frame.deck), deck: null }; +} + +/** How much of a floor footprint rests on the deck. O(1). */ +export function deckContactArea(deck, x, y, length, width) { + const dx = Math.min(x + length, deck.x + deck.length) - Math.max(x, deck.x); + const dy = Math.min(y + width, deck.y + deck.width) - Math.max(y, deck.y); + return dx > 0 && dy > 0 ? dx * dy : 0; +} + +/** The deck as a one-tick slab under the floor, shaped as a supporter: only its footprint is read. */ +export const deckSlab = (deck) => ({ x: deck.x, y: deck.y, z: -1, d: [deck.length, deck.width, 1] }); + +/** How far the placements pass the deck in total along each axis, both ends then both sides. O(p). */ +function overhangUsed(deck, placements) { + let lowX = deck.x, lowY = deck.y, highX = deck.x + deck.length, highY = deck.y + deck.width; + for (const p of placements) { + lowX = Math.min(lowX, p.x); lowY = Math.min(lowY, p.y); + highX = Math.max(highX, p.x + p.ed[0]); highY = Math.max(highY, p.y + p.ed[1]); + } + return [(deck.x - lowX) + (highX - deck.x - deck.length), (deck.y - lowY) + (highY - deck.y - deck.width)]; +} + +/** + * The shift that seats a frame trial's load with the least overhang. Per axis: flush with the + * deck's near edge when the load is no longer than the deck, else centred on the deck with an + * odd tick past the far edge. The load stays inside the frame, since its span is at most the + * deck plus both allowances. O(p); `[0, 0]` for none. + */ +export function centringOffset(deck, placements) { + if (placements.length === 0) return [0, 0]; + let lowX = Infinity, lowY = Infinity, highX = -Infinity, highY = -Infinity; + for (const p of placements) { + lowX = Math.min(lowX, p.x); lowY = Math.min(lowY, p.y); + highX = Math.max(highX, p.x + p.ed[0]); highY = Math.max(highY, p.y + p.ed[1]); + } + return [seat(deck.x, deck.length, lowX, highX), seat(deck.y, deck.width, lowY, highY)]; +} + +function seat(start, length, low, high) { + const span = high - low; + return (span <= length ? start : start - Math.floor((span - length) / 2)) - low; +} + +/** Every placement moved by `(dx, dy)`; relative geometry, and so every rule but floor support, unchanged. O(p). */ +export const movedPlacements = (placements, dx, dy) => placements.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })); + +/** The deck grown by the overhang used, at the load's height: what the loaded pallet reaches. O(p). */ +export function achievedDimensions(deck, placements) { + const [overX, overY] = overhangUsed(deck, placements); + const top = placements.reduce((highest, p) => Math.max(highest, p.z + p.ed[2]), 0); + return [deck.length + overX, deck.width + overY, top]; +} + +/** + * Per horizontal axis the larger of the declared footprint `base` (outer if stated, else inner) + * and the deck grown by the overhang used; the declared height. An outer margin is not positioned + * against the deck, so it is never added to the overhang. O(p). + */ +export function billedDimensions(base, deck, placements) { + const [overX, overY] = overhangUsed(deck, placements); + return [Math.max(base[0], deck.length + overX), Math.max(base[1], deck.width + overY), base[2]]; +} diff --git a/request-errors.js b/request-errors.js index 81ce57a..9fe824e 100644 --- a/request-errors.js +++ b/request-errors.js @@ -12,8 +12,8 @@ * - `detail`: the fixed text for the reason, so the message is identical in every engine. * * `checkRequest` walks the schema's rules over the raw JSON before any model is built, and the - * first violation wins, in the order every engine follows: units, output, configuration, items, - * containers. A cross-engine suite holds all four engines to the same reason, field and + * first violation wins, in the order every engine follows: units, output, currency, + * configuration, items, containers. A cross-engine suite holds all four engines to the same reason, field and * message. * * This module is package-internal: the error class is re-exported from `fallback.js` and the @@ -29,7 +29,87 @@ export const OBJECTIVES = Object.freeze(['default', 'lowest_cost', 'shipping_cos export const ACCESS_DIRECTIONS = Object.freeze(['+x', '-x', '+y', '-y', '+z', '-z']); // The schema's closed vocabularies, each refused as `not_allowed` like the two above. export const SOLVERS = Object.freeze(['grid', 'extreme_points', 'homogeneous_blocks', 'layer', 'maximal_spaces', 'exact_small']); +// The six legacy orientation codes: a box's whole vocabulary and an item's default list. export const ROTATIONS = Object.freeze(['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']); +/** + * The 24 proper rotations in canonical table order (1.10), `packvium.orientations`: the code, + * the declared side (L=0, W=1, H=2) each container axis x, y, z takes, and the sign it carries. + * The six legacy codes come first with the matrices they have always had -- an odd permutation + * negates x -- then, per permutation in that order, its three other determinant +1 sign patterns. + * The index in this table is the tie-break order everywhere; codes are never compared as strings. + */ +export const ORIENTATION_TABLE = Object.freeze([ + ['LWH', [0, 1, 2], [1, 1, 1]], ['LHW', [0, 2, 1], [-1, 1, 1]], ['WLH', [1, 0, 2], [-1, 1, 1]], + ['WHL', [1, 2, 0], [1, 1, 1]], ['HLW', [2, 0, 1], [1, 1, 1]], ['HWL', [2, 1, 0], [-1, 1, 1]], + ['+L-W-H', [0, 1, 2], [1, -1, -1]], ['-L+W-H', [0, 1, 2], [-1, 1, -1]], ['-L-W+H', [0, 1, 2], [-1, -1, 1]], + ['+L-H+W', [0, 2, 1], [1, -1, 1]], ['+L+H-W', [0, 2, 1], [1, 1, -1]], ['-L-H-W', [0, 2, 1], [-1, -1, -1]], + ['+W-L+H', [1, 0, 2], [1, -1, 1]], ['+W+L-H', [1, 0, 2], [1, 1, -1]], ['-W-L-H', [1, 0, 2], [-1, -1, -1]], + ['+W-H-L', [1, 2, 0], [1, -1, -1]], ['-W+H-L', [1, 2, 0], [-1, 1, -1]], ['-W-H+L', [1, 2, 0], [-1, -1, 1]], + ['+H-L-W', [2, 0, 1], [1, -1, -1]], ['-H+L-W', [2, 0, 1], [-1, 1, -1]], ['-H-L+W', [2, 0, 1], [-1, -1, 1]], + ['+H-W+L', [2, 1, 0], [1, -1, 1]], ['+H+W-L', [2, 1, 0], [1, 1, -1]], ['-H-W-L', [2, 1, 0], [-1, -1, -1]], +].map((row) => Object.freeze(row))); +/** Every code, in table order: a convex hull's vocabulary. */ +export const ORIENTATIONS = Object.freeze(ORIENTATION_TABLE.map(([code]) => code)); +const SIGNED_ORIENTATIONS = ORIENTATIONS.slice(ROTATIONS.length); +/** The codes that keep the item's own height vertical and pointing up (z takes `+H`), in table order. */ +export const UPRIGHT_ORIENTATIONS = Object.freeze(ORIENTATION_TABLE + .filter(([, axes, signs]) => axes[2] === 2 && signs[2] === 1).map(([code]) => code)); +/** What a signed code on anything but a hull is refused with, at its own pointer. */ +export const SIGNED_CODE_NEEDS_A_HULL = 'a signed rotation code requires shape_type convex_hull'; + +/** The codes an item may name: all 24 for a hull, the six legacy codes for anything else. */ +export const orientationVocabulary = (hull) => (hull ? ORIENTATIONS : ROTATIONS); + +/** + * Why `value` cannot orient an item of this shape, or `null` when it can: `invalid_value` for a + * signed code beside a non-hull -- a rule broken, not an unknown word -- and `not_allowed` for + * anything outside the item's vocabulary. Every field that names a requested item's orientation + * reads it through `refuseOrientation`. O(1). + */ +function orientationRefusal(value, hull) { + if (!hull && SIGNED_ORIENTATIONS.includes(value)) return 'invalid_value'; + return orientationVocabulary(hull).includes(value) ? null : 'not_allowed'; +} + +/** + * Throw `error(reason, field, detail)` when `value` cannot orient an item of this shape. `where` + * names the value inside the detail, the way a fixed placement or a revision event words its own + * refusals. `packvium.request_errors.refuse_orientation`. O(1). + */ +export function refuseOrientation(value, hull, field, error, where = '') { + const reason = orientationRefusal(value, hull); + if (reason === 'invalid_value') { + throw error(reason, field, where ? `${where}: ${SIGNED_CODE_NEEDS_A_HULL}` : SIGNED_CODE_NEEDS_A_HULL); + } + if (reason !== null) { + const rule = `must be one of ${jsonSpelling([...orientationVocabulary(hull)])}`; + throw error(reason, field, where ? `${where} ${rule}` : rule); + } +} + +/** + * The ids of the requested item types that are `convex_hull`, read from JSON items as written; + * anything that is not an item object names no hull. O(items). + */ +export function hullItemTypes(items) { + if (!Array.isArray(items)) return new Set(); + return new Set(items.filter((item) => item !== null && typeof item === 'object' && !Array.isArray(item) + && item.shape_type === 'convex_hull' && typeof item.id === 'string').map((item) => item.id)); +} + +/** + * An item's rotation list as the engines search it: its own list, or the six legacy codes, then + * narrowed to the upright codes under `keep_upright`, keeping the list's own order. O(r). + * + * The default is a plain copy, never the frozen `ROTATIONS` itself: the fallback's solver reads + * this list in its hottest sweep, and a frozen array is another elements kind there. Handing it + * the frozen constant (1.10) cost 1-3 MB of process peak in the frontier's Node 22 image, in + * TurboFan memory outside the JS heap, and bought nothing (docs/PERFORMANCE-PRACTICE.md). + */ +export function itemRotations(raw) { + const listed = raw.allowed_rotations ?? [...ROTATIONS]; + return raw.keep_upright ? listed.filter((code) => UPRIGHT_ORIENTATIONS.includes(code)) : listed; +} export const LENGTH_UNITS = Object.freeze(['mm', 'cm', 'm', 'in', 'ft']); export const WEIGHT_UNITS = Object.freeze(['mg', 'g', 'kg', 'oz', 'lb']); export const GROUND_CONTACT_RULES = Object.freeze(['free', 'covered', 'single', 'multiple']); @@ -244,6 +324,8 @@ export function checkRequest(data, measures) { const request = requireObject(data, ''); const unit = checkUnits(request.units, measures.length); checkOutput(request.output); + const currency = hasOwn(request, 'currency') ? request.currency : null; + optional(request, 'currency', '', currencyCode); const length = (value, field) => requireLength(requireMeasure(value, field, measures.length, unit), field); const weight = (value, field) => requireWeight(value, field, measures.weight); checkConfiguration(request.configuration, length); @@ -252,14 +334,17 @@ export function checkRequest(data, measures) { items.forEach((raw, index) => checkItem(raw, pointer('items', index), length, weight, lengthTicks)); requireUniqueIds(items, 'items'); const containers = requiredList(request, 'containers', ''); - containers.forEach((raw, index) => checkContainer(raw, pointer('containers', index), length, weight, lengthTicks)); + containers.forEach((raw, index) => checkContainer(raw, pointer('containers', index), length, weight, lengthTicks, currency)); requireUniqueIds(containers, 'containers'); requireTotalWeight(items, containers, measures.weight); } function checkUnits(raw, lengths) { if (raw == null) return 'mm'; - const length = requireObject(raw, '/units').length; + const units = requireObject(raw, '/units'); + // `{"length": "cm", "weight": "kg"}` used to read every bare weight as grams. + requireKnownFields(units, '/units', ['length']); + const length = units.length; if (length == null) return 'mm'; if (!knownUnit(length, lengths)) { throw new InvalidRequestError('invalid_unit', '/units/length', `has an unknown unit ${jsonSpelling(length)}`); @@ -310,10 +395,26 @@ function checkItem(raw, where, length, weight, lengthTicks) { optional(item, 'compression_ratio', where, requireUnbounded); optional(item, 'stacking_group', where, stackingGroupName); optional(item, 'accepts_stacking_groups', where, stackingGroupNames); - optional(item, 'allowed_rotations', where, eachOneOf(ROTATIONS)); + optional(item, 'allowed_rotations', where, (value, field) => allowedRotations(value, field, item)); optional(item, 'ground_contact_rule', where, oneOf(GROUND_CONTACT_RULES)); optional(item, 'shape_type', where, oneOf(SHAPE_TYPES)); for (const name of ITEM_BOOLEANS) optional(item, name, where, requireBoolean); + const listed = item.allowed_rotations; + if (listed != null && !listed.some((code) => !item.keep_upright || UPRIGHT_ORIENTATIONS.includes(code))) { + throw new InvalidRequestError('invalid_value', `${where}/allowed_rotations`, + 'must allow at least one rotation after keep_upright'); + } +} + +/** + * Each code from the item's vocabulary (1.10): all 24 for a hull, the six legacy codes for + * anything else, where a signed code is a rule broken rather than an unknown word. The shape is + * read as written, so a shape no engine knows is not a hull. O(len(raw)). + */ +function allowedRotations(raw, where, item) { + const hull = item.shape_type === 'convex_hull'; + requireList(raw, where).forEach((value, index) => refuseOrientation(value, hull, where + pointer(index), + (reason, field, detail) => new InvalidRequestError(reason, field, detail))); } /** @@ -335,7 +436,7 @@ function stackingGroupNames(raw, where) { requireList(raw, where).forEach((name, index) => stackingGroupName(name, where + pointer(index))); } -function checkContainer(raw, where, length, weight, lengthTicks) { +function checkContainer(raw, where, length, weight, lengthTicks, currency) { const container = requireObject(raw, where); requiredString(container, 'id', where); optional(container, 'quantity', where, atLeast(1)); @@ -347,11 +448,36 @@ function checkContainer(raw, where, length, weight, lengthTicks) { 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, 'rate_table', where, (v, f) => rateTable(v, f, currency)); const inner = SIDES.map((side) => lengthTicks(container.inner_dimensions[side])); optional(container, 'obstacles', where, (v, f) => obstacles(v, f, length, inner)); - optional(container, 'deformable_boundary', where, (v, f) => deformableBoundary(container, v, f, where)); + optional(container, 'deformable_boundary', where, (v, f) => deformableBoundary(container, v, f, where, length, inner)); optional(container, 'made_to_measure', where, (v, f) => madeToMeasure(container, v, f, where, { length, weight, lengthTicks })); + optional(container, 'pallet_overhang_limit', where, (v, f) => palletOverhangLimit(container, v, f, length, lengthTicks)); +} + +/** The two axes `pallet_overhang_limit` requires, in the order they are checked. */ +const PALLET_OVERHANG_FIELDS = ['length', 'width']; + +/** + * Both axes, each a non-negative length, and a solving frame `(L + 2l, W + 2w, H)` held + * to the ceiling every box is: each side, then what it encloses. In `BigInt`, since a frame side + * may pass 2^53 - 1 where each measure does not. O(1). + */ +function palletOverhangLimit(container, raw, field, length, lengthTicks) { + const limit = requireObject(raw, field); + requireKnownFields(limit, field, PALLET_OVERHANG_FIELDS); + for (const axis of PALLET_OVERHANG_FIELDS) length(required(limit, axis, field), field + pointer(axis)); + const [innerLength, innerWidth, height] = SIDES.map((side) => BigInt(lengthTicks(container.inner_dimensions[side]))); + const frame = [innerLength + 2n * BigInt(lengthTicks(limit.length)), innerWidth + 2n * BigInt(lengthTicks(limit.width)), height]; + frame.forEach((ticks, axis) => { + if (ticks > BigInt(MAX_LENGTH_TICKS)) { + throw new InvalidRequestError('above_maximum', field + pointer(SIDES[axis]), `must be at most ${MAX_LENGTH_TICKS} ticks`); + } + }); + if (frame[0] * frame[1] * frame[2] > MAX_VOLUME_TICKS3) { + throw new InvalidRequestError('above_maximum', field, 'must enclose at most 10^30 cubic ticks'); + } } /** Every key the request schema's `made_to_measure` declares; it sets `additionalProperties: false`. */ @@ -391,15 +517,24 @@ function madeToMeasure(container, raw, field, where, { length, weight, lengthTic } /**: the kinds `deformable_boundary` admits. */ -export const DEFORMABLE_BOUNDARY_KINDS = Object.freeze(['flat_bag']); +export const DEFORMABLE_BOUNDARY_KINDS = Object.freeze(['flat_bag', 'gusseted_bag']); /** Container fields a flat bag refuses, in reporting order (docs/DEFORMABLE-CONTAINERS.md). */ const BAG_EXCLUSIONS = Object.freeze(['obstacles', 'axles', 'access_directions', 'max_stack_density', - 'outer_dimensions', 'void_fill_reserve_ratio', 'made_to_measure']); + 'outer_dimensions', 'void_fill_reserve_ratio', 'made_to_measure', 'pallet_overhang_limit']); -function deformableBoundary(container, raw, field, where) { +/** + * The boundary object top to bottom -- kind, unknown keys, the gusset (1.10) -- then the + * container fields a bag refuses. O(1). + */ +function deformableBoundary(container, raw, field, where, length, inner) { const boundary = requireObject(raw, field); - requireOneOf(required(boundary, 'kind', field), field + pointer('kind'), DEFORMABLE_BOUNDARY_KINDS); - requireKnownFields(boundary, field, ['kind']); + const kind = required(boundary, 'kind', field); + requireOneOf(kind, field + pointer('kind'), DEFORMABLE_BOUNDARY_KINDS); + const gusseted = kind === 'gusseted_bag'; + requireKnownFields(boundary, field, gusseted ? ['kind', 'side_gusset_depth'] : ['kind']); + if (gusseted) { + sideGussetDepth(required(boundary, 'side_gusset_depth', field), field + pointer('side_gusset_depth'), length, inner); + } for (const name of BAG_EXCLUSIONS) { if (setBesideABag(name, hasOwn(container, name) ? container[name] : null)) { throw new InvalidRequestError('not_allowed', where + pointer(name), 'cannot be combined with deformable_boundary'); @@ -407,6 +542,13 @@ function deformableBoundary(container, raw, field, where) { } } +/** A length, positive once read in ticks, and no deeper than the bag's inner width. */ +function sideGussetDepth(value, field, length, inner) { + const depth = length(value, field); + if (depth === 0) throw new InvalidRequestError('invalid_value', field, 'must be positive'); + if (depth > inner[1]) throw new InvalidRequestError('invalid_value', field, 'exceeds inner_dimensions width'); +} + // An empty obstacle list and a zero reserve change nothing, so only they are let through. function setBesideABag(name, value) { if (name === 'obstacles') return Array.isArray(value) && value.length > 0; @@ -439,7 +581,7 @@ function tagLimits(raw, where) { for (const tag of Object.keys(limits).sort(compareCodePoints)) requireInteger(limits[tag], where + pointer(tag), 1); } -function rateTable(raw, where) { +function rateTable(raw, where, currency) { const table = requireObject(raw, where); for (const [name, minimum] of [['weight_brackets_g', 1], ['prices_minor', 0]]) { if (table[name] == null) continue; @@ -448,6 +590,29 @@ function rateTable(raw, where) { } optional(table, 'minimum_charge_minor', where, atLeast(0)); optional(table, 'fuel_surcharge_permille', where, atLeast(0)); + optional(table, 'currency', where, (v, f) => rateTableCurrency(v, f, currency)); +} + +/** + * An ISO 4217 shape, `^[A-Z]{3}$`, with no code list: the engines never convert, so a code they + * do not know is still one they can echo. ASCII letters only, and no trailing newline. O(1). + */ +function currencyCode(value, field) { + if (typeof value !== 'string') throw new InvalidRequestError('wrong_type', field, 'must be a string'); + if (!/^[A-Z]{3}$/.test(value)) { + throw new InvalidRequestError('invalid_value', field, 'must be three capital letters (ISO 4217)'); + } +} + +/** A rate table prices in the request's currency or says nothing: another one's minor units would be ranked as the request's. O(1). */ +function rateTableCurrency(value, field, currency) { + currencyCode(value, field); + if (currency == null) { + throw new InvalidRequestError('missing_field', '/currency', 'is required when a rate_table declares a currency'); + } + if (value !== currency) { + throw new InvalidRequestError('not_allowed', field, `must equal the request currency ${jsonSpelling(currency)}`); + } } function obstacles(raw, where, length, inner) { diff --git a/result-validation/accounting.js b/result-validation/accounting.js index 855a7a0..ae0fcdd 100644 --- a/result-validation/accounting.js +++ b/result-validation/accounting.js @@ -1,6 +1,6 @@ /** The summary, the item accounting, and the proof behind every unpacked reason. */ -import { ROTATIONS } from './geometry.js'; +import { BOX_ROTATIONS } from './geometry.js'; import { PyDict, at, getNull, getOr, has, hashKey, isDict, list, pyCmp, pyEq, pyIntInstance, text, truthy, } from './json.js'; @@ -24,6 +24,15 @@ export function checkStructure(result, issues) { } } +/** The result echoes the request's currency, and states none the request did not declare; a null is the field left out. O(1). */ +export function checkCurrency(request, result, issues) { + const requested = getNull(request, 'currency'); + const reported = getNull(result, 'currency'); + if (!pyEq(reported, requested)) { + issues.push(issue('currency_mismatch', `reported ${pyRepr(reported)}, requested ${pyRepr(requested)}`)); + } +} + /** Whether a rule's `applies_to` facts all hold for the shipment. */ function applies(rule, shipment) { const appliesTo = truthy(getNull(rule, 'applies_to')) ? getNull(rule, 'applies_to') : {}; @@ -108,7 +117,7 @@ export function checkAccounting(result, context, issues) { */ export function fitsEmpty(container, size) { if (size.some((side, axis) => side > container.inner[axis])) return false; - return !container.flatBag || (size[0] + size[2] <= container.inner[0] && size[1] + size[2] <= container.inner[1]); + return !container.flatBag || (size[0] + size[2] <= container.inner[0] && size[1] + size[2] <= container.inner[1] + container.sideGusset); } /** @@ -163,7 +172,8 @@ function checkReasonProof(unpacked, spec, context, timedOut, effortLimited, issu checkObservations(proof, reason, itemId, issues); } -const EVERY_ROTATION = ROTATIONS.map(([name]) => name); +// Every box a cuboid can make: a signed code is a hull's alone and spans no other envelope. +const EVERY_ROTATION = BOX_ROTATIONS; function checkProvenReason(unpacked, reason, spec, context, itemId, issues) { const invalid = (detail) => issues.push(issue('invalid_proven_reason', `${itemId}: ${detail}`)); diff --git a/result-validation/bound.js b/result-validation/bound.js index 68696b2..a4be7fc 100644 --- a/result-validation/bound.js +++ b/result-validation/bound.js @@ -18,8 +18,17 @@ import { issue } from './outcome.js'; import { intList, pyRepr } from './pyfmt.js'; import { SCORE_SCALE } from './stacking.js'; -/** Where each covered objective reads `(L0, L1, L2, L3, L4)` in its own score order. */ -const BOUND_ORDERS = new Map([['default', [0, 1, 2, 3, 4]], ['lowest_cost', [0, 2, 1, 3, 4]]]); +/** + * Where each covered objective reads `(L0, L1, L2, L3, L4)` in its own score order, a name + * standing for the one key only that objective ranks by. Any other objective has no bound. + */ +const BOUND_ORDERS = new Map([ + ['default', [0, 1, 2, 3, 4]], + ['lowest_cost', [0, 2, 1, 3, 4]], + ['maximum_value', [0, 'value_forgone', 1, 2, 3]], + ['open_dimension_height', [0, 'achieved_height', 1, 2, 3]], + ['maximum_volume', ['unpacked_volume_ppm', 0, 1, 2, 3]], +]); const MAX_BOUND_SUM = 10n ** 30n; const MAX_BOUND_VALUE = 2n ** 53n - 1n; @@ -194,6 +203,100 @@ function boundInputs(items, containers) { return [volumes, weights, shrinks, types]; } +/** Every instance's `Item.value`, 0 when unset, as unsorted runs. Read only for `maximum_value`. */ +const valueRuns = (items) => items.values() + .map((spec) => [truthy(spec.value) ? int(spec.value, 'value') : 0n, int(spec.quantity, 'quantity')]); + +/** Restores the max-heap below `at`, by value. */ +function siftDown(heap, at) { + let index = at; + for (;;) { + let top = index; + for (const child of [2 * index + 1, 2 * index + 2]) { + if (child < heap.length && heap[child][0] > heap[top][0]) top = child; + } + if (top === index) return; + [heap[index], heap[top]] = [heap[top], heap[index]]; + index = top; + } +} + +function siftUp(heap, at) { + let index = at; + while (index > 0) { + const parent = (index - 1) >> 1; + if (heap[parent][0] >= heap[index][0]) return; + [heap[index], heap[parent]] = [heap[parent], heap[index]]; + index = parent; + } +} + +/** + * The sum of the `count` smallest values of unsorted `[value, count]` runs, selected rather than + * sorted (finding F6): a max-heap holds only the runs the `count` smallest can still reach, and + * the run on top contributes the part of it they need. O(t log k) for the k <= t runs held. + */ +export function smallestUnsorted(runs, count) { + if (count <= 0n) return 0n; + const heap = []; + let held = 0n; + let total = 0n; + for (const run of runs) { + if (run[1] > 0n && !(held >= count && run[0] >= heap[0][0])) { + heap.push(run); + held += run[1]; + total += run[0] * run[1]; + siftUp(heap, heap.length - 1); + while (held - heap[0][1] >= count) { + const [value, taken] = heap[0]; + held -= taken; + total -= value * taken; + heap[0] = heap[heap.length - 1]; + heap.pop(); + siftDown(heap, 0); + } + } + } + return held > count ? total - (held - count) * heap[0][0] : total; +} + +/** + * The key only one objective ranks by, each conditional on `L0` like the five: the + * least value forgone and the least summed top of a packing leaving `unpacked` behind, and the + * least unpacked share of requested volume any packing scores. No term sorts: the volume runs + * are the ones `boundInputs` sorted for the five, and the values are selected. O(t log t + c). + */ +function objectiveTerm(name, values, volumes, shrinks, containers, unpacked) { + if (name === 'value_forgone') return smallestUnsorted(values(), unpacked); + if (shrinks) return 0n; + if (name === 'achieved_height') { + const placed = runCount(volumes) - unpacked; + const floors = containers.map((container) => container.baseArea).filter((area) => area > 0n); + return placed > 0n && floors.length > 0 ? ceilDiv(smallest(volumes, placed), largestOf(floors)) : 0n; + } + const requested = volumes.reduce((total, [value, count]) => total + value * count, 0n); + const capacity = capacityTotal(containers.map((container) => container.usable), containers.map((container) => container.quantity)); + if (requested === 0n || capacity === null) return 0n; + return ceilDiv(max(0n, requested - capacity) * SCORE_SCALE, requested); +} + +/** + * A covered objective's bound in its own score order, or `null` with the reason a ceiling + * withholds it: the one refusal predicate's, or the objective's own term's, which withholds + * that objective alone. `values` yields the value runs, read only by `maximum_value`, so no + * other objective parses `Item.value`. Exported so the shared scene can hold it to the oracle. + */ +export function objectiveLowerBound(name, values, volumes, weights, shrinks, containers) { + const bound = lowerBound(volumes, weights, shrinks, containers); + if (bound === null) return { bound: null, withheld: 'the request is past a declared ceiling' }; + const stated = BOUND_ORDERS.get(name).map((position) => (typeof position === 'number' + ? bound[position] : objectiveTerm(position, values, volumes, shrinks, containers, bound[0]))); + if (stated.some((value) => value > MAX_BOUND_VALUE)) { + return { bound: null, withheld: `the ${name} key is past the portable ceiling` }; + } + return { bound: stated, withheld: '' }; +} + /** The bound in the objective's score order, or `null` with the reason none is stated (§ 1.5). */ function statedBound(request, status, items, containers) { const name = getOr(getOr(request, 'configuration', {}), 'objective', 'default'); @@ -204,9 +307,8 @@ function statedBound(request, status, items, containers) { } if (status === 'infeasible') return { bound: null, withheld: "status is 'infeasible'" }; if (status === 'invalid_result') return { bound: null, withheld: "status is 'invalid_result'" }; - const bound = lowerBound(...boundInputs(items, containers)); - if (bound === null) return { bound: null, withheld: 'the request is past a declared ceiling' }; - return { bound: order.map((position) => bound[position]), withheld: '' }; + const [volumes, weights, shrinks, types] = boundInputs(items, containers); + return objectiveLowerBound(name, () => valueRuns(items), volumes, weights, shrinks, types); } /** The first position where `score` leaves `bound`, and whether it is above (1) or below (-1). */ diff --git a/result-validation/container.js b/result-validation/container.js index eb5f5b1..161f4b4 100644 --- a/result-validation/container.js +++ b/result-validation/container.js @@ -8,11 +8,11 @@ import { PyDict, at, get, getNull, getOr, has, int, isDict, list, pyEq, pyInt, truthy, } from './json.js'; import { Refusal, contract, issue, shape } from './outcome.js'; -import { abs, ceilDiv, floorDiv, max, pyMod, ratioPpm } from './num.js'; +import { Ratio, abs, ceilDiv, floorDiv, max, min, pyMod, ratioPpm } from './num.js'; import { intTuple, list as listRepr, pyRepr, pyStr, strRepr, valueTuple } from './pyfmt.js'; import { allows, intersectsTags, isRotation, rotated, sortedTags, volumeOf } from './spec.js'; import { - SCORE_SCALE, SQUARE_METRE_TICKS, checkCrush, checkRouteRemovalOrder, checkStackDensity, checkStackTopLoad, checkSupportAndLoad, + SCORE_SCALE, SQUARE_METRE_TICKS, checkCrush, checkDeckSupport, checkRouteRemovalOrder, checkStackDensity, checkStackTopLoad, checkSupportAndLoad, occupiedVolume, placedHull, propagateLoad, supportGraph, validNesting, } from './stacking.js'; @@ -58,6 +58,28 @@ export function envelopeOf(placement, clearance) { } } +/** The deck grown by the overhang the placements use past each of its edges. O(p). */ +export function palletFootprint(packed, [x, y, length, width], clearance) { + const envelopes = list(at(packed, 'placements'), 'placements').map((placement) => envelopeOf(placement, clearance)); + const lowX = envelopes.reduce((low, box) => min(low, box.x), x); + const lowY = envelopes.reduce((low, box) => min(low, box.y), y); + const highX = envelopes.reduce((high, box) => max(high, box.x2), x + length); + const highY = envelopes.reduce((high, box) => max(high, box.y2), y + width); + return [highX - lowX, highY - lowY]; +} + +/** + * What a carrier measures: a bag's package; a pallet's declared footprint or its grown + * deck, the larger per axis; else the declared outer, else inner size. + */ +export function billedSize(packed, spec, clearance) { + if (spec.flatBag) return achievedExtent(packed, clearance); + if (spec.deck === null) return spec.outer ?? spec.inner; + const [length, width] = palletFootprint(packed, spec.deck, clearance); + const base = spec.outer ?? [spec.deck[2], spec.deck[3], spec.inner[2]]; + return [max(base[0], length), max(base[1], width), base[2]]; +} + /** The largest envelope x2, y2 and z2 over a packed container's placements. */ export function achievedExtent(packed, clearance) { const boxes = list(at(packed, 'placements'), 'placements').map((placement) => envelopeOf(placement, clearance)); @@ -75,7 +97,7 @@ const optionalTuple = (values) => (values === null ? 'None' : valueTuple(values) /** * `achieved_dimensions` is present exactly when the container declares a soft size, and equals - * the tight extent; a flat bag's extent must pass the girth rule. + * the tight extent; a bag's extent must pass the girth rule, widened by a side gusset (1.10). */ export function checkAchievedExtent(packed, spec, clearance, issues) { const reported = getNull(packed, 'achieved_dimensions'); @@ -85,14 +107,16 @@ export function checkAchievedExtent(packed, spec, clearance, issues) { packedId.verify(); return; } - const [a, b, c] = achievedExtent(packed, clearance); + let [a, b, c] = achievedExtent(packed, clearance); + if (spec.deck !== null) [a, b] = palletFootprint(packed, spec.deck, clearance); const stated = isDict(reported) ? SIDES.map((side) => getNull(getOr(reported, side, {}), 'ticks')) : null; if (!matchesInts(stated, [a, b, c])) { issues.push(issue('achieved_dimensions_mismatch', `${packedId}: reported ${optionalTuple(stated)}, computed ${intTuple([a, b, c])}`)); } - if (spec.flatBag && (a + c > spec.inner[0] || b + c > spec.inner[1])) { + if (spec.flatBag && (a + c > spec.inner[0] || b + c > spec.inner[1] + spec.sideGusset)) { + const gusset = spec.sideGusset > 0n ? `, side gusset ${spec.sideGusset}` : ''; issues.push(issue('bag_girth_exceeded', - `${packedId}: a + c = ${a + c}, b + c = ${b + c}, flat size ${spec.inner[0]} x ${spec.inner[1]}`)); + `${packedId}: a + c = ${a + c}, b + c = ${b + c}, flat size ${spec.inner[0]} x ${spec.inner[1]}${gusset}`)); } packedId.verify(); } @@ -105,6 +129,26 @@ function perSquareMetreCeiling(rate, [length, width, height]) { return ceilDiv(rate * 2n * (length * width + length * height + width * height), SQUARE_METRE_TICKS); } +/** + * Whether a reported box larger than the tight cut is a reserve cut: the tight box + * breaks the void-fill reserve, and the reported one is on the machine grid between the tight box + * and the maximum. That it keeps the reserve is the ordinary reserve check's. Reads the reported + * used volume, which `used_volume_mismatch` holds to the placements. O(1). + */ +function reserveCut(packed, spec, machine, tight, reported) { + if (spec.voidFillReserveRatio.compare(Ratio.of(0n)) === 0 || reported === null) return false; + if (!reported.every((side) => typeof side === 'bigint' || Number.isInteger(side))) return false; + const sides = reported.map(BigInt); + const onGrid = sides.every((side, axis) => tight[axis] <= side && side <= spec.inner[axis] + && pyMod(side - machine.minimum[axis], machine.increment[axis]) === 0n); + const used = getNull(packed, 'used_volume_ticks3'); + if (!onGrid || typeof used !== 'string' || !/^[0-9]+$/.test(used)) return false; + const volume = tight[0] * tight[1] * tight[2]; + const scaled = ratioPpm(spec.voidFillReserveRatio); + const reserved = (volume / SCORE_SCALE) * scaled + ((volume % SCORE_SCALE) * scaled) / SCORE_SCALE; + return BigInt(used) > volume - reserved; +} + /** `(block.get(key) or {}).get("ticks")`. */ function nestedTicks(block, key) { const value = getNull(block, key); @@ -138,11 +182,12 @@ export function checkMadeToMeasure(packed, compact, spec, items, clearance, issu const block = isDict(reportedBlock) ? reportedBlock : {}; if (compact) issues.push(issue('lattice_summary_on_made_to_measure', containerId)); const achieved = achievedExtent(packed, clearance); - const fabricated = [0, 1, 2].map((axis) => fabricatedSide(achieved[axis], machine.minimum[axis], machine.increment[axis])); + let fabricated = [0, 1, 2].map((axis) => fabricatedSide(achieved[axis], machine.minimum[axis], machine.increment[axis])); if (fabricated.some((side, axis) => side > spec.inner[axis])) { issues.push(issue('fabricated_dimensions_exceed_machine', `${containerId}: ${intTuple(fabricated)} beyond ${intTuple(spec.inner)}`)); } const reportedInner = reportedTicks(getNull(packed, 'inner_dimensions')); + if (!matchesInts(reportedInner, fabricated) && reserveCut(packed, spec, machine, fabricated, reportedInner)) fabricated = reportedInner.map(BigInt); if (!matchesInts(reportedInner, fabricated)) { issues.push(issue('fabricated_dimensions_mismatch', `${containerId}: reported ${optionalTuple(reportedInner)}, computed ${intTuple(fabricated)}`)); @@ -187,6 +232,7 @@ export function checkContainer(packed, spec, items, clearance, globalSupport, is checkPairs(entries, packedId, issues); const graph = supportGraph(entries); checkSupportAndLoad(entries, graph, globalSupport, issues); + if (spec.deck !== null) checkDeckSupport(entries, spec.deck, globalSupport, issues); const loads = propagateLoad(entries, graph); checkStackTopLoad(entries, loads, issues); if (spec.maxStackDensity !== null) checkStackDensity(packedId, entries, loads, spec.maxStackDensity, issues); diff --git a/result-validation/geometry.js b/result-validation/geometry.js index 1edeaab..d084701 100644 --- a/result-validation/geometry.js +++ b/result-validation/geometry.js @@ -6,12 +6,30 @@ import { abs, gcd, max, min } from './num.js'; import { shape } from './outcome.js'; -/** Axis permutation each orientation code applies to (length, width, height), in order. */ +/** + * Each of the 24 proper rotations in canonical table order (1.10): the code, the declared side + * (length 0, width 1, height 2) each container axis takes its extent from, and the sign it + * carries. Written out rather than derived, so the table is a second statement of the contract + * and not a copy of an engine's. The legacy codes negate x exactly when their permutation is odd. + */ export const ROTATIONS = Object.freeze([ - ['LWH', [0, 1, 2]], ['LHW', [0, 2, 1]], ['WLH', [1, 0, 2]], - ['WHL', [1, 2, 0]], ['HLW', [2, 0, 1]], ['HWL', [2, 1, 0]], + ['LWH', [0, 1, 2], [1n, 1n, 1n]], ['LHW', [0, 2, 1], [-1n, 1n, 1n]], ['WLH', [1, 0, 2], [-1n, 1n, 1n]], + ['WHL', [1, 2, 0], [1n, 1n, 1n]], ['HLW', [2, 0, 1], [1n, 1n, 1n]], ['HWL', [2, 1, 0], [-1n, 1n, 1n]], + ['+L-W-H', [0, 1, 2], [1n, -1n, -1n]], ['-L+W-H', [0, 1, 2], [-1n, 1n, -1n]], ['-L-W+H', [0, 1, 2], [-1n, -1n, 1n]], + ['+L-H+W', [0, 2, 1], [1n, -1n, 1n]], ['+L+H-W', [0, 2, 1], [1n, 1n, -1n]], ['-L-H-W', [0, 2, 1], [-1n, -1n, -1n]], + ['+W-L+H', [1, 0, 2], [1n, -1n, 1n]], ['+W+L-H', [1, 0, 2], [1n, 1n, -1n]], ['-W-L-H', [1, 0, 2], [-1n, -1n, -1n]], + ['+W-H-L', [1, 2, 0], [1n, -1n, -1n]], ['-W+H-L', [1, 2, 0], [-1n, 1n, -1n]], ['-W-H+L', [1, 2, 0], [-1n, -1n, 1n]], + ['+H-L-W', [2, 0, 1], [1n, -1n, -1n]], ['-H+L-W', [2, 0, 1], [-1n, 1n, -1n]], ['-H-L+W', [2, 0, 1], [-1n, -1n, 1n]], + ['+H-W+L', [2, 1, 0], [1n, -1n, 1n]], ['+H+W-L', [2, 1, 0], [1n, 1n, -1n]], ['-H-W-L', [2, 1, 0], [-1n, -1n, -1n]], ]); +/** The six legacy codes: every box a cuboid can make, and an item's default list. */ +export const BOX_ROTATIONS = Object.freeze(ROTATIONS.slice(0, 6).map(([name]) => name)); + +/** The codes that keep the item's own height vertical and pointing up: z takes `+H`. */ +export const UPRIGHT_ROTATIONS = Object.freeze(ROTATIONS + .filter(([, axes, signs]) => axes[2] === 2 && signs[2] === 1n).map(([name]) => name)); + /** An axis-aligned box; its far corner and base area are computed once. */ export function cuboid(x, y, z, length, width, height) { return Object.freeze({ @@ -66,19 +84,16 @@ function primitiveAxis(axis) { } /** - * Reorient a hull the way `ROTATIONS` reorients its box, flipping one axis on an odd - * permutation so it stays a proper rotation, then translated so its bounds start at 0. + * Reorient a hull by one of the 24 proper rotations, `v'[i] = s[i] * v[src[i]]` from the row + * `ROTATIONS` gives the code `orientation`, then translated so its bounds start at 0. Every code has + * determinant +1, so the result is never the item's mirror image. */ -export function rotateHull(points, axes) { +export function rotateHull(points, orientation) { + const row = ROTATIONS.find(([name]) => name === orientation); + if (row === undefined) throw shape(`unknown orientation ${JSON.stringify(orientation)}`); + const [, axes, signs] = row; if (points.length === 0) throw shape('min() of an empty hull'); - let inversions = 0; - for (let first = 0; first < 3; first += 1) { - for (let second = first + 1; second < 3; second += 1) { - if (axes[first] > axes[second]) inversions += 1; - } - } - const sign = inversions % 2 === 1 ? -1n : 1n; - const turned = points.map((vertex) => [sign * vertex[axes[0]], vertex[axes[1]], vertex[axes[2]]]); + const turned = points.map((vertex) => [0, 1, 2].map((axis) => signs[axis] * vertex[axes[axis]])); const low = [0, 1, 2].map((axis) => turned.reduce((least, vertex) => min(least, vertex[axis]), turned[0][axis])); return turned.map((vertex) => difference(vertex, low)); } diff --git a/result-validation/index.js b/result-validation/index.js index 266fa1a..528e8da 100644 --- a/result-validation/index.js +++ b/result-validation/index.js @@ -16,7 +16,9 @@ * memory. Integers are `BigInt`, so nothing is bounded by 2^53. */ -import { checkAccounting, checkStructure, containerTagRules } from './accounting.js'; +import { + checkAccounting, checkCurrency, checkStructure, containerTagRules, +} from './accounting.js'; import { checkOptimalityBound } from './bound.js'; import { checkAchievedExtent, checkContainer, checkMadeToMeasure } from './container.js'; import { checkAlgorithmMetrics, checkEffortBudget, checkResultFacts } from './facts.js'; @@ -24,6 +26,7 @@ import { PyDict, at, flag, getNull, getOr, has, list, optInt, } from './json.js'; import { materialize } from './lattice.js'; +import { checkPlacementIds } from './placement-ids.js'; import { Refusal, contract, issue, shape } from './outcome.js'; import { pyStr, strRepr } from './pyfmt.js'; import { checkFixedPlacements, checkGroups, checkObjective, checkSolverSelection } from './objective.js'; @@ -104,6 +107,7 @@ function judge(request, result) { const issues = []; const validated = materialize(coordinatesRequired, result, items, containers, clearance, issues); checkStructure(validated, issues); + checkCurrency(request, validated, issues); checkAlgorithmMetrics(validated, issues); checkEffortBudget(request, validated, issues); checkResultFacts(validated, issues); @@ -113,6 +117,7 @@ function judge(request, result) { const fabricated = checkContainers(validated, result, context, globalSupport, issues); checkContainerCounts(validated, configuration, containers, issues); checkFixedPlacements(request, validated, unit, issues); + checkPlacementIds(result, issues); checkGroups(request, validated, issues); checkSolverSelection(request, validated, issues); const score = checkObjective(request, validated, { items, containers, fabricated, clearance }, issues); diff --git a/result-validation/objective.js b/result-validation/objective.js index c57a04a..45b95ae 100644 --- a/result-validation/objective.js +++ b/result-validation/objective.js @@ -3,7 +3,7 @@ * the whole result can answer: fixed placements, groups and solver selection. */ -import { achievedExtent } from './container.js'; +import { billedSize } from './container.js'; import { PyDict, at, compareCodePoints, getNull, getOr, has, hashKey, int, list, pyCmp, pyEq, pyInt, sortPy, text, truthy, } from './json.js'; @@ -59,7 +59,7 @@ function topOf(packed, clearance) { function billedWeight(packed, spec, inputs, billing) { const divisor = int(billing.divisor, 'dimensional_weight_divisor'); const gross = pyInt(getOr(getOr(packed, 'gross_weight', {}), 'ticks', 0), 'gross_weight'); - const size = spec.flatBag ? achievedExtent(packed, inputs.clearance) : spec.outer ?? spec.inner; + const size = billedSize(packed, spec, inputs.clearance); const dimensional = dimensionalWeight(size, divisor, text(billing.lengthUnit, 'dimensional_weight_length_unit'), text(billing.weightUnit, 'dimensional_weight_weight_unit')); return max(gross, dimensional); diff --git a/result-validation/placement-ids.js b/result-validation/placement-ids.js new file mode 100644 index 0000000..bc4a252 --- /dev/null +++ b/result-validation/placement-ids.js @@ -0,0 +1,48 @@ +/** + * Every reported placement names itself by its `placement_id` (1.10), `_check_placement_ids` in + * the reference: the canonical JSON text of `[container id, x, y, z, item type, orientation]` + * with the position in ticks. Written here rather than imported from the engine, so the + * validator shares no code with what it judges. + */ + +import { at, getOr, list } from './json.js'; +import { issue } from './outcome.js'; +import { pyStr } from './pyfmt.js'; + +// `JSON.stringify` escapes exactly the reference's `_id_string` set -- `"`, `\`, the C0 controls +// (`\b \t \n \f \r` by name, the rest as lowercase `\u00xx`) and a lone surrogate as lowercase +// `\udxxx` -- and writes everything else literally. +const idString = (value) => JSON.stringify(pyStr(value)); + +function expectedPlacementId(containerId, placement) { + const position = at(placement, 'position'); + const ticks = ['x', 'y', 'z'].map((axis) => pyStr(at(at(position, axis), 'ticks'))); + return `[${[idString(containerId), ...ticks, idString(at(placement, 'item_type')), + idString(at(placement, 'orientation'))].join(',')}]`; +} + +/** + * Read from the result as reported: a lattice summary has no placement objects, so it has no + * ids to check. A missing or non-string id is a mismatch; a repeat is reported once per repeat + * in result order. O(placements). + */ +export function checkPlacementIds(result, issues) { + const seen = new Set(); + for (const container of list(at(result, 'containers'), 'result.containers')) { + const containerId = getOr(container, 'id', ''); + for (const placement of list(at(container, 'placements'), 'container.placements')) { + const expected = expectedPlacementId(containerId, placement); + const reported = getOr(placement, 'placement_id', null); + const where = `${pyStr(at(placement, 'item_id'))} in ${pyStr(containerId)}`; + if (typeof reported !== 'string') { + issues.push(issue('placement_id_mismatch', `${where}: no placement_id, expected ${expected}`)); + continue; + } + if (reported !== expected) { + issues.push(issue('placement_id_mismatch', `${where}: reported ${reported}, expected ${expected}`)); + } + if (seen.has(reported)) issues.push(issue('duplicate_placement_id', reported)); + seen.add(reported); + } + } +} diff --git a/result-validation/spec.js b/result-validation/spec.js index 256d801..a58bb42 100644 --- a/result-validation/spec.js +++ b/result-validation/spec.js @@ -3,19 +3,17 @@ * `ContainerSpec` and `MachineSpec`). */ -import { ROTATIONS, cuboid } from './geometry.js'; +import { BOX_ROTATIONS, ROTATIONS, UPRIGHT_ROTATIONS, cuboid } from './geometry.js'; import { PyDict, at, compareCodePoints, flag, getNull, getOr, hashKey, int, isDict, list, optInt, pyEq, pyInt, text, truthy, } from './json.js'; import { ratioPpm } from './num.js'; -import { shape } from './outcome.js'; +import { contract, shape } from './outcome.js'; import { LENGTH, WEIGHT, dimensions, ratioOf, toTicks } from './units.js'; /** The six axis-aligned exit directions a placement could be slid out through. */ export const EXIT_DIRECTIONS = Object.freeze(['+x', '-x', '+y', '-y', '+z', '-z']); -const UPRIGHT_ROTATIONS = ['LWH', 'WLH']; - /** `ROTATIONS[orientation]`. */ export function rotationAxes(orientation) { const found = ROTATIONS.find(([name]) => name === orientation); @@ -65,10 +63,19 @@ function optional(raw, key, read) { const weightTicks = (value) => toTicks(value, 'g', WEIGHT); +/** + * The codes an item may be placed in: its list, or the six legacy codes, narrowed to the upright + * ones under `keep_upright`. A signed code counts only on a hull -- the engines refuse it + * anywhere else -- so a box placed in one reads as a forbidden rotation (1.10). + */ function allowedRotations(raw, keepUpright) { const listed = getNull(raw, 'allowed_rotations'); - if (listed !== null) return list(listed, 'allowed_rotations').slice(); - return keepUpright ? UPRIGHT_ROTATIONS.slice() : ROTATIONS.map(([name]) => name); + let rotations = listed === null ? BOX_ROTATIONS.slice() : list(listed, 'allowed_rotations').slice(); + if (!pyEq(getNull(raw, 'shape_type'), 'convex_hull')) { + rotations = rotations.filter((code) => BOX_ROTATIONS.some((name) => pyEq(code, name))); + } + if (keepUpright) rotations = rotations.filter((code) => UPRIGHT_ROTATIONS.some((name) => pyEq(code, name))); + return rotations; } function hullVertices(value, unit) { @@ -80,9 +87,13 @@ function parseItem(raw, unit, index) { const keepUpright = flag(raw, 'keep_upright', false, owner); const stackable = flag(raw, 'stackable', true, owner); const mustBeOnFloor = flag(raw, 'must_be_on_floor', false, owner); + const rotations = allowedRotations(raw, keepUpright); + if (rotations.length === 0) { + throw contract(`${owner}.allowed_rotations must allow at least one rotation after keep_upright`); + } const groups = getNull(raw, 'accepts_stacking_groups'); return Object.freeze({ - allowedRotations: allowedRotations(raw, keepUpright), + allowedRotations: rotations, id: text(at(raw, 'id'), 'an item id'), dimensions: dimensions(at(raw, 'dimensions'), unit), weight: weightTicks(getOr(raw, 'weight', 0)), @@ -170,10 +181,27 @@ function axles(value, unit) { })); } +/** A pallet's solving frame and the deck `[x, y, length, width]` inside it, else the declared size. */ +function frameAndDeck(declared, limit, unit) { + if (limit === null) return [declared, null]; + const overhangX = toTicks(at(limit, 'length'), unit, LENGTH); + const overhangY = toTicks(at(limit, 'width'), unit, LENGTH); + return [[declared[0] + 2n * overhangX, declared[1] + 2n * overhangY, declared[2]], + Object.freeze([overhangX, overhangY, declared[0], declared[1]])]; +} + +/** A gusseted bag's side gusset depth in ticks, else 0n. */ +function sideGusset(boundary, unit) { + if (boundary === null) return 0n; + const depth = getNull(boundary, 'side_gusset_depth'); + return depth === null ? 0n : toTicks(depth, unit, LENGTH); +} + function parseContainer(raw, unit) { + const [inner, deck] = frameAndDeck(dimensions(at(raw, 'inner_dimensions'), unit), getNull(raw, 'pallet_overhang_limit'), unit); return Object.freeze({ id: at(raw, 'id'), - inner: dimensions(at(raw, 'inner_dimensions'), unit), + inner, accessDirections: accessDirections(raw), outer: optional(raw, 'outer_dimensions', (value) => dimensions(value, unit)), costMinor: int(getOr(raw, 'cost_minor', 0), 'cost_minor'), @@ -189,8 +217,14 @@ function parseContainer(raw, unit) { maxStackDensity: optional(raw, 'max_stack_density', weightTicks), rateTable: getNull(raw, 'rate_table'), flatBag: getNull(raw, 'deformable_boundary') !== null, - reportsAchievedExtent: getNull(raw, 'deformable_boundary') !== null || getNull(raw, 'made_to_measure') !== null, + // 1.10: a gusseted bag's side gusset depth G in ticks, widening the width girth to + // b + c <= W + G; 0n for a flat bag and every rigid container. + sideGusset: sideGusset(getNull(raw, 'deformable_boundary'), unit), + reportsAchievedExtent: getNull(raw, 'deformable_boundary') !== null || getNull(raw, 'made_to_measure') !== null + || deck !== null, madeToMeasure: optional(raw, 'made_to_measure', (machine) => parseMachine(machine, unit)), + // The weight-bearing deck inside a pallet's frame, which `inner` then is; else null. + deck, }); } diff --git a/result-validation/stacking.js b/result-validation/stacking.js index 503b62f..0271b26 100644 --- a/result-validation/stacking.js +++ b/result-validation/stacking.js @@ -8,11 +8,11 @@ */ import { cuboid, hullVolume, intersects, overlapAreaXY, rotateHull, vertices } from './geometry.js'; -import { at, hashKey, int, pyCmp, pyEq, sortPy, text } from './json.js'; +import { at, hashKey, int, pyCmp, pyEq, sortPy } from './json.js'; import { Ratio, ceilDiv, fixed6, floorDiv, max, min, ratioPpm } from './num.js'; import { issue, shape } from './outcome.js'; import { list, pyRepr, pyStr } from './pyfmt.js'; -import { EXIT_DIRECTIONS, holds, rotated, rotationAxes } from './spec.js'; +import { EXIT_DIRECTIONS, holds, isRotation, rotated } from './spec.js'; import { TICKS_PER_KG, TICKS_PER_MM } from './units.js'; /** One square metre in length ticks squared. */ @@ -115,6 +115,29 @@ export function checkSupportAndLoad(entries, graph, globalSupport, issues) { }); } +/** + * A floor placement on a pallet rests on its overlap with the deck alone, judged by + * the ratio and centre-of-gravity rule a supporting item would be. O(p). + */ +export function checkDeckSupport(entries, [x, y, length, width], globalSupport, issues) { + const surface = cuboid(x, y, -1n, length, width, 1n); + for (const [rawId, { spec, envelope }] of entries) { + if (spec === undefined || envelope.z !== 0n) continue; + const required = globalSupport.compare(spec.minimumSupportRatio) < 0 ? spec.minimumSupportRatio : globalSupport; + if (required.compare(Ratio.of(0n)) <= 0) continue; + const dx = min(envelope.x2, x + length) - max(envelope.x, x); + const dy = min(envelope.y2, y + width) - max(envelope.y, y); + const area = dx > 0n && dy > 0n ? dx * dy : 0n; + const itemId = pyStr(rawId); + if (area < requiredSupportArea(envelope.baseArea, ratioPpm(required))) { + issues.push(issue('insufficient_support', + `${itemId}: ${fixed6(new Ratio(area, envelope.baseArea).toDouble())} < ${fixed6(required.toDouble())} on the deck`)); + } else if (area * 2n <= envelope.baseArea && !centroidSupported(envelope, [surface])) { + issues.push(issue('centre_of_gravity_unsupported', `${itemId}: centroid outside the deck`)); + } + } +} + /** What one direct supporter allows to rest on it. */ /** * Whether the base centroid lies in the convex hull of the contact areas, the rule @@ -364,8 +387,6 @@ export function validNesting(left, right) { return lower.z !== upper.z && lower.z2 - upper.z === nesting; } -const orientationAxes = (placement) => rotationAxes(text(at(placement, 'orientation'), 'orientation')); - /** * The solid a placement occupies, as vertices in container coordinates: the exact hull when the * request allows it and the reported box matches, otherwise the envelope's corners. @@ -374,11 +395,14 @@ export function placedHull(envelope, placement, spec) { if (spec === undefined || spec.hullVertices === null || spec.shapeType !== 'convex_hull' || spec.stopIndex !== null) { return vertices(envelope); } - const expected = rotated(spec, text(at(placement, 'orientation'), 'orientation')); + // An orientation outside the table is `unknown_orientation`'s to report; the whole box stands. + const orientation = at(placement, 'orientation'); + if (!isRotation(orientation)) return vertices(envelope); + const expected = rotated(spec, orientation); if (envelope.length !== expected[0] || envelope.width !== expected[1] || envelope.height !== expected[2]) { return vertices(envelope); } - return rotateHull(spec.hullVertices, orientationAxes(placement)) + return rotateHull(spec.hullVertices, orientation) .map(([x, y, z]) => [envelope.x + x, envelope.y + y, envelope.z + z]); } @@ -389,7 +413,8 @@ export function occupiedVolume(envelope, placement, spec, load) { return length * width * compressedHeight(height, length * width, spec, load); } if (spec !== undefined && spec.shapeType === 'convex_hull' && spec.hullVertices !== null) { - return hullVolume(rotateHull(spec.hullVertices, orientationAxes(placement))); + // A proper rotation keeps the volume, so the authored vertices answer for every orientation. + return hullVolume(spec.hullVertices); } // Only an exact hull makes `_placed_hull` differ from the envelope's corners, and a hull was // answered above, so the reference's remaining `_hull_volume(solid)` arm is never reached. diff --git a/result-validation/units.js b/result-validation/units.js index 550b98d..a0a62a6 100644 --- a/result-validation/units.js +++ b/result-validation/units.js @@ -1,6 +1,6 @@ /** Measures to exact ticks, re-derived from the unit tables rather than taken from the engine. */ -import { at, get, getNull, isDict, isPythonSpace, pyIntInstance, pyStrip, text } from './json.js'; +import { at, compareCodePoints, get, getNull, isDict, isPythonSpace, pyIntInstance, pyStrip, text } from './json.js'; import { Ratio, parseFraction } from './num.js'; import { contract, shape } from './outcome.js'; import { pyStr } from './pyfmt.js'; @@ -29,13 +29,15 @@ const REQUEST_LENGTH_UNITS = new Map([ /** * `units.length` as every engine reads it: `units` or `units.length` absent or null - * is millimetres; `units` that is not an object, or a length unit the engines do not know, is - * refused naming the field. + * is millimetres; `units` that is not an object, a key other than `length` (the first + * in code-point order), or a length unit the engines do not know, is refused naming the field. */ export function lengthUnit(request) { const units = getNull(request, 'units'); if (units === null) return 'mm'; if (!isDict(units)) throw contract('units must be an object'); + const unknown = Object.keys(units).filter((key) => key !== 'length').sort(compareCodePoints); + if (unknown.length > 0) throw contract(`units.${unknown[0]} is not a known field`); const length = getNull(units, 'length'); if (length === null) return 'mm'; const key = typeof length === 'string' diff --git a/revisions.d.ts b/revisions.d.ts index 1ba241e..96d26de 100644 --- a/revisions.d.ts +++ b/revisions.d.ts @@ -25,14 +25,16 @@ export type RevisionEvent = export interface PlanRevision{format:'packvium-plan-revision/v1';suite_version:string;revision:number;parent:string|null;approved:{artifact:string;replay:ArtifactReplay}|null;events:RevisionEvent[];request:PackingRequest} /** One way a chain fails its audit, anchored on the revision where it shows. */ -export interface RevisionIssue{code:'invalid_revision'|'revision_number'|'parent_mismatch'|'sequence_gap'|'request_mismatch'|'artifact_mismatch';revision:number;detail:string} +export interface RevisionIssue{code:'invalid_revision'|'revision_number'|'parent_mismatch'|'sequence_gap'|'request_mismatch'|'artifact_mismatch'|'artifact_required';revision:number;detail:string} /** Revision 0: the request as first approved, with nothing recorded against it. */ export function rootRevision(request:PackingRequest):PlanRevision; /** The next revision: `events`, observed against `approvedArtifact`, applied to `parent`. Throws `PlanRevisionError`. */ export function deriveRevision(parent:PlanRevision,approvedArtifact:OperationalArtifact,events:RevisionEvent[]):PlanRevision; -/** The request these events derive. Refuses a contradiction (`event_conflict`), never physics. */ +/** The request these events derive. Refuses a contradiction (`event_conflict`), never physics; a placement's signed orientation needs a `convex_hull` item (`invalid_event`, 1.10). */ export function applyEvents(request:PackingRequest,events:RevisionEvent[]):PackingRequest; +/** `request` with every made-to-measure box holding a fixed placement made the stock container `T@#` cut for `approvedArtifact`. Throws `PlanRevisionError`. */ +export function stockCutBoxes(request:PackingRequest,approvedArtifact:OperationalArtifact,revision:number):PackingRequest; /** Every way the chain fails to be what its root and events derive, without stopping at the first. */ export function verifyRevisionChain(revisions:PlanRevision[],artifacts?:(OperationalArtifact|null)[]|null):RevisionIssue[]; /** `sha256:` and the lowercase hex SHA-256 of a document's canonical bytes. */ diff --git a/revisions.js b/revisions.js index f844d11..7802c88 100644 --- a/revisions.js +++ b/revisions.js @@ -17,13 +17,14 @@ import { createHash } from 'node:crypto'; import { FORMAT as ARTIFACT_FORMAT, SUITE_VERSION } from './artifacts.js'; import { CanonicalJsonError, canonicalJson, jsonInteger, jsonSpelling } from './canonical-json.js'; import { compareCodePoints } from './commerce-model.js'; -import { requirePointShape } from './fallback.js'; +import { lengthTicks, requirePointShape, weightTicks } from './fallback.js'; +import { hullItemTypes, refuseOrientation } from './request-errors.js'; +import { perSquareMetre, surfaceArea } from './made-to-measure.js'; export const FORMAT = 'packvium-plan-revision/v1'; export const EVENT_TYPES = Object.freeze(['item_missing', 'container_substituted', 'placement_locked', 'placement_verified']); -const ORIENTATIONS = ['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']; const EVENT_FIELDS = new Map([ ['item_missing', ['item_type', 'quantity']], @@ -32,7 +33,8 @@ const EVENT_FIELDS = new Map([ ['placement_verified', ['placement']], ]); -const PLACEMENT_FIELDS = ['item_type', 'container_type', 'container_instance', 'position', 'orientation']; +const PLACEMENT_FIELDS = ['item_type', 'container_type', 'container_instance', 'position', 'orientation', + 'placement_id']; /** * A revision could not be built. `code` is one of a closed set shared by four engines: @@ -83,13 +85,15 @@ export function deriveRevision(parent, approvedArtifact, events) { throw new PlanRevisionError('invalid_event', 'a revision records at least one event'); } const first = lastSequence(parent) + 1; - const recorded = events.map((event, offset) => recordedEvent(event, first + offset)); - const request = applyEvents(parent.request, recorded); + const hulls = hullTypes(parent.request); + const recorded = events.map((event, offset) => recordedEvent(event, first + offset, hulls)); + const number = jsonInteger(parent.revision) + 1; + const request = stockCutBoxes(applyEvents(parent.request, recorded), approvedArtifact, number); const approved = { artifact: documentDigest(approvedArtifact), replay: clone(approvedArtifact.provenance.replay), }; - const document = revisionDocument(jsonInteger(parent.revision) + 1, documentDigest(parent), approved, recorded, request); + const document = revisionDocument(number, documentDigest(parent), approved, recorded, request); canonicalRevisionJson(document); return document; } @@ -103,8 +107,9 @@ export function applyEvents(request, events) { if (!Array.isArray(events)) throw new PlanRevisionError('invalid_event', 'events are a JSON array'); const derived = clone(request); const placementSession = { fixed: null, keys: null, indexable: true }; + const hulls = hullTypes(request); for (const event of events) { - requireShape(event); + requireShape(event, hulls); const given = get(event, 'sequence'); if (jsonInteger(given) === null) { throw new PlanRevisionError('invalid_event', `event sequence ${jsonSpelling(given)} is not an integer`); @@ -114,6 +119,161 @@ export function applyEvents(request, events) { return derived; } +const SIDES = ['length', 'width', 'height']; + +/** + * `request` with every made-to-measure box that holds a fixed placement made a stock container + *. The box was cut for the approved plan, so a replan cannot recut it: instance `n` of + * made-to-measure type `T` -- the `n`-th container of type `T` in the artifact, in result order -- + * becomes container `T@#`, a copy of `T` without `made_to_measure`, quantity 1, at + * the cut inner and outer size, price and tare, each in ticks; it follows `T` in the container + * list. Its fixed placements move to its instance 1 and `T`'s own `quantity`, if stated, loses the + * boxes taken (`T` is dropped at zero). New boxes are still cut to measure. Pure; O(C + F + A) + * for containers, fixed placements and the artifact's containers. + */ +export function stockCutBoxes(request, approvedArtifact, revision) { + const derived = clone(request); + const fixed = get(derived, 'fixed_placements'), containers = get(derived, 'containers'); + if (!Array.isArray(fixed) || !Array.isArray(containers)) return derived; + const machines = new Map(); + for (const entry of containers) { + if (isMapping(entry) && typeof get(entry, 'id') === 'string' && get(entry, 'made_to_measure') !== null) machines.set(entry.id, entry); + } + const stocked = new Map([...machines.keys()].map((id) => [id, []])); + const named = new Map(); + let cuts = null; + for (const entry of fixed) { + if (!isMapping(entry) || !machines.has(get(entry, 'container_type'))) continue; + const machineId = entry.container_type; + const instance = jsonInteger(hasOwn(entry, 'container_instance') ? entry.container_instance : 1); + const stockId = `${machineId}@${revision}#${instance === null ? 'None' : instance}`; + if (!named.has(stockId)) { + cuts ??= cutBoxes(approvedArtifact); + named.set(stockId, stockBox(derived, machines.get(machineId), stockId, cutInner(cuts, machineId, instance))); + stocked.get(machineId).push(named.get(stockId)); + } + entry.container_type = stockId; + entry.container_instance = 1; + // A stated id named the cut box, which this entry no longer sits in (1.10). + delete entry.placement_id; + } + if (!named.size) return derived; + const existing = new Set(containers.filter(isMapping).map((container) => get(container, 'id'))); + let clash = null; + for (const id of named.keys()) { + if (existing.has(id) && (clash === null || compareCodePoints(id, clash) < 0)) clash = id; + } + if (clash !== null) throw new PlanRevisionError('event_conflict', `a container is already named ${jsonSpelling(clash)}`); + const rebuilt = []; + for (const container of containers) { + const boxes = isMapping(container) ? stocked.get(get(container, 'id')) : undefined; + if (!boxes?.length) { + rebuilt.push(container); + continue; + } + const quantity = get(container, 'quantity') === null ? null : jsonInteger(container.quantity); + if (quantity === null || quantity > boxes.length) { + if (quantity !== null) container.quantity = quantity - boxes.length; + rebuilt.push(container); + } + rebuilt.push(...boxes); + } + derived.containers = rebuilt; + return derived; +} + +/** Index raw shapes in artifact order; only selected dimensions are parsed. O(A). */ +function cutBoxes(artifact) { + const orders = get(get(artifact, 'work_order'), 'containers'), shapes = get(get(artifact, 'geometry'), 'containers'); + const cuts = new Map(); + if (Array.isArray(orders) && Array.isArray(shapes)) { + for (let index = 0; index < Math.min(orders.length, shapes.length); index += 1) { + const machineId = isMapping(orders[index]) ? get(orders[index], 'container_type') : null; + if (typeof machineId === 'string') { + if (!cuts.has(machineId)) cuts.set(machineId, []); + cuts.get(machineId).push(shapes[index]); + } + } + } + return cuts; +} + +/** The selected box's ticks, read only when it is stocked. Expected O(1). */ +function cutInner(cuts, machineId, instance) { + const shapes = cuts.get(machineId) ?? []; + if (instance !== null && instance >= 1 && instance <= shapes.length) { + return SIDES.map((axis) => artifactTicks(shapes[instance - 1], axis)); + } + throw new PlanRevisionError('invalid_artifact', `the approved plan holds no ${machineId} instance ${jsonSpelling(instance)}`); +} + +function artifactTicks(shape, axis) { + const value = get(get(shape, 'inner_dimensions'), axis); + const ticks = typeof value === 'string' && /^[0-9]+$/.test(value) ? jsonInteger(Number(value)) : null; + if (ticks === null) throw new PlanRevisionError('invalid_artifact', `a cut box's ${axis} is not a tick count`); + return ticks; +} + +/** The stock container a cut box becomes, priced as `made-to-measure.js` `fabricate` prices it. */ +function stockBox(request, machine, stockId, inner) { + const units = stockObject(get(request, 'units'), 'units', true); + const unit = get(units, 'length') ?? 'mm'; + if (typeof unit !== 'string') throw new PlanRevisionError('invalid_revision', "a made-to-measure container's units.length is not a string"); + const spec = stockObject(machine.made_to_measure, 'made_to_measure'); + const allowance = stockObject(get(spec, 'outer_allowance'), 'outer_allowance', true); + const outer = SIDES.map((axis, index) => inner[index] + (get(allowance, axis) === null ? 0 + : stockMeasure(() => lengthTicks(allowance[axis], unit), 'outer_allowance'))); + const area = surfaceArea(inner); + const price = stockInteger(get(spec, 'cost_per_square_metre_minor'), 'cost_per_square_metre_minor'); + const tareRate = get(spec, 'tare_per_square_metre') === null ? 0n + : stockMeasure(() => weightTicks(spec.tare_per_square_metre), 'tare_per_square_metre'); + const declaredTare = get(machine, 'tare_weight') === null ? 0n + : stockMeasure(() => weightTicks(machine.tare_weight), 'tare_weight'); + const box = {}; + for (const [key, value] of Object.entries(machine)) { + if (key !== 'made_to_measure') Object.defineProperty(box, key, { value: clone(value), enumerable: true, writable: true, configurable: true }); + } + Object.assign(box, { + id: stockId, + quantity: 1, + inner_dimensions: tickBox(inner), + outer_dimensions: tickBox(outer), + cost_minor: Number(BigInt(stockInteger(get(machine, 'cost_minor'), 'cost_minor')) + perSquareMetre(price, area)), + tare_weight: { value: String(BigInt(declaredTare) + perSquareMetre(tareRate, area)), unit: 'ticks' }, + }); + return box; +} + +function stockObject(value, name, optional = false) { + if (value === null && optional) return {}; + if (!isMapping(value)) throw new PlanRevisionError('invalid_revision', `a made-to-measure container's ${name} is not an object`); + return value; +} + +function stockInteger(value, name) { + const integer = jsonInteger(value ?? 0); + if (integer === null) throw new PlanRevisionError('invalid_revision', `a made-to-measure container's ${name} is not an integer`); + return integer; +} + +function stockMeasure(read, name) { + try { return read(); } catch (error) { + if (!(error instanceof RangeError || error instanceof TypeError || error instanceof SyntaxError)) throw error; + throw new PlanRevisionError('invalid_revision', `a made-to-measure container's ${name} is not a measure`); + } +} + +const tickBox = (sides) => Object.fromEntries(SIDES.map((axis, index) => [axis, { value: String(sides[index]), unit: 'ticks' }])); + +/** Whether a fixed placement names a made-to-measure container type. */ +function holdsCutBoxes(request) { + const containers = get(request, 'containers'), fixed = get(request, 'fixed_placements'); + if (!Array.isArray(containers) || !Array.isArray(fixed)) return false; + const machines = new Set(containers.filter((entry) => isMapping(entry) && get(entry, 'made_to_measure') !== null) + .map((entry) => get(entry, 'id'))); + return fixed.some((entry) => isMapping(entry) && machines.has(get(entry, 'container_type'))); +} + /** * Every way the chain fails to be what its root and events derive, without stopping at the * first. `artifacts[k]`, when given, is the artifact revision `k` names as approved. @@ -145,8 +305,8 @@ export function verifyRevisionChain(revisions, artifacts = null) { issues.push(issue('parent_mismatch', position, `parent ${plain(recordedParent)}, expected ${plain(expectedParent)}`)); } last = checkSequences(revision, position, last, issues); - if (parent !== null) checkRequest(revision, parent, position, issues); const artifact = artifacts == null || position >= artifacts.length ? null : artifacts[position]; + if (parent !== null) checkRequest(revision, parent, artifact, position, issues); if (artifact != null && parent !== null) checkArtifact(revision, parent, artifact, position, issues); } return issues; @@ -208,8 +368,8 @@ function lastSequence(revision) { return 0; } -function recordedEvent(raw, sequence) { - requireShape(raw); +function recordedEvent(raw, sequence, hulls) { + requireShape(raw, hulls); const given = get(raw, 'sequence'); if (jsonInteger(given) !== sequence) { throw new PlanRevisionError('invalid_event', `event sequence ${jsonSpelling(given)} does not continue the chain at ${sequence}`); @@ -217,7 +377,12 @@ function recordedEvent(raw, sequence) { return clone(raw); } -function requireShape(raw) { +/** The request's hull item types, which alone may be placed in a signed orientation (1.10). */ +function hullTypes(request) { + return isMapping(request) ? hullItemTypes(get(request, 'items')) : new Set(); +} + +function requireShape(raw, hulls) { if (!isMapping(raw)) throw new PlanRevisionError('invalid_event', 'an event is a JSON object'); const kind = get(raw, 'type'); const fields = typeof kind === 'string' ? EVENT_FIELDS.get(kind) : undefined; @@ -227,7 +392,7 @@ function requireShape(raw) { if (unknown.length) throw new PlanRevisionError('invalid_event', `${kind} does not carry ${jsonSpelling(unknown)}`); const missing = fields.filter((field) => !hasOwn(raw, field)); if (missing.length) throw new PlanRevisionError('invalid_event', `${kind} needs ${jsonSpelling(missing)}`); - VALIDATE[kind](raw); + VALIDATE[kind](raw, hulls); } function validateItemMissing(event) { @@ -244,21 +409,26 @@ function validateContainerSubstituted(event) { requireName(get(event.replacement, 'id'), 'replacement.id'); } -function validatePlacement(event) { +function validatePlacement(event, hulls) { const { placement } = event; if (!isMapping(placement)) throw new PlanRevisionError('invalid_event', 'a placement is a fixed-placement object'); const unknown = Object.keys(placement).filter((key) => !PLACEMENT_FIELDS.includes(key)).sort(compareCodePoints); if (unknown.length) throw new PlanRevisionError('invalid_event', `a placement does not carry ${jsonSpelling(unknown)}`); requireName(get(placement, 'item_type'), 'placement.item_type'); requireName(get(placement, 'container_type'), 'placement.container_type'); - if (!ORIENTATIONS.includes(get(placement, 'orientation'))) { - throw new PlanRevisionError('invalid_event', `placement.orientation must be one of ${jsonSpelling(ORIENTATIONS)}`); - } + refuseOrientation(get(placement, 'orientation'), hulls.has(get(placement, 'item_type')), '', + (_reason, _field, detail) => new PlanRevisionError('invalid_event', detail), 'placement.orientation'); const instance = jsonInteger(hasOwn(placement, 'container_instance') ? placement.container_instance : 1); if (instance === null || instance < 1) { throw new PlanRevisionError('invalid_event', 'placement.container_instance counts from 1'); } requirePointShape(hasOwn(placement, 'position') ? placement.position : {}, 'placement.position', '', invalidEvent); + // Only its type is the event's to judge: whether it names this placement needs the request's + // unit, and is the engine's admission rule when the revision is solved (1.10). + const identifier = get(placement, 'placement_id'); + if (identifier != null && typeof identifier !== 'string') { + throw new PlanRevisionError('invalid_event', 'placement.placement_id is a string'); + } } function invalidEvent(message) { @@ -317,12 +487,12 @@ function applyContainerSubstituted(request, event) { } function applyPlacement(request, event, session) { - const placement = withDefaultInstance(event.placement); + const placement = identity(event.placement); if (session.fixed === null) { session.fixed = fixedPlacements(request); request.fixed_placements = session.fixed; try { - session.keys = new Set(session.fixed.map((entry) => canonicalRevisionJson(withDefaultInstance(entry)))); + session.keys = new Set(session.fixed.map((entry) => canonicalRevisionJson(identity(entry)))); } catch (error) { if (!(error instanceof PlanRevisionError)) throw error; session.indexable = false; @@ -345,12 +515,18 @@ function applyPlacement(request, event, session) { } // Direct callers may supply values outside canonical JSON. Keep the former first-match // refusal order rather than making construction of the index an earlier refusal. - if (fixed.some((entry) => same(withDefaultInstance(entry), placement))) return; + if (fixed.some((entry) => same(identity(entry), placement))) return; fixed.push(clone(event.placement)); } -function withDefaultInstance(placement) { - return { container_instance: 1, ...clone(placement) }; +/** + * What makes two fixed entries one box: everything but `placement_id`, which only restates the + * container and position (1.10), with the default instance spelled out. + */ +function identity(placement) { + const entry = { container_instance: 1, ...clone(placement) }; + delete entry.placement_id; + return entry; } const APPLY = { @@ -399,10 +575,18 @@ function checkSequences(revision, position, last, issues) { return next; } -function checkRequest(revision, parent, position, issues) { +function checkRequest(revision, parent, artifact, position, issues) { let derived; try { derived = applyEvents(parent.request, revision.events); + if (holdsCutBoxes(derived)) { + // The cut sizes live only in the approved artifact. + if (artifact == null) { + issues.push(issue('artifact_required', position, 'fixed items in a made-to-measure box are checked against the approved artifact')); + return; + } + derived = stockCutBoxes(derived, artifact, jsonInteger(revision.revision)); + } } catch (error) { if (!(error instanceof PlanRevisionError)) throw error; issues.push(issue('request_mismatch', position, `the events do not apply: ${error.message}`)); diff --git a/scene.d.ts b/scene.d.ts index 3abab89..0b717ac 100644 --- a/scene.d.ts +++ b/scene.d.ts @@ -2,7 +2,7 @@ // // visualizer/spec/scene.md and scene.schema.json are the contract: one scene is one container, // every length an exact tick string beside a display-only `mm` decimal. -import type { PackingRequest, PackingResult } from './index.js'; +import type { Orientation, PackingRequest, PackingResult } from './index.js'; export const SCENE_FORMAT: 'packvium-visualization-scene/v1'; @@ -10,7 +10,8 @@ export const SCENE_FORMAT: 'packvium-visualization-scene/v1'; export interface SceneLength{ticks:string;mm:string} export interface ScenePoint{x:SceneLength;y:SceneLength;z:SceneLength} export interface SceneDimensions{length:SceneLength;width:SceneLength;height:SceneLength} -export type SceneRotation='LWH'|'LHW'|'WLH'|'WHL'|'HLW'|'HWL'; +/** One of the 24 table codes (1.10); the item's `dimensions` are already its rotated envelope. */ +export type SceneRotation=Orientation; export interface SceneItem{id:string;position:ScenePoint;dimensions:SceneDimensions;rotation:SceneRotation} export interface SceneStep{step:number;item_id:string} diff --git a/scene.js b/scene.js index b771a1b..33da7ae 100644 --- a/scene.js +++ b/scene.js @@ -9,6 +9,7 @@ */ import { buildOperationalArtifact } from './artifacts.js'; +import { ORIENTATIONS } from './request-errors.js'; export const SCENE_FORMAT = 'packvium-visualization-scene/v1'; @@ -113,8 +114,8 @@ function sceneDimensions(raw, path) { /** * The scene item id for a plan placement reference, `@,,/`. * Injective within a container even when `item_type` contains `@` or `/`: read from the right, - * the orientation is one of six letter triples and the coordinates hold only digits, commas and - * minus signs (docs/EXECUTION-PLAN.md). Exported so a caller can match a scene item to its step. + * the orientation is one of the 24 table codes, none of which holds `/` or `@`, and the + * coordinates hold only digits, commas and minus signs (docs/EXECUTION-PLAN.md). Exported so a caller can match a scene item to its step. * A reference without those fields is a `RangeError`. */ export function sceneItemId(reference) { @@ -126,7 +127,10 @@ function spellItemId({ item_type: itemType, orientation, position_ticks: { x, y, return `${itemType}@${x},${y},${z}/${orientation}`; } -const ROTATIONS = new Set(['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']); +// All 24 proper rotations in table order (1.10): a hull may be placed in a signed code. A scene +// item is drawn as its placed box -- the artifact's dimensions are already the rotated envelope -- +// so the code is carried, never applied a second time. +const ROTATIONS = new Set(ORIENTATIONS); /** A placement reference holding every field a scene reads, or a `RangeError` naming the first that is not. */ function readReference(raw, path) { diff --git a/test/access-directions.test.mjs b/test/access-directions.test.mjs index a30af39..4695bda 100644 --- a/test/access-directions.test.mjs +++ b/test/access-directions.test.mjs @@ -123,7 +123,7 @@ test('a non-string entry is refused', () => { test('with access_directions -x and stops, all items are packed', () => { const req = { - units: { length: 'mm', weight: 'g' }, + units: { length: 'mm' }, configuration: { solver_profile: 'fast' }, containers: [{ id: 'truck', diff --git a/test/conformance.test.mjs b/test/conformance.test.mjs index b25a80e..9127421 100644 --- a/test/conformance.test.mjs +++ b/test/conformance.test.mjs @@ -97,11 +97,14 @@ for (const name of names) { test(`${name}: the result has the documented shape`, () => { const result = pack(request); + // `currency` is echoed exactly when the request declares one (1.10). + const declared = request.currency == null ? [] : ['currency']; assert.deepEqual(Object.keys(result).sort(), [ - 'algorithm', 'alternatives', 'catalog_versions_used', 'complete', 'containers', + 'algorithm', 'alternatives', 'catalog_versions_used', 'complete', 'containers', ...declared, 'feasibility', 'objective', 'optimality', 'score', 'status', 'summary', 'termination', 'unpacked_items', 'warnings', ]); + assert.equal(result.currency, request.currency ?? undefined); assert.equal(typeof result.feasibility.code, 'string'); assert.equal(typeof result.termination.code, 'string'); assert.equal(typeof result.optimality.code, 'string'); diff --git a/test/contact-graph.test.mjs b/test/contact-graph.test.mjs index c512ee5..ffe92e3 100644 --- a/test/contact-graph.test.mjs +++ b/test/contact-graph.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { appendContactBox, buildContactGraph } from '../contact-graph.js'; +import { appendContactBox, buildContactGraph, contactsAbove, contactsBelow } from '../contact-graph.js'; const overlapXY = (left, right) => { const x = Math.max(0, Math.min(left.x + left.d[0], right.x + right.d[0]) - Math.max(left.x, right.x)); @@ -171,3 +171,26 @@ test('the delta matches a rebuild across scene shapes', () => { }); }); +test('plane bucket threshold 32 vs 33 and zero-area knife-edge touches produce identical edges', () => { + for (const count of [0, 1, 31, 32, 33, 34, 65]) { + const boxes = Array.from({ length: count }, (_, i) => ({ + x: i * 10, y: 0, z: 0, d: [10, 10, 10], + })); + const hint = Math.max(10, count * 10); + const query = { x: 0, y: 9, z: 10, d: [hint, 2, 10] }; + const graph = buildContactGraph(boxes, overlapXY, hint); + assert.deepEqual( + contactsBelow(graph, query, overlapXY), + Array.from({ length: count }, (_, i) => [i, 10]), + ); + // Touching only along y=10 (0 overlap area) produces no edges. + const touchingOnly = { x: 0, y: 10, z: 10, d: [hint, 5, 10] }; + assert.deepEqual(contactsBelow(graph, touchingOnly, overlapXY), []); + assert.deepEqual(contactsAbove(graph, { x: 0, y: 9, z: -10, d: [hint, 2, 10] }, overlapXY), + Array.from({ length: count }, (_, i) => [i, 10])); + const appended = appendContactBox(graph, query, overlapXY); + const rebuilt = buildContactGraph([...boxes, query], overlapXY, hint); + assert.deepEqual(edges(appended), edges(rebuilt)); + } +}); + diff --git a/test/currency.test.mjs b/test/currency.test.mjs new file mode 100644 index 0000000..a710904 --- /dev/null +++ b/test/currency.test.mjs @@ -0,0 +1,109 @@ +/** + * 1.10: a request may declare its currency, a rate table may say which it prices in, and the + * result echoes the request's. Nothing converts, prices or ranks by it. Mirrors the Python + * reference's `test_currency.py`. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InvalidRequestError, packFallback } from '../fallback.js'; +import { validateDocuments } from '../result-validation/index.js'; + +const THREE_LETTERS = 'must be three capital letters (ISO 4217)'; + +function request(currency, table) { + const built = { + items: [{ id: 'cube', quantity: 2, weight: '1000', dimensions: { length: '100', width: '100', height: '100' } }], + containers: [{ id: 'box', inner_dimensions: { length: '200', width: '100', height: '200' }, + rate_table: { weight_brackets_g: [5000], prices_minor: [900] } }], + configuration: { solver_profile: 'fast' }, + }; + if (currency !== undefined) built.currency = currency; + if (table !== undefined) built.containers[0].rate_table.currency = table; + return built; +} + +const withoutDuration = (result) => ({ ...result, algorithm: { ...result.algorithm, duration_ms: 0 } }); + +/** Two solvers that disagree, so the answer carries a runner-up (conformance's feature-alternatives-runner-up). */ +const runnerUp = () => ({ + currency: 'EUR', + items: [ + { id: 'wide', quantity: 1, dimensions: { length: '60', width: '100', height: '100' } }, + { id: 'narrow', quantity: 1, dimensions: { length: '40', width: '100', height: '100' } }, + { id: 'half', quantity: 2, dimensions: { length: '50', width: '100', height: '100' } }, + ], + containers: [{ id: 'crate', quantity: 3, inner_dimensions: { length: '100', width: '100', height: '100' }, + rate_table: { weight_brackets_g: [5000], prices_minor: [900], currency: 'EUR' } }], + configuration: { solver_profile: 'balanced', alternatives: 3, time_limit_ms: 300000, + effort_budget: { max_search_nodes: 1000000 }, solvers: ['extreme_points', 'layer'] }, +}); + +test('the result echoes the request currency on the answer and every alternative', () => { + const declared = runnerUp(); + const result = packFallback(declared); + assert.equal(result.currency, 'EUR'); + assert.ok(result.alternatives.length > 0); + for (const alternative of result.alternatives) { + const keys = Object.keys(alternative); + assert.equal(alternative.currency, 'EUR'); + assert.equal(keys.indexOf('currency'), keys.indexOf('catalog_versions_used') + 1); + } + assert.deepEqual(validateDocuments(declared, result), { issues: [] }); +}); + +test('the currency sits after catalog_versions_used and before warnings', () => { + const keys = Object.keys(packFallback(request('EUR'))); + assert.equal(keys.indexOf('currency'), keys.indexOf('catalog_versions_used') + 1); + assert.equal(keys.indexOf('warnings'), keys.indexOf('currency') + 1); +}); + +test('a request without a currency keeps its bytes', () => { + assert.equal('currency' in packFallback(request()), false); + assert.equal('currency' in packFallback(request(null)), false); +}); + +test('the currency changes nothing but the echo', () => { + const plain = withoutDuration(packFallback(request())); + const { currency, ...rest } = withoutDuration(packFallback(request('JPY'))); + assert.equal(currency, 'JPY'); + assert.deepEqual(rest, plain); +}); + +for (const [currency, table, reason, field, detail] of [ + [978, undefined, 'wrong_type', '/currency', 'must be a string'], + ['usd', undefined, 'invalid_value', '/currency', THREE_LETTERS], + ['USD\n', undefined, 'invalid_value', '/currency', THREE_LETTERS], + ['ÜSD', undefined, 'invalid_value', '/currency', THREE_LETTERS], + ['USD', 840, 'wrong_type', '/containers/0/rate_table/currency', 'must be a string'], + ['USD', 'EURO', 'invalid_value', '/containers/0/rate_table/currency', THREE_LETTERS], + [undefined, 'USD', 'missing_field', '/currency', 'is required when a rate_table declares a currency'], + [null, 'USD', 'missing_field', '/currency', 'is required when a rate_table declares a currency'], + ['USD', 'EUR', 'not_allowed', '/containers/0/rate_table/currency', 'must equal the request currency "USD"'], +]) { + test(`a bad currency is refused: ${JSON.stringify([currency, table])}`, () => { + assert.throws(() => packFallback(request(currency, table)), (error) => error instanceof InvalidRequestError + && error.reason === reason && error.field === field && error.detail === detail); + }); +} + +test('a rate table may leave its currency to the request, or repeat it', () => { + assert.equal(packFallback(request('USD')).currency, 'USD'); + assert.equal(packFallback(request('USD', 'USD')).currency, 'USD'); + assert.equal(packFallback(request(undefined, null)).currency, undefined); +}); + +test('the validator holds the echo to the request', () => { + const declared = request('EUR'); + const result = packFallback(declared); + const { currency, ...silent } = result; + assert.equal(currency, 'EUR'); + assert.deepEqual(validateDocuments(declared, silent).issues, + [{ code: 'currency_mismatch', detail: "reported None, requested 'EUR'" }]); + assert.deepEqual(validateDocuments(declared, { ...result, currency: 'USD' }).issues, + [{ code: 'currency_mismatch', detail: "reported 'USD', requested 'EUR'" }]); + assert.deepEqual(validateDocuments(request(), { ...result, currency: 'EUR' }).issues, + [{ code: 'currency_mismatch', detail: "reported 'EUR', requested None" }]); + assert.deepEqual(validateDocuments({ ...request(), currency: null }, { ...silent, currency: null }).issues, []); +}); diff --git a/test/effective-rotations.test.mjs b/test/effective-rotations.test.mjs new file mode 100644 index 0000000..49a9288 --- /dev/null +++ b/test/effective-rotations.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InvalidRequestError, packFallback } from '../fallback.js'; +import { itemRotations } from '../request-errors.js'; +import { validateDocuments } from '../result-validation/index.js'; + +function request(allowedRotations, keepUpright = true) { + return { + configuration: { solver_profile: 'fast' }, + items: [{ + id: 'wedge', shape_type: 'convex_hull', keep_upright: keepUpright, + allowed_rotations: allowedRotations, + dimensions: { length: '10', width: '10', height: '10' }, + hull_vertices: [[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 10]].map(([x, y, z]) => ({ x, y, z })), + }], + containers: [{ id: 'crate', inner_dimensions: { length: '10', width: '10', height: '10' } }], + }; +} + +for (const [codes, upright] of [[['+L-W-H'], true], [['LHW'], true], [[], true], [[], false]]) { + test(`a hull with no effective rotation is refused: ${JSON.stringify(codes)}, upright ${upright}`, () => { + assert.throws(() => packFallback(request(codes, upright)), (error) => { + assert.ok(error instanceof InvalidRequestError); + assert.equal(error.reason, 'invalid_value'); + assert.equal(error.field, '/items/0/allowed_rotations'); + assert.equal(error.message, 'invalid_request: /items/0/allowed_rotations: must allow at least one rotation after keep_upright'); + return true; + }); + }); +} + +test('upright filtering keeps the caller order of the rotations that remain', () => { + assert.deepEqual(itemRotations({ + allowed_rotations: ['+L-W-H', '+W-L+H', 'WLH', '-L-W+H', 'LWH'], keep_upright: true, + }), ['+W-L+H', 'WLH', '-L-W+H', 'LWH']); +}); + +test('a malformed upright flag is checked before an empty effective rotation list', () => { + const invalid = request(['+L-W-H']); + invalid.items[0].keep_upright = 'true'; + assert.throws(() => packFallback(invalid), (error) => { + assert.equal(error.reason, 'wrong_type'); + assert.equal(error.field, '/items/0/keep_upright'); + return true; + }); +}); + +for (const [codes, upright] of [[['+L-W-H'], true], [['LHW'], true], [[], true], [[], false]]) { + test(`the independent validator refuses an empty effective list: ${JSON.stringify(codes)}, upright ${upright}`, () => { + const valid = request(['LWH']); + const result = packFallback(valid); + const invalid = request(codes, upright); + assert.deepEqual(validateDocuments(invalid, result), { + refusal: { + kind: 'contract', + message: 'items[0].allowed_rotations must allow at least one rotation after keep_upright', + }, + }); + }); +} diff --git a/test/fallback.test.mjs b/test/fallback.test.mjs index 8cc06cd..a99ee4d 100644 --- a/test/fallback.test.mjs +++ b/test/fallback.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; import { Deadline, SequenceReplayError, UNSUPPORTED_FIELDS, UnsupportedFeatureError, BoundOverflowError, __spatialIndexForTests, __inspectHullShapeForTests, __objectiveBoundsForTests, - __requestBoundForTests, + __objectiveVectorForTests, __requestBoundForTests, __runSmallestUnsortedForTests, __occupiesLessThanItsBoxForTests, __candidatePrefixForTests, __groupBatchesForTests, aggregateTermination, InvalidRequestError, explainUnpackedItem, explanationForUnpackedItem, packFallback, rebalanceWeight, verifyLoadingPrefixBusinessRules, __ratiosForTests, __planBeamForTests, @@ -2144,45 +2144,50 @@ test('the objective lower bound matches Python on every corpus case', (t) => { return; } const scene = JSON.parse(readFileSync(sceneUrl)); - assert.equal(scene.format, 'packvium-objective-bounds/v1'); + assert.equal(scene.format, 'packvium-objective-bounds/v2'); assert.ok(scene.cases.length > 300, 'a scene that quietly emptied itself would make every case below vacuous'); + const instancesOf = (scenario) => scenario.instances.map((raw) => ({ + volume: BigInt(raw.volume), weight: BigInt(raw.weight), shrinks: raw.shrinks, + value: BigInt(raw.value), + })); + const containersOf = (scenario) => scenario.containers.map((raw) => ({ + usable: BigInt(raw.usable), inner: BigInt(raw.inner), baseArea: BigInt(raw.base_area), + height: BigInt(raw.height), + payload: raw.payload === null ? null : BigInt(raw.payload), + maxItems: raw.max_items === null ? null : BigInt(raw.max_items), + quantity: raw.quantity === null ? null : BigInt(raw.quantity), + costMinor: BigInt(raw.cost_minor), + })); + // Every covered objective's vector, `null` where a ceiling withholds it: the + // objective-only keys of maximum_value, open_dimension_height and maximum_volume included. + const assertObjectives = (scenario, instances, containers, name) => { + assert.deepEqual(Object.keys(scenario.objectives).sort(), + ['default', 'lowest_cost', 'maximum_value', 'maximum_volume', 'open_dimension_height'], name); + for (const [objective, expected] of Object.entries(scenario.objectives)) { + assert.deepEqual(__objectiveVectorForTests(instances, containers, objective), expected, + `${objective} diverges from Python on ${name}`); + } + }; for (const one of scene.cases) { - const instances = one.instances.map((raw) => ({ - volume: BigInt(raw.volume), weight: BigInt(raw.weight), shrinks: raw.shrinks, - })); - const containers = one.containers.map((raw) => ({ - usable: BigInt(raw.usable), inner: BigInt(raw.inner), baseArea: BigInt(raw.base_area), - height: BigInt(raw.height), - payload: raw.payload === null ? null : BigInt(raw.payload), - maxItems: raw.max_items === null ? null : BigInt(raw.max_items), - quantity: raw.quantity === null ? null : BigInt(raw.quantity), - costMinor: BigInt(raw.cost_minor), - })); + const instances = instancesOf(one), containers = containersOf(one); assert.deepEqual(__objectiveBoundsForTests(instances, containers), one.bounds, `bounds diverge from Python on ${one.fixture}`); + assertObjectives(one, instances, containers, one.fixture); } - // The edges are written from the oracle: the fixed-placement rule and every edge of the one - // refusal predicate, where null means this port must refuse too (finding F4). + // The edges are written from the oracle: the fixed-placement rule, the objective-only keys + // and every edge of the one refusal predicate, where null means this port must refuse too + // (finding F4). assert.ok(scene.edges.length > 0, 'the refusal predicate\'s edges are not optional'); for (const edge of scene.edges) { - const instances = edge.instances.map((raw) => ({ - volume: BigInt(raw.volume), weight: BigInt(raw.weight), shrinks: raw.shrinks, - })); - const containers = edge.containers.map((raw) => ({ - usable: BigInt(raw.usable), inner: BigInt(raw.inner), baseArea: BigInt(raw.base_area), - height: BigInt(raw.height), - payload: raw.payload === null ? null : BigInt(raw.payload), - maxItems: raw.max_items === null ? null : BigInt(raw.max_items), - quantity: raw.quantity === null ? null : BigInt(raw.quantity), - costMinor: BigInt(raw.cost_minor), - })); + const instances = instancesOf(edge), containers = containersOf(edge); if (edge.bounds === null) { assert.throws(() => __objectiveBoundsForTests(instances, containers), BoundOverflowError, edge.name); } else { assert.deepEqual(__objectiveBoundsForTests(instances, containers), edge.bounds, edge.name); } + assertObjectives(edge, instances, containers, edge.name); } }); @@ -2194,7 +2199,7 @@ test('the request bound is read from the request itself, in the objective\'s own assert.deepEqual(__requestBoundForTests(metre), [0, 1, 3, 0, 1000000]); assert.deepEqual(__requestBoundForTests({ ...metre, configuration: { objective: 'lowest_cost' } }), [0, 3, 1, 0, 1000000]); - assert.equal(__requestBoundForTests({ ...metre, configuration: { objective: 'maximum_value' } }), null); + assert.equal(__requestBoundForTests({ ...metre, configuration: { objective: 'shipping_cost' } }), null); assert.equal(__requestBoundForTests({ ...metre, configuration: { objective: 'constructor' } }), null); }); @@ -2213,10 +2218,78 @@ test('a result states its bound, and attainment is the only route to optimal', ( missed.score[missed.optimality.gap_key] - missed.optimality.lower_bound[missed.optimality.gap_key]); const uncovered = packFallback(request([cube('c', 100)], [box('b', 300, 100, 100)], - { configuration: { objective: 'maximum_volume' } })); + { configuration: { objective: 'shipping_cost', dimensional_weight_divisor: 5000 } })); assert.deepEqual(uncovered.optimality, { code: 'not_proven' }); }); +test('maximum_value, open_dimension_height and maximum_volume each bound their own key', () => { + // One metre cube of three fits the only box. Leaving the two cheapest forgoes + // 10 + 50; the one placed stands at least its volume over the floor, 1000 mm in ticks; + // 2 of the 3 requested volumes cannot fit, 666666.7 ppm, scored rounded up. + const values = [50, 10, 90].map((value, index) => cube(`m${index}`, 1000, { value })); + const crowded = (objective) => request(values, [box('b', 1000, 1000, 1000, { quantity: 1 })], + { configuration: { objective } }); + assert.deepEqual(__requestBoundForTests(crowded('maximum_value')), [2, 60, 1, 0, 0]); + assert.deepEqual(__requestBoundForTests(crowded('open_dimension_height')), [2, 16000000, 1, 0, 0]); + assert.deepEqual(__requestBoundForTests(crowded('maximum_volume')), [666667, 2, 1, 0, 0]); + // Nothing placed, or an instance smaller than its box, gives the volume terms nothing. + const none = request([cube('m', 1000)], [box('b', 10, 10, 10, { quantity: 1 })], + { configuration: { objective: 'open_dimension_height' } }); + assert.deepEqual(__requestBoundForTests(none), [1, 0, 0, 0, 0]); + const nested = request([cube('m', 1000, { quantity: 3, nesting_height: 10 })], + [box('b', 1000, 1000, 1000, { quantity: 1 })], { configuration: { objective: 'maximum_volume' } }); + assert.deepEqual(__requestBoundForTests(nested), [0, 0, 1, 0, 0]); + // Unlimited stock holds everything, so no volume is forced out. + const stocked = request([cube('m', 1000, { quantity: 3 })], [box('b', 1000, 1000, 1000)], + { configuration: { objective: 'maximum_volume' } }); + assert.deepEqual(__requestBoundForTests(stocked).slice(0, 2), [0, 0]); +}); + +test('the smallest values are selected from unsorted runs exactly as a sort would take them', () => { + // / F6: value_forgone selects rather than sorts, so it must agree with the sort. + const runs = [[5n, 2n], [1n, 3n], [9n, 1n], [1n, 0n], [3n, 2n], [5n, 1n], [0n, 1n]]; + const flat = runs.flatMap(([value, count]) => Array.from({ length: Number(count) }, () => value)) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + for (let count = 0; count <= flat.length + 1; count += 1) { + const expected = flat.slice(0, count).reduce((total, value) => total + value, 0n); + assert.equal(__runSmallestUnsortedForTests(runs, BigInt(count)), expected, `count ${count}`); + assert.equal(__runSmallestUnsortedForTests([...runs].reverse(), BigInt(count)), expected, + `reversed, count ${count}`); + } +}); + +test('a value forgone past the portable ceiling withholds only maximum_value', () => { + // Each value is exact; the two cheapest sum to 2^53, one past what JSON carries exactly. + const items = [Number.MAX_SAFE_INTEGER, 1, Number.MAX_SAFE_INTEGER] + .map((value, index) => cube(`m${index}`, 1000, { value })); + const crowded = (objective) => request(items, [box('b', 1000, 1000, 1000, { quantity: 1 })], + { configuration: { objective } }); + assert.equal(__requestBoundForTests(crowded('maximum_value')), null); + assert.deepEqual(__requestBoundForTests(crowded('maximum_volume')), [666667, 2, 1, 0, 0]); + assert.deepEqual(packFallback(crowded('maximum_value')).optimality.lower_bound, undefined); +}); + +test('an achieved height past the portable ceiling withholds only open_dimension_height', () => { + // A one-tick floor under a column of volume: the summed top passes 2^53 ticks while every + // one of the five bounds stays small. + const instances = [{ volume: 2n ** 53n, weight: 0n, shrinks: false, value: 0n }]; + const containers = [{ usable: 2n ** 53n, inner: 2n ** 53n, baseArea: 1n, height: 2n ** 53n, + payload: null, maxItems: null, quantity: 1n, costMinor: 0n }]; + assert.equal(__objectiveVectorForTests(instances, containers, 'open_dimension_height'), null); + assert.deepEqual(__objectiveVectorForTests(instances, containers, 'maximum_volume'), [0, 0, 1, 0, 0]); +}); + +test('a covered objective is proven optimal by attaining its own key', () => { + // Everything fits: maximum_value forgoes nothing and maximum_volume leaves no volume out, + // so each attains its bound on every key. + for (const objective of ['maximum_value', 'maximum_volume']) { + const proven = packFallback(request([cube('c', 100, { quantity: 2, value: 5 })], [box('b', 200, 100, 100)], + { configuration: { objective } })); + assert.equal(proven.status, 'optimal', objective); + assert.deepEqual(proven.optimality, { code: 'proven_optimal', lower_bound: proven.score }, objective); + } +}); + test('a sum past the declared ceiling is refused rather than answered', () => { //, at the same two inputs Python, PHP and Rust assert. The ceiling is declared // rather than inherited: this engine's `Number` stops being exact past 2^53, PHP's integers @@ -2309,6 +2382,24 @@ test('a hull is tested against an obstacle as a solid, not as its bounding box', assert.deepEqual(attempt(0, 0).unpacked_items.map(item => item.item_id), ['wedge#1']); }); +test('a chiral wedge pair fits one crate only under a signed rotation, and keep_upright narrows a list (1.10)', () => { + // Two half-cube wedges fill a cube when one is turned half a turn about z; no legacy code + // does that, since an odd legacy code mirrors x and the even ones lay the wedge down. + const wedge = [[0, 0, 0], [100, 0, 0], [0, 100, 0], [0, 0, 100], [100, 0, 100], [0, 100, 100]]; + const attempt = (rotations, extra = {}) => packSound(request([{ + ...cube('wedge', 100, { quantity: 2 }), shape_type: 'convex_hull', allowed_rotations: rotations, ...extra, + hull_vertices: wedge.map(([vx, vy, vz]) => ({ x: String(vx), y: String(vy), z: String(vz) })), + }], [box('crate', 100, 100, 100, { quantity: 1 })], { configuration: { time_limit_ms: 60_000 } })); + const legacy = ['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']; + assert.equal(attempt(legacy).unpacked_items.length, 1); + const signed = attempt([...legacy, '-L-W+H']); + assert.equal(signed.unpacked_items.length, 0); + assert.deepEqual(placements(signed).map(placement => placement.orientation).sort(), ['-L-W+H', 'LWH']); + // Upright keeps only codes whose z takes +H; -L-W+H is one, +L-W-H is not. + assert.equal(attempt(['+L-W-H', 'LWH', '-L-W+H'], { keep_upright: true }).unpacked_items.length, 0); + assert.equal(attempt(['+L-W-H', 'LWH'], { keep_upright: true }).unpacked_items.length, 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 @@ -3266,3 +3357,36 @@ test('plan-beam dominance keys and their tie-break cannot be fooled by a separat assert.ok(compareIdLists(['a'], ['a', 'b']) < 0); assert.equal(compareIdLists(['a', 'b'], ['a', 'b']), 0); }); + +test('verifyLoadingPrefixBusinessRules catches out-of-z-order floor box sliding under an elevated box', () => { + const container = { length: 10, width: 10, height: 20 }; + const fragileFloor = { x: 0, y: 0, z: 0, length: 10, width: 10, height: 5, weight: 100, max_top_load: 100 }; + const heavyUpper = { x: 0, y: 0, z: 5, length: 10, width: 10, height: 5, weight: 500 }; + assert.throws( + () => verifyLoadingPrefixBusinessRules([fragileFloor, heavyUpper], [1, 0], container), + (err) => err instanceof SequenceReplayError && err.index === 0 && err.step === 1 && err.reason === 'top_load_exceeded', + ); +}); + +test('multilayer quality beam search with diamond top_load and compressible items stays valid', () => { + const payload = request( + [ + { id: 'base', quantity: 2, dimensions: mm(50, 100, 50), weight: '5', max_top_load: '12' }, + { + id: 'foam', + quantity: 2, + dimensions: mm(50, 100, 50), + weight: '4', + shape_type: 'compressible', + compression_ratio: 0.1, + max_compression_pressure_kpa: 50, + }, + { id: 'cap', quantity: 2, dimensions: mm(100, 50, 50), weight: '3' }, + ], + [box('crate', 100, 100, 150, { quantity: 2 })], + { configuration: { solver_profile: 'quality' } }, + ); + const result = packFallback(payload); + assert.deepEqual(validate(payload, result), []); +}); + diff --git a/test/flat-bag.test.mjs b/test/flat-bag.test.mjs index e111aa4..9dfb6f6 100644 --- a/test/flat-bag.test.mjs +++ b/test/flat-bag.test.mjs @@ -41,7 +41,7 @@ test('a malformed bag is refused with its pointer, reason and text', () => { const cases = [ [{ deformable_boundary: 'flat_bag' }, '/deformable_boundary', 'wrong_type', 'must be an object'], [{ deformable_boundary: {} }, '/deformable_boundary/kind', 'missing_field', 'is required'], - [{ deformable_boundary: { kind: 'pouch' } }, '/deformable_boundary/kind', 'not_allowed', 'must be one of ["flat_bag"]'], + [{ deformable_boundary: { kind: 'pouch' } }, '/deformable_boundary/kind', 'not_allowed', 'must be one of ["flat_bag","gusseted_bag"]'], [{ deformable_boundary: { kind: 'flat_bag', seam: 1 } }, '/deformable_boundary/seam', 'not_allowed', 'is not a known field'], [{ ...BAG, made_to_measure: {} }, '/made_to_measure', 'not_allowed', 'cannot be combined with deformable_boundary'], [{ ...BAG, obstacles: [{ id: 'o', dimensions: { length: '1', width: '1', height: '1' } }] }, '/obstacles', 'not_allowed', diff --git a/test/legacy-smoke.mjs b/test/legacy-smoke.mjs index 6facc8c..035c8ce 100644 --- a/test/legacy-smoke.mjs +++ b/test/legacy-smoke.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; -import { backend, pack, version } from '../index.js'; +import { backend, pack, packNested, version } from '../index.js'; const result = pack({ items: [ @@ -21,4 +21,17 @@ assert.equal(result.summary.packed_item_count, 2); assert.equal(result.summary.unpacked_item_count, 0); assert.equal(result.containers.length, 1); +// The cheaper carton set is the only one the tower takes; a beam of two finds it. +const cube = { id: 'cube', quantity: 2, dimensions: { length: '100', width: '100', height: '100' } }; +const box = (id, length, height) => ({ id, inner_dimensions: { length, width: '100', height } }); +const nested = packNested({ + levelBeamWidth: 2, + levels: [ + { name: 'carton', request: { items: [cube], containers: [box('long', '200', '100'), box('single', '100', '100')] } }, + { name: 'pallet', request: { items: [], containers: [box('tower', '100', '200')] } }, + ], +}); +assert.deepEqual(nested.levels.map((level) => [level.name, level.result.complete]), [['carton', true], ['pallet', true]]); +assert.deepEqual(nested.levels[0].result.containers.map((container) => container.container_type), ['single', 'single']); + process.stdout.write('legacy Node smoke: PASS\n'); diff --git a/test/made-to-measure.test.mjs b/test/made-to-measure.test.mjs index b701935..9dc5df1 100644 --- a/test/made-to-measure.test.mjs +++ b/test/made-to-measure.test.mjs @@ -8,6 +8,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { InvalidRequestError, UnsupportedFeatureError, packFallback } from '../fallback.js'; +import { validateDocuments } from '../result-validation/index.js'; import { boxCost, fabricate, grid, icbrtCeil, perSquareMetre, solvingEnvelopes } from '../made-to-measure.js'; const MM = 16000; @@ -129,7 +130,6 @@ test('each refused combination is named', () => { const cases = { 'container.made_to_measure+axles': { axles: [{ position: '0' }, { position: '1510' }] }, 'container.made_to_measure+obstacles': { obstacles: [{ id: 'post', dimensions: mm(10, 10, 10) }] }, - 'container.made_to_measure+void_fill_reserve_ratio': { void_fill_reserve_ratio: 0.5 }, }; for (const [name, fields] of Object.entries(cases)) { assert.throws(() => packFallback(request(fields)), (error) => error instanceof UnsupportedFeatureError && error.fields.includes(name)); @@ -160,3 +160,36 @@ test('each request error names its rule', () => { assert.throws(() => packFallback(request({ outer_dimensions: mm(1600, 1600, 1600) })), (error) => error.field === '/containers/0/outer_dimensions' && error.detail === 'cannot be combined with made_to_measure'); }); + +test('a reserve the tight box would break is kept by cutting the rung itself', () => { + const data = request({ void_fill_reserve_ratio: 0.5 }, { solver_profile: 'balanced' }); + const result = packFallback(data); + const [container] = result.containers; + assert.equal(result.complete, true); + // Tight would be 210 mm a side: 8 cubes use 8e-3 m^3 of 9.261e-3, past half of it. + assert.deepEqual(['length', 'width', 'height'].map((side) => container.inner_dimensions[side].value), ['260', '260', '260']); + assert.ok(BigInt(container.used_volume_ticks3) <= BigInt(260 * MM) ** 3n - BigInt(container.void_fill_reserve_ticks3)); + assert.deepEqual(validateDocuments(data, result).issues, []); +}); + +test('the validator admits a larger box only as a reserve cut on the grid', () => { + const data = request({ void_fill_reserve_ratio: 0.5 }, { solver_profile: 'balanced' }); + const result = packFallback(data); + const codes = (edit, request = data) => { + const copy = JSON.parse(JSON.stringify(result)); + edit(copy.containers[0]); + return validateDocuments(request, copy).issues.map((found) => found.code); + }; + const side = (container, value) => { for (const axis of ['length', 'width', 'height']) container.inner_dimensions[axis] = { ticks: value * MM, value: String(value), unit: 'mm' }; }; + // Off the 25 mm grid. + assert.ok(codes((container) => side(container, 261)).includes('fabricated_dimensions_mismatch')); + // Past the machine maximum. + assert.ok(codes((container) => side(container, 1535)).includes('fabricated_dimensions_mismatch')); + // A load the tight box would hold within its reserve leaves no reason to cut larger. + assert.ok(codes((container) => { container.used_volume_ticks3 = '0'; }).includes('fabricated_dimensions_mismatch')); + // A reported volume that is not an integer is not admitted as one. + assert.ok(codes((container) => { container.inner_dimensions.length.ticks = 260 * MM + 0.5; }).includes('fabricated_dimensions_mismatch')); + // Without a reserve the tight box is the only box. + const plain = request({}, { solver_profile: 'balanced' }); + assert.ok(codes(() => {}, plain).includes('fabricated_dimensions_mismatch')); +}); diff --git a/test/native-binding.test.mjs b/test/native-binding.test.mjs index ab7454f..c67dd11 100644 --- a/test/native-binding.test.mjs +++ b/test/native-binding.test.mjs @@ -139,7 +139,7 @@ const REFUSED = { containers: [{ id: 'box', inner_dimensions: SIDE }], fixed_placements: [{ ...fixed('0'), item_type: 'crate' }] }, unsupported: { items: [{ id: 'cube', quantity: 1, dimensions: SIDE }], - containers: [{ id: 'box', inner_dimensions: SIDE, pallet_overhang_limit: '1' }] }, + containers: [{ id: 'box', inner_dimensions: SIDE, pallet_overhang_limit: { length: '1', width: '1' }, axles: [] }] }, }; const describe = (error) => ({ name: error.name, code: error.code, reason: error.reason, diff --git a/test/nested.test.mjs b/test/nested.test.mjs new file mode 100644 index 0000000..ac5d935 --- /dev/null +++ b/test/nested.test.mjs @@ -0,0 +1,300 @@ +/** + * Nested packing, items into cartons and cartons onto a pallet, with a beam over levels. + * + * Mirrors `packvium-python/tests/test_nested.py`, then holds the beam to the shared scene + * `conformance/scene/nested-beam.json`, Python's `NestedPacker` answer on requests every engine + * packs identically. + */ + +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { pack, packNested } from '../index.js'; +import { packNestedWith } from '../nested.js'; + +const MM = 16000; +const GRAM = 8000000; +const CONFIGURATION = { + solver_profile: 'balanced', time_limit_ms: 300000, + effort_budget: { max_candidates_evaluated: 1000000, max_placement_attempts: 1000000, max_search_nodes: 1000000 }, +}; + +const sides = (length, width, height) => ({ length: String(length), width: String(width), height: String(height) }); +const container = (id, length, width, height, extra = {}) => ({ + id, inner_dimensions: sides(length, width, height), max_payload: { value: '100', unit: 'kg' }, ...extra, +}); +const cubes = (quantity, extra = {}) => [{ id: 'cube', quantity, dimensions: sides(100, 100, 100), ...extra }]; +const level = (name, containers, items = []) => ({ + name, request: { units: { length: 'mm' }, items, containers, configuration: CONFIGURATION }, +}); +const cartonsThenPallet = (items, cartons, pallets) => [level('carton', cartons, items), level('pallet', pallets)]; +const deadEnd = () => cartonsThenPallet(cubes(2), + [container('long', 200, 100, 100), container('single', 100, 100, 100)], [container('tower', 100, 100, 200)]); +const eightCubes = (extra = {}) => cartonsThenPallet(cubes(8, extra), + [container('carton', 200, 200, 100)], [container('pallet', 200, 200, 200)]); + +/** Every request `packNestedWith` packs, in order, beside the answer. */ +function recorded(options) { + const requests = []; + const answer = packNestedWith((request) => { + requests.push(request); + return pack(request); + }, options); + return { answer, requests }; +} + +const shape = ({ levels }) => levels.map(({ result }) => [result.complete, result.containers.map((c) => c.id), result.score]); + +test('two levels are packed in order', () => { + const { levels } = packNested({ levels: eightCubes() }); + assert.deepEqual(levels.map(({ name }) => name), ['carton', 'pallet']); + assert.ok(levels.every(({ result }) => result.complete)); + assert.equal(levels[0].result.summary.container_count, 2); + assert.equal(levels[1].result.summary.container_count, 1); +}); + +test('the second level packs the first level containers by their ids', () => { + const { levels } = packNested({ levels: eightCubes() }); + const onPallet = new Set(levels[1].result.containers[0].placements.map((placement) => placement.item_type)); + assert.deepEqual(onPallet, new Set(['carton#1', 'carton#2'])); +}); + +test('each packed container is handed up as one upright item of its gross weight', () => { + const levels = cartonsThenPallet(cubes(8, { weight: { value: '1', unit: 'kg' } }), + [container('carton', 200, 200, 100, { tare_weight: { value: '500', unit: 'g' } })], + [container('pallet', 200, 200, 200, { max_payload: { value: '10', unit: 'kg' } })]); + const { answer, requests } = recorded({ levels }); + assert.ok(answer.levels.every(({ result }) => result.complete)); + assert.deepEqual(requests[1].items[0], { + id: 'carton#1', quantity: 1, + dimensions: { length: { value: '200', unit: 'mm' }, width: { value: '200', unit: 'mm' }, height: { value: '100', unit: 'mm' } }, + weight: { value: '4500', unit: 'g' }, + keep_upright: true, + metadata: { source_packed_container: 'carton#1' }, + }); + assert.equal(answer.levels[1].result.containers[0].payload_weight.ticks, 9000 * GRAM); +}); + +test('outer dimensions are what the next level sees', () => { + const levels = cartonsThenPallet(cubes(2), + [container('carton', 100, 100, 100, { outer_dimensions: sides(110, 110, 110) })], [container('pallet', 220, 110, 110)]); + const { answer, requests } = recorded({ levels }); + assert.deepEqual(requests[1].items[0].dimensions.length, { value: '110', unit: 'mm' }); + assert.ok(answer.levels[1].result.complete); +}); + +test('a flat bag is handed up at its achieved package, not its declared size', () => { + const levels = cartonsThenPallet(cubes(1), + [container('mailer', 300, 300, 300, { deformable_boundary: { kind: 'flat_bag' } })], [container('pallet', 400, 400, 400)]); + const { answer, requests } = recorded({ levels }); + const bag = answer.levels[0].result.containers[0]; + const handed = requests[1].items[0].dimensions; + for (const side of ['length', 'width', 'height']) { + assert.equal(Number(handed[side].value) * MM, bag.achieved_dimensions[side].ticks, side); + } + assert.notDeepEqual(bag.achieved_dimensions, bag.outer_dimensions); +}); + +test('a measure that is not whole millimetres or grams is carried to the exact tick', () => { + const levels = cartonsThenPallet(cubes(1, { weight: { value: '1', unit: 'oz' } }), + [container('carton', 4, 4, 4, { outer_dimensions: { length: '4.1', width: '4.1', height: '4.1' } })], + [container('pallet', 10, 10, 10)]); + levels[0].request.units.length = 'in'; + levels[0].request.items[0].dimensions = sides(3, 3, 3); + levels[1].request.units.length = 'in'; + const { answer, requests } = recorded({ levels }); + const carton = answer.levels[0].result.containers[0]; + const placed = answer.levels[1].result.containers[0].placements[0]; + assert.equal(requests[1].items[0].dimensions.length.value, '104.14'); + assert.equal(requests[1].items[0].weight.value, '28.349523125'); + assert.equal(placed.dimensions.length.ticks, carton.outer_dimensions.length.ticks); + assert.equal(answer.levels[1].result.containers[0].payload_weight.ticks, carton.gross_weight.ticks); +}); + +test('packing stops at the first level that could not finish', () => { + const levels = cartonsThenPallet(cubes(4, { dimensions: sides(90, 90, 90) }), + [container('carton', 100, 100, 100, { quantity: 1 })], [container('pallet', 1000, 1000, 1000)]); + const { levels: packed } = packNested({ levels }); + assert.equal(packed.length, 1); + assert.equal(packed[0].result.complete, false); +}); + +test('an incomplete level is followed by a next level that lists its own items', () => { + const levels = cartonsThenPallet(cubes(4, { dimensions: sides(90, 90, 90) }), + [container('carton', 100, 100, 100, { quantity: 1 })], [container('pallet', 1000, 1000, 1000)]); + levels[1].request.items = cubes(1); + const { levels: packed } = packNested({ levels, levelBeamWidth: 2 }); + assert.deepEqual(packed.map(({ result }) => result.complete), [false, true]); +}); + +test('a level may carry its own configuration', () => { + const levels = eightCubes(); + levels[0].request.configuration = { solver_profile: 'fast', seed: 5 }; + levels[1].request.configuration = { solver_profile: 'quality', time_limit_ms: 1000, seed: 9 }; + const { levels: packed } = packNested({ levels }); + assert.deepEqual(packed.map(({ result }) => result.algorithm.seed), [5, 9]); + assert.deepEqual(packed.map(({ result }) => result.algorithm.profile), ['fast', 'quality']); +}); + +test('a single level behaves like an ordinary pack', () => { + const only = level('carton', [container('carton', 200, 200, 100)], cubes(4)); + const { levels } = packNested({ levels: [only] }); + assert.equal(levels.length, 1); + assert.deepEqual(levels[0].result.containers, pack(only.request).containers); +}); + +test('a level beam width of one is the greedy chain', () => { + assert.deepEqual(shape(packNested({ levels: deadEnd(), levelBeamWidth: 1 })), shape(packNested({ levels: deadEnd() }))); +}); + +test('a wider beam finds the carton set that fits the pallet', () => { + const greedy = packNested({ levels: deadEnd(), levelBeamWidth: 1 }); + const beam = packNested({ levels: deadEnd(), levelBeamWidth: 2 }); + assert.deepEqual(greedy.levels.map(({ result }) => result.complete), [true, false]); + assert.deepEqual(beam.levels.map(({ result }) => result.complete), [true, true]); + assert.deepEqual(beam.levels[0].result.containers.map((c) => c.container_type), ['single', 'single']); +}); + +test('a width above one also offers each container alone', () => { + const { requests } = recorded({ levels: deadEnd(), levelBeamWidth: 2 }); + assert.deepEqual(requests.slice(0, 3).map((request) => request.containers.map((c) => c.id)), + [['long', 'single'], ['long'], ['single']]); +}); + +test('single-container offers keep every fixed placement in its declared container', () => { + const only = level('carton', [container('locked', 100, 100, 100), container('other', 100, 100, 100)], cubes(1)); + only.request.fixed_placements = [{ + item_type: 'cube', container_type: 'locked', container_instance: 1, + position: { x: '0', y: '0', z: '0' }, orientation: 'LWH', + }]; + const { answer, requests } = recorded({ levels: [only], levelBeamWidth: 2 }); + assert.deepEqual(requests.map((request) => request.containers.map((c) => c.id)), + [['locked', 'other'], ['locked']]); + assert.equal(answer.levels[0].result.complete, true); + assert.equal(answer.levels[0].result.containers[0].id, 'locked#1'); + assert.equal(answer.levels[0].result.containers[0].placements[0].item_type, 'cube'); +}); + +test('fixed placements in several types leave the whole-list offer available', () => { + const only = level('carton', [container('first', 100, 100, 100), container('second', 100, 100, 100)], cubes(2)); + only.request.fixed_placements = ['first', 'second'].map((container_type) => ({ + item_type: 'cube', container_type, position: { x: '0', y: '0', z: '0' }, orientation: 'LWH', + })); + const { answer, requests } = recorded({ levels: [only], levelBeamWidth: 3 }); + assert.equal(requests.length, 1); + assert.equal(answer.levels[0].result.complete, true); + assert.deepEqual(answer.levels[0].result.containers.map((c) => c.id), ['first#1', 'second#1']); +}); + +test('a malformed fixed placement is still refused by the whole-list offer', () => { + const only = level('carton', [container('carton', 100, 100, 100), container('other', 100, 100, 100)], cubes(1)); + only.request.fixed_placements = [{ item_type: 'cube', container_type: 'missing', orientation: 'LWH' }]; + assert.throws(() => packNested({ levels: [only], levelBeamWidth: 2 }), /unknown container type "missing"/); +}); + +test('upper-level locks discard an incompatible handoff while retaining its compatible sibling', () => { + const levels = cartonsThenPallet(cubes(2), + [container('long', 200, 100, 100), container('single', 100, 100, 100)], + [container('pallet', 200, 100, 100)]); + levels[1].request.fixed_placements = [{ item_type: 'single#1', container_type: 'pallet', orientation: 'LWH' }]; + const { levels: packed } = packNested({ levels, levelBeamWidth: 2 }); + assert.deepEqual(packed.map(({ result }) => result.complete), [true, true]); + assert.deepEqual(packed[0].result.containers.map((c) => c.id), ['single#1', 'single#2']); + assert.deepEqual(packed[1].result.containers[0].placements.map((p) => p.item_type), ['single#1', 'single#2']); + assert.throws(() => packNested({ levels, levelBeamWidth: 1 }), /unknown item type "single#1"/); +}); + +test('upper-level locks absent from every handoff retain the original request failure', () => { + const levels = deadEnd(); + levels[1].request.fixed_placements = [{ item_type: 'missing#1', container_type: 'tower', orientation: 'LWH' }]; + assert.throws(() => packNested({ levels, levelBeamWidth: 2 }), /unknown item type "missing#1"/); +}); + +test('a malformed upper-level lock still fails when another handoff contains its item', () => { + const levels = deadEnd(); + levels[1].request.fixed_placements = [{ item_type: 'single#1', container_type: 'tower', orientation: 'invalid' }]; + assert.throws(() => packNested({ levels, levelBeamWidth: 2 }), /orientation must be one of/); +}); + +test('handoff filtering leaves malformed item types to ordinary request validation', () => { + const levels = deadEnd(); + levels[1].request.fixed_placements = [{ item_type: 7, container_type: 'tower', orientation: 'LWH' }]; + assert.throws(() => packNested({ levels, levelBeamWidth: 2 }), /item_type is a non-empty string/); +}); + +test('a handoff cannot satisfy two locks on its single item instance', () => { + const levels = deadEnd(); + const fixed = { item_type: 'single#1', container_type: 'tower', orientation: 'LWH' }; + levels[1].request.fixed_placements = [fixed, fixed]; + // The greedy handoff has no single#1 at all; retain its original failure when no sibling + // can provide both locked instances, rather than throwing from an arbitrary later branch. + assert.throws(() => packNested({ levels, levelBeamWidth: 2 }), /unknown item type "single#1"/); +}); + +test('handoffs that are the same items occupy one beam slot', () => { + // Packing against every carton and against `long` alone both hand up one `long#1`; counting + // that twice would fill a width of two and drop the chain that fits. + const { levels } = packNested({ levels: deadEnd(), levelBeamWidth: 2 }); + assert.ok(levels.every(({ result }) => result.complete)); +}); + +test('no levels is an empty answer at any width', () => { + assert.deepEqual(packNested({ levels: [], levelBeamWidth: 3 }), { levels: [] }); +}); + +for (const width of [0, -1, true, 1.5, '2', 2 ** 53, Number.NaN]) { + test(`a level beam width of ${String(width)} is refused`, () => { + assert.throws(() => packNested({ levels: [], levelBeamWidth: width }), + { name: 'RangeError', message: 'level_beam_width must be a positive integer' }); + }); +} + +const SCENE = new URL('../../../../conformance/scene/nested-beam.json', import.meta.url); +const document = existsSync(SCENE) ? JSON.parse(readFileSync(SCENE, 'utf8')) : null; +const cases = document === null ? null : [...document.cases, ...document.fixed_cases]; + +/** A handed-up item is named after its packed container, `quad#2`; only the type is contract. */ +const kind = (itemType) => itemType.split('#')[0]; +const byJson = (left, right) => { + const a = JSON.stringify(left); + const b = JSON.stringify(right); + return a < b ? -1 : a > b ? 1 : 0; +}; + +/** The generator's `projection()`: the contract's multiset view, instance numbers dropped. */ +function projection({ name, result }) { + return { + name, + complete: result.complete, + score: result.score, + containers: result.containers.map((c) => ({ + container_type: c.container_type, + placements: c.placements.map((p) => [kind(p.item_type), ...['x', 'y', 'z'].map((axis) => p.position[axis].ticks), p.orientation]), + })), + unpacked: result.unpacked_items.map((unpacked) => kind(unpacked.item_type)), + }; +} + +/** One order for both sides, so the comparison is of multisets and exactly as strict. */ +function normalized(level) { + const containers = level.containers + .map((c) => ({ container_type: c.container_type, placements: [...c.placements].sort(byJson) })) + .sort(byJson); + return { ...level, containers, unpacked: [...level.unpacked].sort() }; +} + +test('the shared nested-beam scene is present and non-empty', (t) => { + if (cases === null) { + t.skip('the shared cross-language scene corpus is not part of this package'); + return; + } + assert.ok(cases.length > 0, 'an empty scene would let every case below pass without asserting anything'); +}); + +for (const scene of cases ?? []) { + test(`nested-beam scene: ${scene.name}`, () => { + const { levels } = packNested({ levels: scene.levels, levelBeamWidth: scene.level_beam_width }); + assert.deepEqual(levels.map(projection).map(normalized), scene.expected.map(normalized)); + }); +} diff --git a/test/package-exports.test.mjs b/test/package-exports.test.mjs index 2be86a0..1f09217 100644 --- a/test/package-exports.test.mjs +++ b/test/package-exports.test.mjs @@ -22,7 +22,7 @@ const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.jso test('the main entry resolves by package name and carries the packing surface', async () => { const main = await import(manifest.name); - for (const name of ['pack', 'packJson', 'rebalanceWeight', 'commerce', 'backend']) { + for (const name of ['pack', 'packJson', 'packNested', 'rebalanceWeight', 'commerce', 'backend']) { assert.equal(typeof main[name], name === 'commerce' ? 'object' : 'function', name); } }); @@ -50,7 +50,7 @@ test('the operational artifact and its exports resolve by package name', async ( test('plan revisions resolve by package name', async () => { const revisions = await import(`${manifest.name}/revisions.js`); - for (const name of ['rootRevision', 'deriveRevision', 'applyEvents', 'verifyRevisionChain', + for (const name of ['rootRevision', 'deriveRevision', 'applyEvents', 'stockCutBoxes', 'verifyRevisionChain', 'documentDigest', 'canonicalRevisionJson', 'PlanRevisionError']) { assert.equal(typeof revisions[name], 'function', name); } @@ -69,8 +69,9 @@ test('a module an export imports is published even when it is not exported itsel // `canonical-json.js` is internal: absent from `exports` on purpose, but `artifacts.js` and // `execution.js` import it, so a tarball without it would break both at load time. const published = new Set(manifest.files); - // `revisions.js` imports `commerce-model.js` and `artifacts.js` the same way. - for (const name of ['canonical-json.js', 'commerce-model.js', 'artifacts.js', 'artifact-exports.js', 'revisions.js', + // `revisions.js` imports `commerce-model.js` and `artifacts.js` the same way, and `index.js` + // imports `nested.js`; `fallback.js` imports `pallet-overhang.js`. + for (const name of ['canonical-json.js', 'nested.js', 'pallet-overhang.js', 'commerce-model.js', 'artifacts.js', 'artifact-exports.js', 'revisions.js', 'artifacts.d.ts', 'artifact-exports.d.ts', 'revisions.d.ts']) { assert.ok(published.has(name), `"files" does not publish ${name}`); } diff --git a/test/pallet-overhang.test.mjs b/test/pallet-overhang.test.mjs new file mode 100644 index 0000000..63117df --- /dev/null +++ b/test/pallet-overhang.test.mjs @@ -0,0 +1,195 @@ +/** + * Pallet overhang: the solving frame, deck-only floor support, the deck-first trial, + * achieved and billed sizes, the refusals and the validator's rules. Mirrors the five + * pallet-overhang conformance fixtures and the Python reference. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { FixedPlacementError, InvalidRequestError, UnsupportedFeatureError, packFallback } from '../fallback.js'; +import { packNestedWith } from '../nested.js'; +import { centringOffset } from '../pallet-overhang.js'; +import { validateDocuments } from '../result-validation/index.js'; + +const MM = 16000; +const mm = (length, width, height) => ({ length: String(length), width: String(width), height: String(height) }); +const crate = { id: 'crate', quantity: 1, weight: '10 kg', dimensions: mm(1250, 850, 400), allowed_rotations: ['LWH'] }; +const cartons = (quantity) => ({ id: 'carton', quantity, weight: '10 kg', dimensions: mm(400, 400, 300) }); + +function request(items, container = {}, configuration = {}) { + return { + units: { length: 'mm' }, + configuration: { solver_profile: 'balanced', time_limit_ms: 300000, + effort_budget: { max_search_nodes: 1000000 }, ...configuration }, + items, + containers: [{ id: 'pallet', inner_dimensions: mm(1200, 800, 1000), max_payload: '1000 kg', + pallet_overhang_limit: { length: '50', width: '50' }, ...container }], + }; +} + +const ticksOf = (block) => [block.length.ticks, block.width.ticks, block.height.ticks]; +const at = (placement) => [placement.position.x.ticks / MM, placement.position.y.ticks / MM, placement.position.z.ticks / MM]; +const refusal = (req) => { try { packFallback(req); } catch (error) { return error; } return null; }; +const issueCodes = (req, result) => validateDocuments(req, result).issues.map((found) => found.code); + +test('a load the deck cannot hold overhangs it, seated with the least overhang, on the deck alone', () => { + const req = request([crate]); + const result = packFallback(req); + const [container] = result.containers; + assert.equal(result.complete, true); + assert.deepEqual(ticksOf(container.inner_dimensions), [1200 * MM, 800 * MM, 1000 * MM]); + assert.deepEqual(ticksOf(container.outer_dimensions), [1200 * MM, 800 * MM, 1000 * MM]); + assert.deepEqual(ticksOf(container.achieved_dimensions), [1250 * MM, 850 * MM, 400 * MM]); + // Centred on the deck: 25 mm past each edge of the 50 mm allowance. + assert.deepEqual(at(container.placements[0]), [25, 25, 0]); + // The deck bears 1200 x 800 of the 1250 x 850 base. + assert.equal(container.placements[0].support_ratio, '0.903529'); + assert.deepEqual(validateDocuments(req, result).issues, []); +}); + +test('a load the deck holds stays on the deck: the frame is tried only when it places more', () => { + const req = request([cartons(6)]); + const [container] = packFallback(req).containers; + assert.equal(container.placements.length, 6); + for (const placement of container.placements) { + const [x, y] = at(placement); + assert.ok(x >= 50 && x + 400 <= 1250 && y >= 50 && y + 400 <= 850, JSON.stringify(at(placement))); + assert.equal(placement.support_ratio, '1.000000'); + } + assert.deepEqual(ticksOf(container.achieved_dimensions), [1200 * MM, 800 * MM, 300 * MM]); + assert.equal(container.centre_of_mass_offset_ppm, 0); +}); + +test('a zero limit forbids overhang and still reports the achieved size', () => { + const req = request([crate, { id: 'carton', quantity: 2, weight: '5 kg', dimensions: mm(400, 300, 200) }], + { pallet_overhang_limit: { length: '0', width: '0' } }); + const result = packFallback(req); + assert.deepEqual(result.unpacked_items.map((item) => item.reason), ['no_compatible_container_dimensions']); + assert.deepEqual(ticksOf(result.containers[0].achieved_dimensions), [1200 * MM, 800 * MM, 200 * MM]); +}); + +test('only the deck bears weight: a crate the deck carries below the minimum ratio is left out', () => { + const req = request([crate, { id: 'carton', quantity: 2, weight: '5 kg', dimensions: mm(400, 300, 200) }], {}, + { minimum_support_ratio: 0.95 }); + const result = packFallback(req); + assert.deepEqual(result.unpacked_items.map((item) => [item.item_type, item.reason]), [['crate', 'insufficient_support']]); + assert.deepEqual(validateDocuments(req, result).issues, []); +}); + +test('a fixed placement off the deck is refused by the deck support rule', () => { + const fixed = (ratio) => ({ + ...request([{ id: 'box', quantity: 1, weight: '1 kg', dimensions: mm(100, 100, 100) }], + { pallet_overhang_limit: { length: '60', width: '0' } }, { minimum_support_ratio: ratio }), + fixed_placements: [{ item_type: 'box', container_type: 'pallet', orientation: 'LWH', position: { x: '0', y: '0', z: '0' } }], + }); + // 40 of the 100 mm rest on the deck, which starts 60 mm in. + const short = refusal(fixed(0.5)); + assert.ok(short instanceof FixedPlacementError); + assert.match(short.message, /^invalid_fixed_placement: insufficient_support: box#1: /); + const tipped = refusal(fixed(0.3)); + assert.ok(tipped instanceof FixedPlacementError); + assert.match(tipped.message, /^invalid_fixed_placement: centre_of_gravity_unsupported: box#1: .* centroid outside/); + const placed = packFallback(fixed(0)); + assert.equal(placed.containers[0].placements[0].support_ratio, '0.400000'); +}); + +test('billed size is the declared footprint grown by the overhang used, at the declared height', () => { + const shipping = { objective: 'shipping_cost', dimensional_weight_divisor: 5000, + dimensional_weight_length_unit: 'cm', dimensional_weight_weight_unit: 'kg' }; + const kg = 8000000000; + // 125 x 85 x 115 cm / 5000 = 244.375 kg, over the 1210 x 810 outer size. + const outerRequest = request([crate], { outer_dimensions: mm(1210, 810, 1150) }, shipping); + const withOuter = packFallback(outerRequest); + assert.equal(withOuter.score[1], 244.375 * kg); + assert.deepEqual(validateDocuments(outerRequest, withOuter).issues, []); + assert.deepEqual(ticksOf(withOuter.containers[0].outer_dimensions), [1210 * MM, 810 * MM, 1150 * MM]); + // Without an outer size the base is the declared inner one: 125 x 85 x 100 cm. + const innerRequest = request([crate], {}, shipping); + const withoutOuter = packFallback(innerRequest); + assert.equal(withoutOuter.score[1], 212.5 * kg); + assert.deepEqual(validateDocuments(innerRequest, withoutOuter).issues, []); +}); + +test('a nested level hands up a pallet at its billed size', () => { + const pack = (req) => packFallback(req); + const outcome = packNestedWith(pack, { levels: [ + { name: 'pallet', request: request([crate], { outer_dimensions: mm(1210, 810, 1150) }) }, + { name: 'truck', request: { ...request([], { id: 'truck', inner_dimensions: mm(5000, 2400, 2400) }), + containers: [{ id: 'truck', inner_dimensions: mm(5000, 2400, 2400) }] } }, + ] }); + const [handed] = outcome.levels[1].result.containers[0].placements; + assert.deepEqual(ticksOf(handed.dimensions), [1250 * MM, 850 * MM, 1150 * MM]); +}); + +test('the seat is flush with the deck when the load fits it, else centred with the odd tick past the far edge', () => { + const deck = { x: 50, y: 50, length: 1200, width: 800 }; + assert.deepEqual(centringOffset(deck, []), [0, 0]); + assert.deepEqual(centringOffset(deck, [{ x: 0, y: 0, ed: [400, 400, 1] }]), [50, 50]); + assert.deepEqual(centringOffset(deck, [{ x: 0, y: 0, ed: [1251, 801, 1] }]), [25, 50]); +}); + +test('each combination with no defined meaning is refused by name, sorted', () => { + const error = refusal({ + ...request([{ ...crate, stop_index: 1 }], { access_directions: ['+x'], axles: [{ position: '0' }, { position: '100' }], + made_to_measure: { minimum: mm(1, 1, 1), increment: mm(1, 1, 1) }, obstacles: [{ dimensions: mm(1, 1, 1) }], + void_fill_reserve_ratio: 0.1 }), + }); + assert.ok(error instanceof UnsupportedFeatureError); + // The made-to-measure container's own combinations are named beside these. + assert.deepEqual(error.fields.filter((name) => name.startsWith('container.pallet')), ['container.pallet_overhang_limit+access_directions', 'container.pallet_overhang_limit+axles', + 'container.pallet_overhang_limit+item.stop_index', 'container.pallet_overhang_limit+made_to_measure', + 'container.pallet_overhang_limit+obstacles', 'container.pallet_overhang_limit+void_fill_reserve_ratio']); + // An empty list, a zero reserve and a null stop change nothing. + const quiet = packFallback(request([{ ...crate, stop_index: null }], { access_directions: [], obstacles: [], void_fill_reserve_ratio: 0 })); + assert.equal(quiet.complete, true); +}); + +test('a malformed limit is a request error naming the field', () => { + const cases = [ + [{ pallet_overhang_limit: '50' }, 'wrong_type', '/containers/0/pallet_overhang_limit', 'must be an object'], + [{ pallet_overhang_limit: { length: '1', width: '1', depth: '1', axis: '1' } }, 'not_allowed', + '/containers/0/pallet_overhang_limit/axis', 'is not a known field'], + [{ pallet_overhang_limit: { width: '1' } }, 'missing_field', '/containers/0/pallet_overhang_limit/length', 'is required'], + [{ pallet_overhang_limit: { length: '1' } }, 'missing_field', '/containers/0/pallet_overhang_limit/width', 'is required'], + [{ pallet_overhang_limit: { length: '-1', width: '1' } }, 'negative_measure', '/containers/0/pallet_overhang_limit/length', 'cannot be negative'], + [{ pallet_overhang_limit: { length: '1', width: { value: '1', unit: 'furlong' } } }, 'invalid_unit', + '/containers/0/pallet_overhang_limit/width', 'has an unknown unit "furlong"'], + [{ inner_dimensions: { length: '9007199254740991 ticks', width: '1 ticks', height: '1 ticks' }, + pallet_overhang_limit: { length: '1 ticks', width: '0' } }, 'above_maximum', + '/containers/0/pallet_overhang_limit/length', 'must be at most 9007199254740991 ticks'], + [{ inner_dimensions: { length: '10000000000 ticks', width: '10000000000 ticks', height: '10000000000 ticks' }, + pallet_overhang_limit: { length: '0', width: '1 ticks' } }, 'above_maximum', + '/containers/0/pallet_overhang_limit', 'must enclose at most 10^30 cubic ticks'], + [{ deformable_boundary: { kind: 'flat_bag' } }, 'not_allowed', '/containers/0/pallet_overhang_limit', + 'cannot be combined with deformable_boundary'], + ]; + for (const [fields, reason, field, detail] of cases) { + const error = refusal(request([crate], fields)); + assert.ok(error instanceof InvalidRequestError, `${field}: ${error}`); + assert.deepEqual([error.reason, error.field, error.detail ?? error.message.split(': ').slice(2).join(': ')], [reason, field, detail]); + } +}); + +test('the validator holds a pallet to its deck and its achieved size', () => { + const req = request([{ id: 'box', quantity: 1, weight: '1 kg', dimensions: mm(100, 100, 100) }], + { pallet_overhang_limit: { length: '60', width: '0' } }, { minimum_support_ratio: 0.3 }); + const result = packFallback(req); + assert.deepEqual(validateDocuments(req, result).issues, []); + const moved = (x, achieved) => { + const copy = JSON.parse(JSON.stringify(result)); + const [container] = copy.containers; + container.placements[0].position.x = { ticks: x * MM, value: String(x), unit: 'mm' }; + container.placements[0].support_ratio = '1.000000'; + if (achieved === undefined) delete container.achieved_dimensions; + else container.achieved_dimensions.length = { ticks: achieved * MM, value: String(achieved), unit: 'mm' }; + return copy; + }; + // 40 mm of 100 on the deck clears 0.3 but leaves the centroid off it; 10 mm clears nothing. + assert.ok(issueCodes(req, moved(0, 1260)).includes('centre_of_gravity_unsupported')); + assert.ok(issueCodes(req, moved(50, 1200)).every((code) => code !== 'centre_of_gravity_unsupported')); + const strict = { ...req, configuration: { ...req.configuration, minimum_support_ratio: 0.5 } }; + assert.ok(issueCodes(strict, moved(0, 1260)).includes('insufficient_support')); + assert.ok(issueCodes(req, moved(0)).includes('achieved_dimensions_mismatch')); + assert.ok(issueCodes(req, moved(0, 1200)).includes('achieved_dimensions_mismatch')); +}); diff --git a/test/placement-id.test.mjs b/test/placement-id.test.mjs new file mode 100644 index 0000000..938c3e6 --- /dev/null +++ b/test/placement-id.test.mjs @@ -0,0 +1,231 @@ +/** + * `placement_id` (1.10): the canonical JSON of `[container id, x, y, z, item type, orientation]`, + * a pure function of a placement. Emitted on every result placement, recomputed by the + * validator, restated optionally by a fixed placement and copied into the execution plan. + * + * Mirrors `packvium-python/tests/test_placement_id.py`; the refusal and issue texts are asserted + * whole because they are compared across engines. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildOperationalArtifact } from '@packvium/engine/artifacts.js'; +import { ExecutionPlanError, buildExecutionPlan, placementReference } from '@packvium/engine/execution.js'; +import { PlanRevisionError, deriveRevision, rootRevision } from '@packvium/engine/revisions.js'; +import { canonicalIdentifier } from '../canonical-json.js'; +import { FixedPlacementError, packFallback, rebalanceWeight } from '../fallback.js'; +import { validateDocuments } from '../result-validation/index.js'; +import { validate as oracle } from './validate.mjs'; + +const MM = 16000; +const dims = (length, width, height) => ({ length: String(length), width: String(width), height: String(height) }); + +function request(containerId = 'box', quantity = 4, extra = {}) { + return { + configuration: { solver_profile: 'balanced', alternatives: 3 }, + items: [{ id: 'cube', quantity, dimensions: dims(100, 100, 100) }], + containers: [{ id: containerId, quantity: 3, inner_dimensions: dims(200, 100, 100) }], + ...extra, + }; +} + +const ident = (containerId, x, y, z, itemType = 'cube', orientation = 'LWH') => + JSON.stringify([containerId, x, y, z, itemType, orientation]); +const expectedId = (container, placement) => ident(container.id, + ...['x', 'y', 'z'].map((axis) => placement.position[axis].ticks), placement.item_type, placement.orientation); +const pack = (body) => packFallback(body); +const issuesOf = (body, result) => validateDocuments(body, result).issues; + +function* everyPlacement(result) { + for (const document of [result, ...(result.alternatives ?? [])]) { + for (const container of document.containers) { + for (const placement of container.placements) yield [container, placement]; + } + } +} + +for (const containerId of ['box', 'a@b#c', 'böx-箱', 'q"uo\\te\n
']) { + test(`every placement of the answer and its alternatives carries its id: ${JSON.stringify(containerId)}`, () => { + const body = request(containerId); + const result = pack(body); + const placements = [...everyPlacement(result)]; + assert.ok(placements.length >= 4); + for (const [container, placement] of placements) assert.equal(placement.placement_id, expectedId(container, placement)); + assert.deepEqual(issuesOf(body, result), []); + assert.deepEqual(oracle(body, result), []); + }); +} + +test('a tampered id is a mismatch naming both', () => { + const result = pack(request()); + const placement = result.containers[0].placements[0]; + const expected = expectedId(result.containers[0], placement); + placement.placement_id = ident('box#1', 1, 2, 3); + assert.deepEqual(issuesOf(request(), result), [{ + code: 'placement_id_mismatch', + detail: `${placement.item_id} in box#1: reported ${ident('box#1', 1, 2, 3)}, expected ${expected}`, + }]); + assert.deepEqual(oracle(request(), result), ['placement_id_mismatch']); +}); + +test('a missing or non-string id is a mismatch', () => { + for (const tamper of [(placement) => { delete placement.placement_id; }, (placement) => { placement.placement_id = 7; }]) { + const result = pack(request()); + const placement = result.containers[0].placements[0]; + tamper(placement); + const issues = issuesOf(request(), result); + assert.deepEqual(issues.map((found) => found.code), ['placement_id_mismatch']); + assert.equal(issues[0].detail, `${placement.item_id} in box#1: no placement_id, expected ${expectedId(result.containers[0], placement)}`); + assert.deepEqual(oracle(request(), result), ['placement_id_mismatch']); + } +}); + +test('a repeated id is a duplicate, after its mismatch', () => { + const result = pack(request()); + const [first, second] = result.containers[0].placements; + second.placement_id = first.placement_id; + assert.deepEqual(issuesOf(request(), result), [ + { code: 'placement_id_mismatch', detail: `${second.item_id} in box#1: reported ${first.placement_id}, expected ${expectedId(result.containers[0], second)}` }, + { code: 'duplicate_placement_id', detail: first.placement_id }, + ]); + assert.deepEqual(oracle(request(), result), ['placement_id_mismatch', 'duplicate_placement_id']); +}); + +test('a lone surrogate in an id is written as its lowercase escape', () => { + assert.equal(canonicalIdentifier(['b\uDC00x', 1, 'c\uD83D']), '["b\\udc00x",1,"c\\ud83d"]'); + assert.equal(canonicalIdentifier(['\u{1F600}\u0001\u007f']), '["\u{1F600}\\u0001\u007f"]'); + const body = request('b\uDC00x'); + const result = pack(body); + assert.ok(result.containers[0].placements[0].placement_id.startsWith('["b\\udc00x#1",')); + assert.deepEqual(issuesOf(body, result), []); + assert.deepEqual(oracle(body, result), []); +}); + +test('a lattice summary has no placements and so no ids', () => { + const body = request('box', 2); + body.configuration = { solver_profile: 'fast', require_placement_coordinates: false }; + const result = pack(body); + assert.ok(result.containers.every((container) => container.placements.length === 0)); + assert.deepEqual(issuesOf(body, result), []); +}); + +test('a lattice answer with coordinates names each placement', () => { + const body = request('box', 2); + body.configuration = { solver_profile: 'fast' }; + const result = pack(body); + for (const [container, placement] of everyPlacement(result)) assert.equal(placement.placement_id, expectedId(container, placement)); + assert.deepEqual(issuesOf(body, result), []); +}); + +test('a rebalanced placement is named where it now sits', () => { + const body = { + items: [{ id: 'heavy', quantity: 2, weight: '5000', dimensions: dims(100, 100, 100) }], + containers: [{ id: 'box', quantity: 2, inner_dimensions: dims(200, 100, 100) }], + }; + const original = pack(body); + const rebalanced = rebalanceWeight(body, original); + for (const container of rebalanced.containers) { + for (const placement of container.placements) assert.equal(placement.placement_id, expectedId(container, placement)); + } +}); + +// ------------------------------------------------------------------ fixed placements + +const fixedRequest = (placement) => request('box', 4, { + fixed_placements: [{ item_type: 'cube', container_type: 'box', position: { x: '100' }, orientation: 'LWH', ...placement }], +}); + +function refusal(body) { + try { + pack(body); + } catch (error) { + assert.ok(error instanceof FixedPlacementError); + return error; + } + return assert.fail('the request was answered'); +} + +test('a fixed placement may quote the id its result placement carries, and null is absent', () => { + const result = pack(fixedRequest({ placement_id: ident('box#1', 100 * MM, 0, 0) })); + const fixed = result.containers[0].placements.filter((placement) => placement.fixed); + assert.deepEqual(fixed.map((placement) => placement.placement_id), [ident('box#1', 100 * MM, 0, 0)]); + assert.equal(pack(fixedRequest({ placement_id: null })).status, 'feasible'); +}); + +test('a fixed id naming another place is refused', () => { + const error = refusal(fixedRequest({ placement_id: ident('box#1', 0, 0, 0) })); + assert.deepEqual([error.code, error.reason, error.field], ['invalid_fixed_placement', 'invalid_value', '/fixed_placements/0/placement_id']); + assert.equal(error.message, + `invalid_fixed_placement: fixed_placements[0].placement_id must be ${JSON.stringify(ident('box#1', 100 * MM, 0, 0))}`); +}); + +test('a fixed id counts the instance and defaults it to one', () => { + const error = refusal(fixedRequest({ container_instance: 2, position: {}, placement_id: ident('box#1', 0, 0, 0) })); + assert.ok(error.detail.endsWith(JSON.stringify(ident('box#2', 0, 0, 0)))); +}); + +test('a fixed id past every exact integer is left to admission', () => { + const error = refusal(fixedRequest({ position: { x: { value: String(2 ** 53), unit: 'ticks' } }, placement_id: ident('box#1', 0, 0, 0) })); + assert.equal(error.reason, 'cannot_hold'); + assert.ok(error.detail.includes('outside_container'), error.detail); +}); + +test('a fixed id that is not a string is malformed', () => { + const error = refusal(fixedRequest({ placement_id: 7 })); + assert.deepEqual([error.reason, error.field, error.detail], + ['malformed', '/fixed_placements/0/placement_id', 'fixed_placements[0].placement_id is a string']); +}); + +// ------------------------------------------------------------------ execution plan and artifact + +const withoutIds = (result) => { + const projection = JSON.parse(JSON.stringify(result)); + for (const container of projection.containers) for (const placement of container.placements) delete placement.placement_id; + return projection; +}; + +test('the plan copies each id and a projection without ids keeps its bytes', () => { + const result = pack(request()); + const plan = buildExecutionPlan(request(), result); + const references = plan.containers.flatMap((container) => container.steps.map((step) => step.placement)); + assert.deepEqual(references.map((reference) => reference.placement_id), + result.containers.flatMap((container) => container.placements.map((placement) => placement.placement_id))); + const bare = buildExecutionPlan(request(), withoutIds(result)); + assert.ok(bare.containers.every((container) => container.steps.every((step) => !('placement_id' in step.placement)))); + assert.deepEqual(Object.keys(placementReference(0, result.containers[0].placements[0])).sort(), + ['container_index', 'item_type', 'orientation', 'position_ticks']); +}); + +test('a plan with a non-string or empty id is refused', () => { + for (const [identifier, spelled] of [[3, '3'], ['', "''"], [null, 'None']]) { + const result = pack(request()); + result.containers[0].placements[0].placement_id = identifier; + assert.throws(() => buildExecutionPlan(request(), result), + (error) => error instanceof ExecutionPlanError && error.message === `placement_id ${spelled} is not a non-empty string`); + } +}); + +test('the artifact v1 carries no id', () => { + const result = pack(request()); + const artifact = buildOperationalArtifact(request(), result); + assert.ok(!JSON.stringify(artifact).includes('placement_id')); + assert.deepEqual(artifact, buildOperationalArtifact(request(), withoutIds(result))); +}); + +// ------------------------------------------------------------------ revisions + +test('a lock event may carry the id and is one box with the same entry without it', () => { + const base = request(); + const root = rootRevision(base); + const artifact = buildOperationalArtifact(base, pack(JSON.parse(JSON.stringify(base)))); + const entry = { item_type: 'cube', container_type: 'box', position: { x: '0', y: '0', z: '0' }, orientation: 'LWH' }; + const quoted = { ...entry, placement_id: ident('box#1', 0, 0, 0) }; + const revision = deriveRevision(root, artifact, [ + { sequence: 1, type: 'placement_locked', placement: quoted }, + { sequence: 2, type: 'placement_verified', placement: entry }, + ]); + assert.deepEqual(revision.request.fixed_placements, [quoted]); + assert.throws(() => deriveRevision(root, artifact, [{ sequence: 1, type: 'placement_locked', placement: { ...entry, placement_id: 1 } }]), + (error) => error instanceof PlanRevisionError && error.message.includes('placement.placement_id is a string')); +}); diff --git a/test/request-errors.test.mjs b/test/request-errors.test.mjs index 03a7fa4..eaecd29 100644 --- a/test/request-errors.test.mjs +++ b/test/request-errors.test.mjs @@ -191,13 +191,13 @@ function assertRefusedWith(edit, message) { } test('an error that carries its own code is not relabelled', () => { - const unsupported = refusalOf((r) => { r.containers[0].pallet_overhang_limit = '10'; }); + const unsupported = refusalOf((r) => { Object.assign(r.containers[0], { pallet_overhang_limit: '10', axles: [] }); }); 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', () => { - const error = refusalOf((r) => { r.items = { cube: r.items[0] }; r.containers.push(null, { id: 'b', pallet_overhang_limit: 1 }); }); + const error = refusalOf((r) => { r.items = { cube: r.items[0] }; r.containers.push(null, { id: 'b', pallet_overhang_limit: 1, axles: 1 }); }); assert.ok(error instanceof UnsupportedFeatureError, `${error.name}: ${error.message}`); assertRefused((r) => { r.containers[0].obstacles = [null]; }, 'wrong_type', '/containers/0/obstacles/0', 'must be an object'); }); @@ -255,6 +255,35 @@ test('a fixed placement orientation outside the six codes names them as a requir 'invalid_fixed_placement: fixed_placements[0].orientation must be one of ["LWH","LHW","WLH","WHL","HLW","HWL"]']); }); +const wedge = (extra) => ({ + id: 'wedge', quantity: 1, weight: '1000', dimensions: { length: '100', width: '100', height: '100' }, + shape_type: 'convex_hull', + hull_vertices: [[0, 0, 0], [100, 0, 0], [0, 100, 0], [0, 0, 100]].map(([x, y, z]) => ({ x: `${x}`, y: `${y}`, z: `${z}` })), + ...extra, +}); +const EVERY_ORIENTATION = '["LWH","LHW","WLH","WHL","HLW","HWL","+L-W-H","-L+W-H","-L-W+H","+L-H+W","+L+H-W","-L-H-W",' + + '"+W-L+H","+W+L-H","-W-L-H","+W-H-L","-W+H-L","-W-H+L","+H-L-W","-H+L-W","-H-L+W","+H-W+L","+H+W-L","-H-W-L"]'; + +test('a signed rotation code needs a convex hull, and a hull names all 24 codes (1.10)', () => { + assertRefused((r) => { r.items[0].allowed_rotations = ['LWH', '-L-W+H']; }, 'invalid_value', + '/items/0/allowed_rotations/1', 'a signed rotation code requires shape_type convex_hull'); + assertRefused((r) => { r.items[0] = wedge({ allowed_rotations: ['+L+W+H'] }); }, 'not_allowed', + '/items/0/allowed_rotations/0', `must be one of ${EVERY_ORIENTATION}`); + const placed = (orientation, item) => refusalOf((r) => { + if (item) r.items[0] = item; + r.fixed_placements = [{ item_type: item ? 'wedge' : 'cube', container_type: 'box', orientation }]; + }); + const signed = placed('-L-W+H'); + assert.ok(signed instanceof FixedPlacementError, `${signed.name}: ${signed.message}`); + assert.deepEqual([signed.reason, signed.field, signed.message], ['invalid_value', '/fixed_placements/0/orientation', + 'invalid_fixed_placement: fixed_placements[0].orientation: a signed rotation code requires shape_type convex_hull']); + const unknown = placed('LLL', wedge()); + assert.deepEqual([unknown.reason, unknown.message], ['malformed', + `invalid_fixed_placement: fixed_placements[0].orientation must be one of ${EVERY_ORIENTATION}`]); + const refused = placed('-L-W+H', wedge({ allowed_rotations: ['LWH'] })); + assert.deepEqual([refused.reason, refused.message], ['cannot_hold', 'invalid_fixed_placement: wedge may not be placed in orientation -L-W+H']); +}); + test('a nesting height not below the item height is refused at its pointer', () => { for (const nesting of ['100', { value: '11', unit: 'cm' }]) { assertRefused((r) => { r.items[0].nesting_height = nesting; }, 'invalid_value', '/items/0/nesting_height', diff --git a/test/result-validation-bound.test.mjs b/test/result-validation-bound.test.mjs index 69a7775..cee95f9 100644 --- a/test/result-validation-bound.test.mjs +++ b/test/result-validation-bound.test.mjs @@ -11,7 +11,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; -import { lowerBound } from '../result-validation/bound.js'; +import { lowerBound, objectiveLowerBound, smallestUnsorted } from '../result-validation/bound.js'; import { validateWith } from '../index.js'; const SCENE = new URL('../../../../conformance/scene/objective-bounds.json', import.meta.url); @@ -21,7 +21,7 @@ const optional = (value) => (value === null ? null : BigInt(value)); const runs = (instances, field) => instances.map((instance) => [integer(instance[field]), 1n]) .sort((left, right) => (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0)); -function sceneBound(scenario) { +function sceneInputs(scenario) { const containers = scenario.containers.map((container) => ({ innerVolume: integer(container.inner), baseArea: integer(container.base_area), @@ -33,7 +33,18 @@ function sceneBound(scenario) { quantity: optional(container.quantity), })); const shrinks = scenario.instances.some((instance) => instance.shrinks); - return lowerBound(runs(scenario.instances, 'volume'), runs(scenario.instances, 'weight'), shrinks, containers); + return [runs(scenario.instances, 'volume'), runs(scenario.instances, 'weight'), shrinks, containers]; +} + +const sceneBound = (scenario) => lowerBound(...sceneInputs(scenario)); + +/** Every covered objective's vector for one scenario, `null` where a ceiling withholds it. */ +function sceneObjectives(scenario) { + const values = () => scenario.instances.map((instance) => [integer(instance.value), 1n]); + return Object.fromEntries(Object.keys(scenario.objectives).map((objective) => { + const { bound } = objectiveLowerBound(objective, values, ...sceneInputs(scenario)); + return [objective, bound && bound.map(Number)]; + })); } if (!fs.existsSync(SCENE)) { @@ -41,12 +52,23 @@ if (!fs.existsSync(SCENE)) { } else { test('the validator bound matches every case and refusal edge of the shared scene', () => { const scene = JSON.parse(fs.readFileSync(SCENE, 'utf8')); + assert.equal(scene.format, 'packvium-objective-bounds/v2'); const disagreements = [...scene.cases, ...scene.edges] .map((scenario) => ({ name: scenario.name || scenario.fixture, computed: sceneBound(scenario), expected: scenario.bounds })) .filter(({ computed, expected }) => JSON.stringify(computed && computed.map(String)) !== JSON.stringify(expected && expected.map(String))); assert.deepEqual(disagreements, []); assert.ok(scene.cases.length + scene.edges.length > 400); }); + + test('every covered objective\'s vector matches the shared scene', () => { + const scene = JSON.parse(fs.readFileSync(SCENE, 'utf8')); + const covered = ['default', 'lowest_cost', 'maximum_value', 'maximum_volume', 'open_dimension_height']; + const disagreements = [...scene.cases, ...scene.edges] + .filter((scenario) => JSON.stringify(Object.keys(scenario.objectives).sort()) !== JSON.stringify(covered) + || JSON.stringify(sceneObjectives(scenario)) !== JSON.stringify(scenario.objectives)) + .map((scenario) => scenario.name || scenario.fixture); + assert.deepEqual(disagreements, []); + }); } test('an unlimited capacity, a negative one and zero-volume instances are read as the reference reads them', () => { @@ -58,6 +80,58 @@ test('an unlimited capacity, a negative one and zero-volume instances are read a assert.deepEqual(lowerBound([[1n, 1n]], [[0n, 1n]], false, []), [1n, 0n, 0n, 0n, 0n]); }); +test('each objective-only key is withheld or zero exactly where the reference says', () => { + const box = { + innerVolume: 1000n, baseArea: 100n, height: 10n, usable: 1000n, costMinor: 0n, payload: null, maxItems: null, quantity: 1n, + }; + const three = [[600n, 3n]]; + const valued = (values) => () => values; + const vector = (objective, volumes, shrinks, containers, values = valued([[0n, 3n]])) => ( + objectiveLowerBound(objective, values, volumes, [[0n, 3n]], shrinks, containers)); + assert.deepEqual(vector('maximum_value', three, false, [box], valued([[10n, 1n], [50n, 1n], [90n, 1n]])).bound, [2n, 60n, 1n, 0n, 400000n]); + assert.deepEqual(vector('maximum_value', three, false, [box], valued([[1n, 1n], [2n ** 53n - 1n, 2n]])), + { bound: null, withheld: 'the maximum_value key is past the portable ceiling' }); + assert.deepEqual(vector('open_dimension_height', three, false, [box]).bound, [2n, 6n, 1n, 0n, 400000n]); + assert.deepEqual(vector('open_dimension_height', three, false, [{ ...box, baseArea: 0n }]).bound[1], 0n); + assert.deepEqual(vector('open_dimension_height', three, false, [{ ...box, usable: 0n }]).bound[1], 0n); + assert.deepEqual(vector('open_dimension_height', three, true, [box]).bound[1], 0n); + assert.deepEqual(vector('maximum_volume', three, false, [box]).bound, [444445n, 2n, 1n, 0n, 400000n]); + assert.deepEqual(vector('maximum_volume', three, false, [{ ...box, quantity: null }]).bound[0], 0n); + assert.deepEqual(vector('maximum_volume', [[0n, 3n]], false, [box]).bound[0], 0n); + assert.deepEqual(vector('maximum_volume', three, true, [box]).bound[0], 0n); + assert.deepEqual(vector('maximum_volume', [[10n ** 30n, 3n]], false, [box]), + { bound: null, withheld: 'the request is past a declared ceiling' }); +}); + +test('the smallest values are selected from unsorted runs exactly as a sort would take them', () => { + const runsList = [[5n, 2n], [1n, 3n], [9n, 1n], [1n, 0n], [3n, 2n], [5n, 1n], [0n, 1n]]; + const flat = runsList.flatMap(([value, count]) => Array.from({ length: Number(count) }, () => value)) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); + for (let count = 0; count <= flat.length + 1; count += 1) { + const expected = flat.slice(0, count).reduce((total, value) => total + value, 0n); + assert.equal(smallestUnsorted(runsList, BigInt(count)), expected, `count ${count}`); + assert.equal(smallestUnsorted(runsList.slice().reverse(), BigInt(count)), expected, `reversed, count ${count}`); + } +}); + +test('maximum_value reads each item type\'s declared value from the request', () => { + const request = { + items: [ + { id: 'cheap', quantity: 2, value: 3, dimensions: { length: '10', width: '10', height: '10' } }, + { id: 'dear', value: 7, dimensions: { length: '10', width: '10', height: '10' } }, + { id: 'free', dimensions: { length: '10', width: '10', height: '10' } }, + ], + containers: [{ id: 'box', quantity: 1, inner_dimensions: { length: '20', width: '10', height: '10' } }], + configuration: { objective: 'maximum_value' }, + }; + const packed = validateWith(null, request, { ...result({ code: 'not_proven', lower_bound: [2, 3] }), score: [2, 3, 1, 0, 1000000] }) + .map((found) => found.code); + assert.ok(!packed.includes('optimality_bound_mismatch'), packed.join()); + const forged = validateWith(null, request, { ...result({ code: 'not_proven', lower_bound: [2, 4] }), score: [2, 4, 1, 0, 1000000] }) + .map((found) => found.code); + assert.ok(forged.includes('optimality_bound_mismatch'), forged.join()); +}); + /** One cube in a box for two: attains every position but the height, 1000000 against 500000. */ const REQUEST = { items: [{ id: 'cube', quantity: 1, dimensions: { length: '10', width: '10', height: '10' } }], @@ -106,6 +180,7 @@ function result(optimality) { used_volume_ticks3: String(1000 * MM3), centre_of_mass_offset_ppm: 0, placements: [{ + placement_id: '["box#1",0,0,0,"cube","LWH"]', item_id: 'cube#1', item_type: 'cube', orientation: 'LWH', diff --git a/test/result-validation-port.test.mjs b/test/result-validation-port.test.mjs index d2ae297..df67be8 100644 --- a/test/result-validation-port.test.mjs +++ b/test/result-validation-port.test.mjs @@ -177,7 +177,8 @@ test('a hull\'s volume is exact, whatever the vertex order', () => { const cube = []; for (const x of [0n, 2n]) for (const y of [0n, 2n]) for (const z of [0n, 2n]) cube.push([x, y, z]); assert.equal(hullVolume(cube), 8n); - assert.equal(hullVolume(rotateHull(cube.slice().reverse(), [1, 0, 2])), 8n); + assert.equal(hullVolume(rotateHull(cube.slice().reverse(), 'WLH')), 8n); + assert.equal(hullVolume(rotateHull(cube, '-H-W-L')), 8n); // A wedge with a vertex on an edge midpoint: the face walk passes over it. const wedge = [[0n, 0n, 0n], [4n, 0n, 0n], [0n, 4n, 0n], [0n, 0n, 4n], [2n, 0n, 0n]]; assert.equal(hullVolume(wedge), 10n); @@ -202,7 +203,11 @@ test('a container without an id is refused once an issue has to name it', () => const result = pack(request); assert.deepEqual(validateWith(null, request, result), []); delete result.containers[0].id; - assert.deepEqual(validateWith(null, request, result), []); + // The reference reads a missing id as `""`, so the only issue is the placement id naming it. + assert.deepEqual(validateWith(null, request, result), [{ + code: 'placement_id_mismatch', + detail: 'cube#1 in : reported ["box#1",0,0,0,"cube","LWH"], expected ["",0,0,0,"cube","LWH"]', + }]); result.containers[0].payload_weight = { ticks: 1 }; assert.throws(() => validateWith(null, request, result), (error) => error instanceof MalformedResult && error.kind === 'shape'); }); diff --git a/test/result-validation.test.mjs b/test/result-validation.test.mjs index b8c4316..3cc9277 100644 --- a/test/result-validation.test.mjs +++ b/test/result-validation.test.mjs @@ -68,7 +68,9 @@ test('without the addon, the JavaScript port answers', () => { const broken = JSON.parse(JSON.stringify(result)); const placements = broken.containers[0].placements; placements[1].position = JSON.parse(JSON.stringify(placements[0].position)); - assert.deepEqual(validateWith(null, REQUEST, broken).map((issue) => issue.code), ['collision']); + // Two cubes in one place name the same placement, so the restated id is a repeat (1.10). + placements[1].placement_id = placements[0].placement_id; + assert.deepEqual(validateWith(null, REQUEST, broken).map((issue) => issue.code), ['collision', 'duplicate_placement_id']); if (backend() === 'javascript') assert.deepEqual(validate(REQUEST, result), []); }); diff --git a/test/revisions.test.mjs b/test/revisions.test.mjs index 2c2c9e1..d8188be 100644 --- a/test/revisions.test.mjs +++ b/test/revisions.test.mjs @@ -7,13 +7,14 @@ */ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import { createHash } from 'node:crypto'; import test from 'node:test'; import { SUITE_VERSION, buildOperationalArtifact } from '@packvium/engine/artifacts.js'; import { FORMAT, PlanRevisionError, applyEvents, canonicalRevisionJson, deriveRevision, documentDigest, - rootRevision, verifyRevisionChain, + rootRevision, stockCutBoxes, verifyRevisionChain, } from '@packvium/engine/revisions.js'; import { packFallback } from '../fallback.js'; @@ -203,6 +204,8 @@ const MALFORMED_EVENTS = [ [[{ sequence: true, type: 'item_missing', item_type: 'cube', quantity: 1 }], 'event sequence true does not continue the chain at 1'], [[{ sequence: 1, type: 'placement_locked', placement: { ...locked().placement, orientation: 'XYZ' } }], 'placement.orientation must be one of ["LWH","LHW","WLH","WHL","HLW","HWL"]'], + [[{ sequence: 1, type: 'placement_locked', placement: { ...locked().placement, orientation: '-L-W+H' } }], + 'placement.orientation: a signed rotation code requires shape_type convex_hull'], [[{ sequence: 1, type: 'placement_locked', placement: { ...locked().placement, container_instance: 0 } }], 'placement.container_instance counts from 1'], [['item_missing'], 'an event is a JSON object'], @@ -475,3 +478,84 @@ test('a parent that is not a digest is quoted as JSON', () => { assert.deepEqual(verifyRevisionChain(revisions.slice(0, 1)), [{ code: 'parent_mismatch', revision: 0, detail: 'parent 5, expected null' }]); }); + +// ------------------------------------------------------------------ cut boxes + +function cutRequest(quantity = 2) { + return { + units: { length: 'mm' }, + items: [{ id: 'cube', quantity: 1, dimensions: { length: '100', width: '100', height: '100' } }], + containers: [ + { id: 'machine', quantity, cost_minor: 40, tare_weight: '50', inner_dimensions: { length: '310', width: '310', height: '610' }, + made_to_measure: { minimum: { length: '60', width: '60', height: '60' }, increment: { length: '25', width: '25', height: '25' }, + cost_per_square_metre_minor: 1000, tare_per_square_metre: '500', outer_allowance: { length: '3', width: '3' } } }, + { id: 'crate', inner_dimensions: { length: '100', width: '100', height: '100' } }, + ], + fixed_placements: [{ item_type: 'cube', container_type: 'machine', orientation: 'LWH' }], + }; +} + +const cutArtifact = (sides = ['1760000', '1760000', '1760000']) => ({ + work_order: { containers: [{ container_type: 'crate' }, { container_type: 'machine' }] }, + geometry: { containers: [{}, { inner_dimensions: { length: sides[0], width: sides[1], height: sides[2] } }] }, +}); + +test('a box cut for the approved plan becomes a stock container after its machine', () => { + const request = cutRequest(); + const stocked = stockCutBoxes(request, cutArtifact(), 3); + assert.deepEqual(stocked.containers.map((container) => [container.id, container.quantity ?? null]), + [['machine', 1], ['machine@3#1', 1], ['crate', null]]); + const box = stocked.containers[1]; + assert.equal('made_to_measure' in box, false); + assert.deepEqual(box.inner_dimensions.length, { value: '1760000', unit: 'ticks' }); + // 3 mm of allowance on two axes; none stated on the third. + assert.deepEqual([box.outer_dimensions.length.value, box.outer_dimensions.height.value], ['1808000', '1760000']); + // 6 x 0.11 m x 0.11 m of board at 1000 per square metre is 72.6, rounded up, on a fee of 40. + assert.equal(box.cost_minor, 113); + assert.deepEqual(stocked.fixed_placements[0], { item_type: 'cube', container_type: 'machine@3#1', orientation: 'LWH', container_instance: 1 }); + assert.equal(request.containers.length, 2, 'the caller\'s request is not mutated'); + // The last box of a counted machine takes the machine with it. + assert.deepEqual(stockCutBoxes(cutRequest(1), cutArtifact(), 3).containers.map((container) => container.id), ['machine@3#1', 'crate']); +}); + +test('a restocked fixed entry drops the placement id that named its cut box', () => { + const request = cutRequest(); + request.fixed_placements[0].placement_id = '["machine#1",0,0,0,"cube","LWH"]'; + const stocked = stockCutBoxes(request, cutArtifact(), 3); + assert.deepEqual(stocked.fixed_placements[0], { item_type: 'cube', container_type: 'machine@3#1', orientation: 'LWH', container_instance: 1 }); + assert.equal(request.fixed_placements[0].placement_id, '["machine#1",0,0,0,"cube","LWH"]', 'the caller\'s request is not mutated'); +}); + +test('a request with nothing cut to measure is returned as it was', () => { + const request = cutRequest(); + request.fixed_placements[0].container_type = 'crate'; + assert.deepEqual(stockCutBoxes(request, cutArtifact(), 1), request); + assert.deepEqual(stockCutBoxes({ containers: {} }, cutArtifact(), 1), { containers: {} }); +}); + +test('a cut box the artifact cannot size, or a name already taken, is refused', () => { + const refused = (request, artifact) => { try { stockCutBoxes(request, artifact, 2); } catch (error) { return [error.code, error.message]; } return null; }; + const second = cutRequest(); + second.fixed_placements[0].container_instance = 2; + assert.deepEqual(refused(second, cutArtifact()), ['invalid_artifact', 'the approved plan holds no machine instance 2']); + assert.deepEqual(refused(cutRequest(), cutArtifact(['1760000', '17.5', '1'])), ['invalid_artifact', "a cut box's width is not a tick count"]); + const clash = cutRequest(); + clash.containers[1].id = 'machine@2#1'; + assert.deepEqual(refused(clash, cutArtifact()), ['event_conflict', 'a container is already named "machine@2#1"']); +}); + +test('fixed items in a cut box are checked against the approved artifact', (t) => { + const corpus = new URL('../../../../conformance/revisions/histories.json', import.meta.url); + if (!fs.existsSync(corpus)) { + t.skip('the shared revision history corpus lives in the workspace only'); + return; + } + const cutHistory = JSON.parse(fs.readFileSync(corpus, 'utf8')) + .histories.find((history) => history.name === 'cut-to-measure'); + const chain = cutHistory.chain.map((text) => JSON.parse(text)); + const artifacts = [null, ...cutHistory.steps.map((step) => step.artifact)]; + assert.deepEqual(verifyRevisionChain(chain, artifacts), []); + const unchecked = verifyRevisionChain(chain); + assert.ok(unchecked.some((found) => found.code === 'artifact_required' + && found.detail === 'fixed items in a made-to-measure box are checked against the approved artifact'), JSON.stringify(unchecked)); +}); diff --git a/test/scene.test.mjs b/test/scene.test.mjs index 5d22907..10f03e9 100644 --- a/test/scene.test.mjs +++ b/test/scene.test.mjs @@ -110,7 +110,9 @@ const MALFORMED = [ ['a placement with no reference', (a) => ({ ...a, geometry: { containers: [{ ...a.geometry.containers[0], placements: [{ dimensions: a.geometry.containers[0].placements[0].dimensions }] }] } }), 'artifact.geometry.containers[0].placements[0].placement must be an object'], ['an unknown orientation', (a) => ({ ...a, geometry: { containers: [{ ...a.geometry.containers[0], placements: [{ ...a.geometry.containers[0].placements[0], placement: { ...REFERENCE, orientation: 'XYZ' } }] }] } }), - 'artifact.geometry.containers[0].placements[0].placement.orientation must be one of LWH, LHW, WLH, WHL, HLW, HWL'], + 'artifact.geometry.containers[0].placements[0].placement.orientation must be one of LWH, LHW, WLH, WHL, HLW, HWL, ' + + '+L-W-H, -L+W-H, -L-W+H, +L-H+W, +L+H-W, -L-H-W, +W-L+H, +W+L-H, -W-L-H, +W-H-L, -W+H-L, -W-H+L, ' + + '+H-L-W, -H+L-W, -H-L+W, +H-W+L, +H+W-L, -H-W-L'], ['a fractional position', (a) => ({ ...a, geometry: { containers: [{ ...a.geometry.containers[0], placements: [{ ...a.geometry.containers[0].placements[0], placement: { ...REFERENCE, position_ticks: { x: 0.5, y: 0, z: 0 } } }] }] } }), 'artifact.geometry.containers[0].placements[0].placement.position_ticks.x must be an integer'], ]; @@ -139,3 +141,27 @@ test('a packing result becomes one scene per container, read through its artifac assert.equal(scenes.length, result.containers.length); assert.deepEqual(scenes, result.containers.map((_, index) => artifactToScene(artifact, index))); }); + +test('a hull placed in a signed rotation is drawn as its rotated envelope, not refused (1.10)', () => { + // Two chiral wedges share one cube only when one is turned half a turn about z (-L-W+H). + const vertices = [[0, 0, 0], [100, 0, 0], [0, 100, 0], [0, 0, 100], [100, 0, 100], [0, 100, 100]]; + const request = { + items: [{ + id: 'wedge', quantity: 2, weight: '1000', dimensions: { length: '100', width: '60', height: '80' }, + shape_type: 'convex_hull', allowed_rotations: ['LWH', '-L-W+H'], + hull_vertices: vertices.map(([x, y, z]) => ({ x: `${x}`, y: `${y * 0.6}`, z: `${z * 0.8}` })), + }], + containers: [{ id: 'crate', quantity: 1, inner_dimensions: { length: '100', width: '60', height: '80' } }], + configuration: { solver_profile: 'balanced', time_limit_ms: 60000 }, + }; + const result = pack(request); + assert.equal(result.unpacked_items.length, 0); + const [scene] = resultToScenes(request, result); + assert.deepEqual(scene.items.map((item) => item.rotation).sort(), ['-L-W+H', 'LWH']); + for (const item of scene.items) { + assert.deepEqual(item.dimensions, { length: { ticks: '1600000', mm: '100' }, width: { ticks: '960000', mm: '60' }, + height: { ticks: '1280000', mm: '80' } }); + } + const signed = scene.items.find((item) => item.rotation === '-L-W+H'); + assert.ok(signed.id.endsWith('/-L-W+H'), signed.id); +}); diff --git a/test/stock-cut-errors.test.mjs b/test/stock-cut-errors.test.mjs new file mode 100644 index 0000000..361f6c9 --- /dev/null +++ b/test/stock-cut-errors.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { packFallback } from '../fallback.js'; +import { buildOperationalArtifact } from '../artifacts.js'; +import { deriveRevision, rootRevision, stockCutBoxes, PlanRevisionError } from '../revisions.js'; + +test('replacement and lock refuse unreadable stock fields through the revision error API', () => { + const sides = { length: '10', width: '10', height: '10' }; + const request = { items: [{ id: 'cube', dimensions: sides }], containers: [ + { id: 'machine', inner_dimensions: sides, made_to_measure: { minimum: sides, increment: sides } }, + ] }; + const artifact = buildOperationalArtifact(request, packFallback(request)); + for (const [field, value, detail] of [ + ['made_to_measure', true, 'made_to_measure is not an object'], + ['cost_minor', 'price', 'cost_minor is not an integer'], + ['tare_weight', 'heavy', 'tare_weight is not a measure'], + ]) { + const replacement = { ...request.containers[0], [field]: value }; + const events = [ + { sequence: 1, type: 'container_substituted', container_type: 'machine', replacement }, + { sequence: 2, type: 'placement_locked', placement: { item_type: 'cube', container_type: 'machine', orientation: 'LWH' } }, + ]; + assert.throws(() => deriveRevision(rootRevision(request), artifact, events), + (error) => error instanceof PlanRevisionError && error.code === 'invalid_revision' + && error.message === `a made-to-measure container's ${detail}`); + } + const revision = deriveRevision(rootRevision(request), artifact, [{ sequence: 1, type: 'placement_locked', + placement: { item_type: 'cube', container_type: 'machine', orientation: 'LWH', position: { x: '999999' } } }]); + assert.equal(revision.request.fixed_placements[0].position.x, '999999'); + for (const [units, detail] of [[[], 'units is not an object'], [{ length: [] }, 'units.length is not a string']]) { + const fixed = { ...request, units, fixed_placements: [{ container_type: 'machine' }] }; + assert.throws(() => stockCutBoxes(fixed, artifact, 1), + (error) => error instanceof PlanRevisionError && error.message === `a made-to-measure container's ${detail}`); + } + for (const cost of [null, 0, 1, 1.0, -1]) { + const fixed = { ...request, containers: [{ ...request.containers[0], cost_minor: cost }], + fixed_placements: [{ container_type: 'machine' }] }; + assert.equal(stockCutBoxes(fixed, artifact, 1).containers[1].cost_minor, cost ?? 0); + } +}); diff --git a/test/stock-cut-index.test.mjs b/test/stock-cut-index.test.mjs new file mode 100644 index 0000000..f7459ce --- /dev/null +++ b/test/stock-cut-index.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { stockCutBoxes } from '../revisions.js'; + +test('stocking reads artifact rows once and keeps the first lock order', () => { + const count = 100; + const request = { + containers: [{ id: 'machine', quantity: count, made_to_measure: {} }], + fixed_placements: Array.from({ length: count }, (_, i) => ({ container_type: 'machine', container_instance: count - i })), + }; + let reads = 0; + const artifact = { + work_order: { containers: Array.from({ length: count }, () => ({ get container_type() { reads += 1; return 'machine'; } })) }, + geometry: { containers: Array.from({ length: count }, (_, i) => ({ inner_dimensions: { length: String(i + 1), width: '2', height: '3' } })) }, + }; + const stocked = stockCutBoxes(request, artifact, 7); + assert.ok(reads <= count * 2, `${reads} artifact row reads`); + assert.deepEqual(stocked.containers.map((box) => box.id), Array.from({ length: count }, (_, i) => `machine@7#${count - i}`)); + assert.equal(stocked.containers[0].inner_dimensions.length.value, '100'); + assert.equal(stocked.containers[count - 1].inner_dimensions.length.value, '1'); +}); + +test('stocking joins mixed types and leaves unselected malformed geometry unread', () => { + const request = { + containers: [{ id: 'machine', made_to_measure: {}, cost_minor: 1 }, { id: 'machine', made_to_measure: {}, cost_minor: 9 }], + fixed_placements: [{ container_type: 'machine', container_instance: 2 }], + }; + const artifact = { + work_order: { containers: [{ container_type: 'machine' }, { container_type: 'other' }, { container_type: 'machine' }, { container_type: 'machine' }] }, + geometry: { containers: [null, null, { inner_dimensions: { length: '1', width: '2', height: '3' } }] }, + }; + const stocked = stockCutBoxes(request, artifact, 1); + assert.equal(stocked.containers[1].cost_minor, 9); + assert.equal(stocked.containers[1].inner_dimensions.length.value, '1'); +}); diff --git a/test/validate.mjs b/test/validate.mjs index 0f7d1ff..d93e727 100644 --- a/test/validate.mjs +++ b/test/validate.mjs @@ -117,14 +117,23 @@ function vprimitive(v) { const lead = r.find((x) => x !== 0n); return lead > 0n ? r : [-r[0], -r[1], -r[2]]; } -/** The rotation applied to a hull, as a proper rotation: three of the six orientations are odd - * permutations, and a bare permutation would return the item's mirror image. */ +/** The 24 proper rotations (1.10), written out as the contract states them: per code, the + * declared side each container axis takes and the sign it carries. Legacy odd permutations + * negate x, so no code is ever the item's mirror image. */ +const HULL_ROTATIONS = { + LWH: [[0, 1, 2], [1n, 1n, 1n]], LHW: [[0, 2, 1], [-1n, 1n, 1n]], WLH: [[1, 0, 2], [-1n, 1n, 1n]], + WHL: [[1, 2, 0], [1n, 1n, 1n]], HLW: [[2, 0, 1], [1n, 1n, 1n]], HWL: [[2, 1, 0], [-1n, 1n, 1n]], + '+L-W-H': [[0, 1, 2], [1n, -1n, -1n]], '-L+W-H': [[0, 1, 2], [-1n, 1n, -1n]], '-L-W+H': [[0, 1, 2], [-1n, -1n, 1n]], + '+L-H+W': [[0, 2, 1], [1n, -1n, 1n]], '+L+H-W': [[0, 2, 1], [1n, 1n, -1n]], '-L-H-W': [[0, 2, 1], [-1n, -1n, -1n]], + '+W-L+H': [[1, 0, 2], [1n, -1n, 1n]], '+W+L-H': [[1, 0, 2], [1n, 1n, -1n]], '-W-L-H': [[1, 0, 2], [-1n, -1n, -1n]], + '+W-H-L': [[1, 2, 0], [1n, -1n, -1n]], '-W+H-L': [[1, 2, 0], [-1n, 1n, -1n]], '-W-H+L': [[1, 2, 0], [-1n, -1n, 1n]], + '+H-L-W': [[2, 0, 1], [1n, -1n, -1n]], '-H+L-W': [[2, 0, 1], [-1n, 1n, -1n]], '-H-L+W': [[2, 0, 1], [-1n, -1n, 1n]], + '+H-W+L': [[2, 1, 0], [1n, -1n, 1n]], '+H+W-L': [[2, 1, 0], [1n, 1n, -1n]], '-H-W-L': [[2, 1, 0], [-1n, -1n, -1n]], +}; +/** The rotation applied to a hull: `v'[i] = s[i] * v[src[i]]`, translated to the origin. */ function rotateHull(vertices, orientation) { - const axes = { LWH: [0, 1, 2], LHW: [0, 2, 1], WLH: [1, 0, 2], WHL: [1, 2, 0], HLW: [2, 0, 1], HWL: [2, 1, 0] }[orientation]; - let inversions = 0; - for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) if (axes[i] > axes[j]) inversions++; - const sign = inversions % 2 ? -1n : 1n; - const turned = vertices.map((v) => [sign * v[axes[0]], v[axes[1]], v[axes[2]]]); + const [axes, signs] = HULL_ROTATIONS[orientation]; + const turned = vertices.map((v) => [signs[0] * v[axes[0]], signs[1] * v[axes[1]], signs[2] * v[axes[2]]]); const low = [0, 1, 2].map((a) => turned.reduce((m, v) => (v[a] < m ? v[a] : m), turned[0][a])); return turned.map((v) => [v[0] - low[0], v[1] - low[1], v[2] - low[2]]); } @@ -257,11 +266,66 @@ function fixedPlacementIssues(request, result, lengthUnit) { } /** - * A flat bag reports the package its placements make, and that package passes the - * girth rule. Measured on the physical boxes, as the score below is: meaningful only when no - * clearance was configured. + * Every reported placement names itself (1.10): `placement_id` is the canonical JSON of + * `[container id, x, y, z, item type, orientation]` with the position in ticks, which for an + * array of strings and integers is exactly what `JSON.stringify` writes. A lattice summary has + * no placement objects, so it has no ids. + */ +function placementIdIssues(result) { + const issues = []; + const seen = new Set(); + for (const container of result.containers) { + for (const placement of container.placements) { + const expected = JSON.stringify([container.id, ...corner(placement.position), placement.item_type, placement.orientation]); + if (placement.placement_id !== expected) issues.push('placement_id_mismatch'); + if (typeof placement.placement_id !== 'string') continue; + if (seen.has(placement.placement_id)) issues.push('duplicate_placement_id'); + seen.add(placement.placement_id); + } + } + return issues; +} + +/** + * A pallet's solving frame: the reported inner size is the declared deck, the load + * may pass it by the limit at each end and side, and coordinates start at the corner of the + * overhang zone. Without a limit the frame is the reported inner size and there is no deck. */ -function flatBagIssues(template, container, boxes, inner) { +function palletFrame(template, container, lengthUnit) { + const inner = size(container.inner_dimensions); + const limit = template?.pallet_overhang_limit; + if (limit == null) return { frame: inner, deck: null }; + const x = requestLengthTicks(limit.length, lengthUnit); + const y = requestLengthTicks(limit.width, lengthUnit); + return { frame: [inner[0] + 2 * x, inner[1] + 2 * y, inner[2]], deck: { x, y, length: inner[0], width: inner[1] } }; +} + +/** The deck grown by the overhang the boxes use past each edge, at the load's height. */ +function deckExtent(deck, boxes) { + let lowX = deck.x, lowY = deck.y, highX = deck.x + deck.length, highY = deck.y + deck.width, top = 0; + for (const box of boxes) { + lowX = Math.min(lowX, box.at[0]); lowY = Math.min(lowY, box.at[1]); + highX = Math.max(highX, box.at[0] + box.size[0]); highY = Math.max(highY, box.at[1] + box.size[1]); + top = Math.max(top, box.at[2] + box.size[2]); + } + return [highX - lowX, highY - lowY, top]; +} + +function palletIssues(container, boxes, deck) { + const [a, b, c] = deckExtent(deck, boxes); + const reported = container.achieved_dimensions == null ? null : size(container.achieved_dimensions); + return reported == null || reported[0] !== a || reported[1] !== b || reported[2] !== c + ? ['achieved_dimensions_mismatch'] : []; +} + +/** + * A bag reports the package its placements make, and that package passes the girth + * rule a + c <= L, b + c <= W + G, G a gusseted bag's side gusset (1.10) and 0 for a flat one. + * Measured on the physical boxes, as the score below is: meaningful only when no clearance + * was configured. + */ +function flatBagIssues(template, container, boxes, inner, deck, lengthUnit) { + if (deck != null) return palletIssues(container, boxes, deck); if (template.deformable_boundary == null) { const soft = template.made_to_measure != null; return container.achieved_dimensions != null && !soft ? ['achieved_dimensions_mismatch'] : []; @@ -270,7 +334,9 @@ function flatBagIssues(template, container, boxes, inner) { const issues = []; const reported = container.achieved_dimensions == null ? null : size(container.achieved_dimensions); if (reported == null || reported[0] !== a || reported[1] !== b || reported[2] !== c) issues.push('achieved_dimensions_mismatch'); - if (a + c > inner[0] || b + c > inner[1]) issues.push('bag_girth_exceeded'); + const depth = template.deformable_boundary.side_gusset_depth; + const gusset = depth == null ? 0 : requestLengthTicks(depth, lengthUnit); + if (a + c > inner[0] || b + c > inner[1] + gusset) issues.push('bag_girth_exceeded'); return issues; } @@ -296,11 +362,11 @@ export function validate(request, result) { } inventory.set(container.container_type, (inventory.get(container.container_type) ?? 0) + 1); - const inner = size(container.inner_dimensions); + const { frame: inner, deck } = palletFrame(template, container, lengthUnit); const boxes = boxesOf(container, latticeSequences); if (template.max_items != null && boxes.length > template.max_items) issues.push('max_items_exceeded'); - issues.push(...flatBagIssues(template, container, boxes, inner)); + issues.push(...flatBagIssues(template, container, boxes, inner, deck, lengthUnit)); boxes.forEach((box, index) => { if (seen.has(box.id)) issues.push('duplicate_item'); @@ -374,6 +440,7 @@ export function validate(request, result) { } issues.push(...fixedPlacementIssues(request, result, lengthUnit)); + issues.push(...placementIdIssues(result)); const expected = new Set(); for (const item of request.items) { @@ -443,6 +510,8 @@ function containerCost(template, inner) { return fee + Number((price * area + squareMetre - 1n) / squareMetre); } +const palletBilledSize = (base, achieved) => [Math.max(base[0], achieved[0]), Math.max(base[1], achieved[1]), base[2]]; + export function objective(request, result) { const containersById = new Map(request.containers.map((container) => [container.id, container])); const itemsById = new Map(request.items.map((item) => [item.id, item])); @@ -456,9 +525,11 @@ export function objective(request, result) { const latticeSequences = new Map(); for (const container of result.containers) { - const inner = size(container.inner_dimensions); - cost += containerCost(containersById.get(container.container_type), inner); + const template = containersById.get(container.container_type); + cost += containerCost(template, size(container.inner_dimensions)); + // Utilization and height are measured against the frame the solver packed. + const { frame: inner, deck } = palletFrame(template, container, lengthUnit); const total = volume(inner); const boxes = boxesOf(container, latticeSequences); // Occupied volume follows the shape: a hull's own volume, a compressible item's height @@ -499,9 +570,11 @@ export function objective(request, result) { const lengthTicks = BigInt(LENGTH_TICKS[request.configuration.dimensional_weight_length_unit ?? 'in']); const weightTicks = BigInt(WEIGHT_TICKS[request.configuration.dimensional_weight_weight_unit ?? 'lb']); const divisor = BigInt(request.configuration.dimensional_weight_divisor); - // A flat bag is billed on the package it conformed to. - const billedSize = containersById.get(container.container_type)?.deformable_boundary != null - ? bagExtent(boxes) : size(container.outer_dimensions); + // A flat bag is billed on the package it conformed to; a pallet on the larger + // of its declared footprint and the deck grown by the overhang used. + const billedSize = template?.deformable_boundary != null ? bagExtent(boxes) + : deck != null ? palletBilledSize(size(container.outer_dimensions), deckExtent(deck, boxes)) + : size(container.outer_dimensions); const dimensional = volume(billedSize) * weightTicks / (lengthTicks ** 3n * divisor); const billed = BigInt(ticks(container.gross_weight)) > dimensional