From 70c692a646df2c1c59769ea957d52a518ee3c907 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 11 Oct 2026 12:19:10 -0400 Subject: [PATCH] chore(release): 1.6.0 --- CHANGELOG.md | 51 ++ CITATION.cff | 2 +- README.md | 7 +- artifacts.js | 2 +- codemeta.json | 2 +- docs/GUARANTEES.md | 2 +- docs/PUBLIC-API.md | 81 ++- fallback.js | 916 +++++++++++++++++++++------ index.d.ts | 10 +- index.js | 2 +- made-to-measure.js | 95 +++ package.json | 4 +- request-errors.js | 81 ++- test/beam-ordering-hulls.test.mjs | 56 ++ test/block-enumeration.test.mjs | 221 +++++++ test/equal-volume-order.test.mjs | 59 ++ test/exact-small-hulls.test.mjs | 83 +++ test/fallback.test.mjs | 407 +++++++++++- test/fixed-placements.test.mjs | 4 + test/flat-bag.test.mjs | 123 ++++ test/limit-reasons.test.mjs | 78 +++ test/made-to-measure.test.mjs | 162 +++++ test/multi-start-orders.test.mjs | 48 ++ test/plan-bound-hulls.test.mjs | 31 + test/request-errors.test.mjs | 10 + test/result-writer.test.mjs | 102 +++ test/sequence.test.mjs | 35 + test/unsupported-placements.test.mjs | 69 ++ test/validate.mjs | 62 +- 29 files changed, 2552 insertions(+), 253 deletions(-) create mode 100644 made-to-measure.js create mode 100644 test/beam-ordering-hulls.test.mjs create mode 100644 test/block-enumeration.test.mjs create mode 100644 test/equal-volume-order.test.mjs create mode 100644 test/exact-small-hulls.test.mjs create mode 100644 test/flat-bag.test.mjs create mode 100644 test/limit-reasons.test.mjs create mode 100644 test/made-to-measure.test.mjs create mode 100644 test/multi-start-orders.test.mjs create mode 100644 test/plan-bound-hulls.test.mjs create mode 100644 test/result-writer.test.mjs create mode 100644 test/unsupported-placements.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ce24d52..44c7a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,57 @@ 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.6.0] + +Boxes cut to measure, flat bags, stacking groups and a volume-first objective in the request. +A request that uses none of them keeps its meaning; `fast` and `balanced` may now answer better. + +### Added + +- **`made_to_measure` on a container**: an on-demand box machine (largest box, minimum, cut + step, board price and weight per m²); each packed box is the load's extent rounded up onto + the machine grid and priced as cut, reported with `achieved_dimensions`. +- **`deformable_boundary: {kind: 'flat_bag'}` on a container**: a poly mailer that admits a load + when length plus thickness and width plus thickness fit. +- **`stacking_group` and `accepts_stacking_groups` on an item**: which classes may rest directly + on which. +- **`objective: 'maximum_volume'`**, ranking the volume left behind ahead of the piece count. +- **A warning for items resting on nothing** when `minimum_support_ratio` is 0. No answer + changes. TypeScript types cover every new field. + +### Changed + +- **An unpacked item names the limit that left it out, the same in every engine.** + Where the search used to fall back to `no_feasible_placement` or `search_exhausted`, an item a + fresh container would take but none is left reports `container_inventory_exhausted`, and one + that overloads an axle wherever it stands alone reports `axle_overloaded`; both are + `inferred`. Code that matched the old reason for these cases must match the new one. +- **`balanced` and `quality` try eight orderings by default**, as the other engines + do; `multi_start_orders` defaulted to 1. Answers can improve, and these profiles take longer: + on our benchmark `balanced` 2-25 times as long, `quality` 1.7-2.8 times. `fast` keeps one + ordering, and now ignores a larger `multi_start_orders`, as the other engines do. Set + `multi_start_orders: 1` for the 1.5 speed. The candidate search allocates about 80% less, so + eight orderings peak within about 13% of the memory one ordering took in 1.5.0. +- **Equal-volume items are ordered longest edge first, then heavier first, as in the other engines +,** and the layer start keeps its own order in every profile, so `quality` no longer + packs a simple pallet/carton/drum load worse than `balanced`. +- **`quality` orders its beam by a `convex_hull` item's own solid**, not its box, so + it no longer assumes a second wedge cannot fit when it can. +- **The container plan counts a `convex_hull` item by its own solid too**, so + `quality` no longer ranks a plan as if wedges that share a container each needed their own. +- **`fast` and `balanced` add the block solver as one more start** on multi-type loads and keep + its answer only when strictly better. + +### Fixed + +- **`effort_budget` caps the whole request, not each start.** With several orderings or the + `quality` portfolio every start got the full budget, so `max_search_nodes: 5` could spend 40. +- **A restart keeps item priority.** A shuffled ordering could put a low-priority item ahead of + a high-priority one; it now keeps priority, value under `maximum_value` and stop order first. +- **Exact search with a `convex_hull` item.** It bounded a hull by its box volume: two wedges + that share one crate went into two, and a small order with a jar spent most of its time limit. + It now finishes in a handful of search nodes. + ## [1.5.0] No API changes. The JavaScript engine's beam search is faster; route packing, diff --git a/CITATION.cff b/CITATION.cff index c118e17..1392839 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - name: "Packvium contributors" website: "https://packvium.com" -version: "1.5.0" +version: "1.6.0" license: MIT url: "https://packvium.com" repository-code: "https://github.com/toxakara/packvium-node" diff --git a/README.md b/README.md index fd6f102..f1168c0 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,11 @@ is a tenth of a millimetre and never `0.09999999999999999`. Every measurement in comes back as `{ ticks, value, unit }` -- `ticks` is the exact integer the engine reasoned with, `value` the same number written for a person. +Support is not required by default: `minimum_support_ratio` is 0, so an item may rest on +nothing above the container floor, and the result then says so in `warnings` +(`unsupported_placements: N placement(s) rest on nothing above the container floor; ...`). +To require support, set it above 0 — `configuration: { minimum_support_ratio: 0.75 }` for three quarters of the base. + ## Two backends `pack()` uses a compiled native addon when one is built beside `index.js` and loads, @@ -307,7 +312,7 @@ CodeMeta form. @software{packvium_node, author = {{Packvium contributors}}, title = {Packvium for Node.js}, - version = {1.5.0}, + version = {1.6.0}, license = {MIT}, url = {https://packvium.com} } diff --git a/artifacts.js b/artifacts.js index 163abaf..d4eb07a 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.5.0'; +export const SUITE_VERSION = '1.6.0'; /** The deterministic part of `result.algorithm`; `duration_ms` is wall-clock time. */ const SOLVER_FIELDS = ['profile', 'solver', 'seed', 'time_limit_reached', 'effort_limit_reached']; diff --git a/codemeta.json b/codemeta.json index 6e47b4f..26ec135 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.5.0", + "version": "1.6.0", "license": "https://spdx.org/licenses/MIT", "author": [ { diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 6f2b855..1ee0c28 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -52,7 +52,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.5.0` freezes the public API. Field names, status codes, the objective +Version `1.6.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 b3d3799..df8fd8c 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -3,6 +3,20 @@ ## Core inputs - `Item`: id, dimensions, weight, quantity, rotations, upright/floor/stacking rules, top-load limit, support ratio, group, tags, metadata, and an optional `nesting_height` (how much this item sinks into an identical one beneath it when stacked). Only the same item type with the exact same footprint may nest; its adjacent predecessor is one full-footprint direct supporter for support ratio, ground-contact, stack/load and route rules, while non-adjacent same-column face coincidences are shadowed. An optional, exact non-negative integer `value` (no unit or currency — the caller's own economic scale) means nothing to placement or to any other objective; only the `maximum_value` objective reads it. +- **Building a Rust `Item` or `Container` in code.** `Item::new(id, dimensions, + weight, quantity)` and `Container::new(id, inner_dimensions)` fill every other field with + the value a JSON request gets when it leaves that field out. Set the rest with struct update + syntax (`Item { stackable: false, ..Item::new(...) }`), which keeps compiling when a release + adds a field; a full struct literal does not. +- **Stacking groups.** `stacking_group` (a non-empty string, default none) names an + item's class; `accepts_stacking_groups` (non-empty strings, default empty) lists the classes + it may carry directly on its top face. Empty is unconstrained; a non-empty list refuses every + other class and every item with no class, edge by edge, never transitively; `stackable: + false` dominates. The engines expose the same two fields on their item type: Python + `Item.stacking_group` / `Item.accepts_stacking_groups`, PHP trailing constructor parameters + `$stackingGroup` / `$acceptsStackingGroups`, Rust `Item::stacking_group` / + `Item::accepts_stacking_groups`, and the TypeScript request type. See + STACKING-GROUPS.md. - **Item geometry beyond the box.** `shape_type` is an enum — `rigid_cuboid` (the default), `convex_hull`, `compressible` — accompanied by `hull_vertices`, `compression_ratio` and `max_compression_pressure_kpa`. **A request that @@ -45,12 +59,51 @@ 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. +- **Flat bags.** `deformable_boundary: {"kind": "flat_bag"}` (absent or `null`: a rigid + container) makes the container a two-sheet bag of usable flat size `inner_dimensions.length` + x `.width`, filled to at most `.height`. A packing is admitted only when, with `a`, `b`, `c` + the largest envelope `x2`, `y2`, `z2` of its placements, `a + c <= L` and `b + c <= W` as well + as the ordinary boundary; equality fits. Each packed bag reports `achieved_dimensions` + `(a, b, c)`, the package dimensional weight is billed on (and the size a nested level packs + it as); objective key 3 still uses the declared `L·W·H`. Next to `obstacles` (non-empty), + `axles`, `access_directions`, `max_stack_density`, `outer_dimensions`, `made_to_measure` or a + positive `void_fill_reserve_ratio` it is refused with `invalid_request` / `not_allowed`, and a + fixed set past its girth with `invalid_fixed_placement` / `cannot_hold`, detail + `bag_girth_exceeded: `. The engines expose it as Python + `Container.deformable_boundary` (`"flat_bag"` or None, last field) with + `PackedContainer.achieved_dimensions` / `.billed_dimensions`, PHP the trailing constructor + parameter `$deformableBoundary` with `PackedContainer::achievedDimensions()` / + `billedDimensions()`, Rust `Container::deformable_boundary: Option` with + `PackedContainer::achieved_dimensions()` / `billed_dimensions()`, and the TypeScript request + type. See DEFORMABLE-CONTAINERS.md. +- **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 + `inner_dimensions` pins an axis, and `inner_dimensions - minimum` must be a multiple of + `increment`), `cost_per_square_metre_minor` (integer, default 0), `tare_per_square_metre` + (a weight measure, default 0) and `outer_allowance` (per axis, default 0). Each packed + container is the box cut for it: `inner_dimensions` the achieved extent rounded up onto the + grid, `outer_dimensions` that plus `outer_allowance`, and a `made_to_measure` result block + with its `cost_minor` (declared `cost_minor` plus `ceil(price x 2(LW + LH + WH) / 1 m^2)`) + 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 + `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. See + MADE-TO-MEASURE-CONTAINERS.md. - `PackingConfig`: solver profile, deadline, alternatives, deterministic seed, clearance, - support threshold, exact-search limit, beam/order budgets, the portable + 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 + base to be carried), exact-search limit, beam/order budgets, the portable `max_candidate_points` search-memory bound, deterministic global-plan controls `container_plan_beam_width` / `container_plan_node_limit`, and an optional `effort_budget` that bounds all four stable implementations by counted work instead of wall-clock - time. `solvers` is an ordered list: every named strategy is run, stable ties prefer + time. In code it is Python `packvium.EffortBudget`, PHP `Packvium\Config\EffortBudget` and + Rust `packvium_core::EffortBudget`. `solvers` is an ordered list: every named strategy is run, stable ties prefer the earlier name, and an unknown name is a structured error. The optional The stable solver names are `grid`, `extreme_points`, `homogeneous_blocks`, `layer`, `maximal_spaces` and `exact_small`. `homogeneous_blocks` is the unrestricted @@ -124,7 +177,10 @@ move backwards. This is a testing seam, not a serialized request field. - objective score — a five-key lexicographic vector of exact integers, identical across every implementation (OBJECTIVE.md) — and solver statistics; - the exact `catalog_versions_used` lineage from the request (an empty array when no catalog was involved), preserved by Python, PHP, Rust and JavaScript; -- warnings and top-K alternatives. +- 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; ...` + line, byte-identical in every engine; each alternative carries its own. ### `alternatives`, and what it is not @@ -315,6 +371,10 @@ by the same `packvium_core::rebalance_json` adapter: - Python/PyO3 and WebAssembly: `rebalance_json(request, result, max_moves)`; - N-API: `rebalanceJson(request, result, maxMoves)`. +`@packvium/browser` wraps the WebAssembly operation as `rebalanceWeight(request, result, +{maxMoves})`, the Node package's name and signature, resolving to the same document +. + The Node package's public `rebalanceWeight` automatically uses that N-API operation when the addon is healthy and otherwise runs the deterministic JavaScript implementation. Boundary adapters exchange canonical JSON and do not duplicate the @@ -439,6 +499,17 @@ details. A structural bound takes precedence over a deadline: an oversized item `proven` even if the overall run timed out. Conversely, `time_limit`, `search_exhausted` and other unfinished-search outcomes can never carry `proven`. +Container, axle and stop limits have one code each, the same in every engine. +Among the containers that admit an item on every structural count, an axle that would be +overloaded wherever the item stood alone in each of them gives `axle_overloaded`. Failing +that, when every such container that would take the item alone is out of stock, or +`max_containers` is reached, the item is `container_inventory_exhausted`: `max_items`, a +cumulative `max_payload`, `tag_limits`, stop accessibility and an axle's share of a full +load can only refuse an item a fresh container would take, so they all end there. Both are +`inferred` -- neither is a certificate, since a counterweight behind the rear axle can +relieve the front one -- and both rank after the structural proofs, the deadline, the +effort budget and `group_cannot_fit_together`. + ## Commercial and control-plane API Three deterministic functions over one canonical JSON document -- a carrier quote, an @@ -559,7 +630,9 @@ looked up in the plan's order and never re-ordered. **Replay is stated, not assumed.** `provenance.replay.level` is `exact` only when solving the embedded request again must reproduce the result. When the search stopped on wall-clock time, or the result does not record how it was solved, the level is `not_guaranteed`, and `because` -names the field that decided it. +names the field that decided it. Python `packvium.artifacts.replay_level(result)` and PHP +`OperationalArtifact::replayLevel($result)` answer the same level without building the +artifact. **Refusals use one closed set of codes** in every language (`invalid_request`, `invalid_result`, `invalid_plan_input`, `mixed_units`, `number_out_of_range`, `invalid_string`, `invalid_value`, diff --git a/fallback.js b/fallback.js index cecf4ed..9d335c1 100644 --- a/fallback.js +++ b/fallback.js @@ -3,6 +3,7 @@ import { appendContactBox, buildContactGraph } 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 { fabricate, solvingEnvelopes } from './made-to-measure.js'; export { InvalidRequestError }; @@ -15,6 +16,8 @@ const hasOwn=(value,key)=>Object.prototype.hasOwnProperty.call(value,key); const isObject=value=>value!==null&&typeof value==='object'&&!Array.isArray(value); function cloneValue(value){ if(Array.isArray(value))return value.map(cloneValue); + // An item's accepted stacking groups are a Set of strings; copied, not flattened. + if(value instanceof Set)return new Set(value); if(value!==null&&typeof value==='object'){ const copy={}; for(const [key,entry] of Object.entries(value))copy[key]=cloneValue(entry); @@ -79,6 +82,13 @@ function parseRateTable(raw){ if(!Number.isSafeInteger(fuel)||fuel<0)throw new RangeError('rate_table fuel_surcharge_permille must be a non-negative safe integer'); return {brackets,prices,minimum,fuel} } +// A made-to-measure container's machine in ticks, or null for a stock container. +function parseMachine(raw,unit){ + if(raw==null)return null; + const sides=value=>value==null?[0,0,0]:dims(value,unit); + return {minimum:sides(raw.minimum),increment:sides(raw.increment),price:raw.cost_per_square_metre_minor??0, + tarePerSquareMetre:raw.tare_per_square_metre==null?0:scalar(raw.tare_per_square_metre,'g',WT),allowance:sides(raw.outer_allowance)} +} // Billed weight in whole grams, rounded up -- how a carrier reads a scale. A shipment // fractionally over a bracket is in the next bracket; rounding down would price it // below what the carrier charges. @@ -112,6 +122,17 @@ export class UnsupportedFeatureError extends Error{ // shape is checked, so a list that is not one, or an entry that is not an object, is left for // `checkRequest` to name rather than crashing here. const objectsIn=value=>Array.isArray(value)?value.filter(isObject):[]; +// D10: each combination reads a container dimension or position the fabricated box does +// not keep. Checked on the raw request, before any request error. O(containers + fixed placements). +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(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} 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}`); @@ -122,6 +143,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)); if(fields.length)throw new UnsupportedFeatureError([...new Set(fields)].sort()); } /** @@ -215,7 +237,7 @@ function admitFixedPlacements(req,{items,templates,u,clear}){ if(Math.min(px,py,pz)edge+2*clear),r:entry.orientation,item,fixed:true})}); + grouped.get(key).placements.push({x:px-clear,y:py-clear,z:pz-clear,pd,ed:grown(pd,2*clear),r:entry.orientation,item,fixed:true})}); const groups=[...grouped.values()].sort((a,b)=>order.get(a.tmpl.id)-order.get(b.tmpl.id)||a.seq-b.seq); requireContiguousInstances(groups,req.configuration?.max_containers??null); // Support is measured against every other fixed item in the container, not only the ones @@ -251,7 +273,7 @@ function fixedSetIssue(groups,req,clear){ if(intersects(boxOf(a),boxOf(b))&&!validNesting(a,b)&&solidsOverlap(placedHull(a),boxOf(a),placedHull(b),boxOf(b)))return `collision: ${a.item.id} with ${b.item.id}`} const compatibility=placements.some(p=>p.item.tags.length||p.item.incompatible.length); const support=globalRatio>0||placements.some(p=>p.item.supportPpm>0||(p.item.groundRule!=null&&p.item.groundRule!=='free')); - const stack=placements.some(p=>!p.item.stackable||p.item.maxTop!=null||p.item.maxStacked!=null||p.item.maxCompressionKpa!=null); + const stack=placements.some(p=>refusesLoad(p.item)||p.item.maxTop!=null||p.item.maxStacked!=null||p.item.maxCompressionKpa!=null); const boxes=placements.map(constraintBox),graph=contactGraph(boxes); for(let index=0;indexk!==index); if(p.x+p.ed[0]>tmpl.d[0]||p.y+p.ed[1]>tmpl.d[1]||p.z+p.ed[2]>tmpl.d[2])return `outside_container: ${id}`; @@ -260,14 +282,16 @@ function fixedSetIssue(groups,req,clear){ 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(stack){const lower=graph.supporters[index].find(([k])=>!placements[k].item.stackable); - if(lower!==undefined)return `non_stackable: ${id}: ${placements[lower[0]].item.raw.id}`; - if(!item.stackable&&graph.children[index].length)return `non_stackable: ${id}: ${item.raw.id}`}} + 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; + if(upper!==undefined)return `${stackingRefusal(item,placements[upper].item)}: ${id}: ${item.raw.id}`}} const bearing=loadIssue(boxes,graph,placements,tmpl);if(bearing!==null)return `${bearing[0]}: ${cid}: ${bearing[1]}`; const reaction=axleReactions(tmpl,placements); if(reaction!=null){const [front,rear]=tmpl.axleSpec; if(front.max!=null&&reaction.front>BigInt(front.max)*reaction.denominator)return `axle_overloaded: ${cid}: front`; if(rear.max!=null&&reaction.rear>BigInt(rear.max)*reaction.denominator)return `axle_overloaded: ${cid}: rear`} + if(tmpl.bag&&!bagAdmits(tmpl,placements))return `bag_girth_exceeded: ${cid}`; if(placements.some(p=>p.item.stopIndex!=null)){const stuck=routeStuck(placements,tmpl,clear); if(stuck!==null)return `unloading_order_violation: ${cid}: stop ${stuck.stop} cannot be fully unloaded (${stuck.indices.map(k=>placements[k].item.id).join(', ')} still blocked)`} if(tmpl.max!=null&&placements.reduce((total,p)=>total+p.item.w,0)>tmpl.max)return `payload_exceeded: ${cid}`; @@ -277,6 +301,11 @@ function fixedSetIssue(groups,req,clear){ if(where.length>1)return `group_split: ${group}: ${where.join(', ')}`} return null } +// `[max x2, max y2, max z2]` over the envelopes, and the girth rule over it. +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]} 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))) @@ -329,7 +358,12 @@ function rat(s){s=String(s).trim();if(s.includes(' ')){const [w,f]=s.split(/\s+/ function round(n,d){const sg=n<0n?-1n:1n,a=n<0n?-n:n,q=a/d,r=a%d;const z=r*2nd?q+1n:q%2n===0n?q:q+1n;return sg*z} function scalar(v,def,table){let raw=v,unit=def;if(v&&typeof v==='object'){raw=v.value;unit=v.unit??def}else if(typeof v==='string'){const m=v.trim().match(/^(.+?)\s*(millimeters|millimeter|inches|ticks|inch|lbs|tick|mm|cm|ft|in|mg|kg|oz|lb|g|m)$/i);if(m){raw=m[1];unit=m[2]}}const[n,d]=rat(raw),ticks=Number(round(n*BigInt(table[unit.toLowerCase()]),d));if(ticks<0)throw new RangeError(`${table===WT?'weight':'length'} cannot be negative`);return ticks} function dims(v,u){return [scalar(v.length,u,LEN),scalar(v.width,u,LEN),scalar(v.height,u,LEN)]} -function rotate(d,r){return ROT[r].map(i=>d[i])} +// Three-element literals, not `Array.prototype.map`: an inlined `map` in optimised code builds +// a holey array where the baseline tiers build a packed one, so every consumer of a box's +// sides met two array maps and deoptimised on the second -- the hot candidate sweep included, +// each time costing it another optimising compile. +function rotate(d,r){const order=ROT[r];return [d[order[0]],d[order[1]],d[order[2]]]} +function grown(d,margin){return [d[0]+margin,d[1]+margin,d[2]+margin]} function volume(d){return BigInt(d[0])*BigInt(d[1])*BigInt(d[2])} function intersects(a,b){return a.xb.x&&a.yb.y&&a.zb.z} @@ -796,6 +830,14 @@ function placedHull(placement){ } /** Do two placed items actually overlap? The axis-aligned envelope test is the broad phase and * stays mandatory; this refines its answer only when a hull is one of the two solids. */ +/** `solidsOverlap` for a candidate at (x, y, z) against a placement, without building either + * box when both are plain boxes -- the case of almost every pair the broad phase hands the + * candidate sweep. */ +function solidsOverlapAt(candidateShape,x,y,z,ed,placed){ + const placedShape=placedHull(placed); + if(candidateShape===null&&placedShape===null)return true; + return solidsOverlap(candidateShape,{x,y,z,d:ed},placedShape,{x:placed.x,y:placed.y,z:placed.z,d:placed.ed}); +} function solidsOverlap(leftShape,leftBox,rightShape,rightBox){ if(leftShape===null&&rightShape===null)return true; return hullsCollide( @@ -823,6 +865,27 @@ function occupiedVolume(placement,loadTicks=0){ if(pressureExceeds(pressure,item.maxCompressionKpa))return volume(placement.pd); return BigInt(footprint)*BigInt(effectiveHeight(placement.pd[2],item.compressionPpm,item.maxCompressionKpa,pressure)); } +/** The volume `occupiedVolume` counts for this item in any orientation, before any load. + * + * A proper rotation keeps a hull's volume, so one orientation answers for all; the first + * allowed one is the shape placement has most likely cached already. Exact-small's capacity, + * unused-volume and height floors bound `usedVolume` with this: built from the box volume + * they put two interlocking wedges at twice a crate, pruning the packing that holds both, + * and left the unused-volume floor short of every reachable score. A compressible + * item keeps its declared volume, an upper bound on what it occupies, as in Rust. O(1) + * after the first `shapeFor` per distinct hull. */ +function itemOccupiedVolume(item){ + if(item.shapeType==='convex_hull')return shapeFor(item.hullVertices,item.rots[0]??'LWH').volume; + return volume(item.d); +} +/** How many more containers the plan's remaining items need at least: the largest available + * capacity is granted to every container still to open. A hull counts its own solid, as the + * beam and exact floors do; its box would overstate the need and misrank the plan. + * Nominal volume is not additive under nesting, so that half stands down there. O(n + t). */ +function additionalContainerBound(plan,templates){const available=templates.filter(t=>plan.inventory.get(t.id)>0);if(!plan.remaining.length||!available.length)return 0;let lower=0; + if(!plan.remaining.some(item=>item.nesting!=null)){const capacity=available.reduce((best,t)=>volume(t.d)>best?volume(t.d):best,0n),required=plan.remaining.reduce((sum,item)=>sum+itemOccupiedVolume(item),0n);if(capacity>0n)lower=Math.max(lower,Number((required+capacity-1n)/capacity))} + if(available.every(t=>t.max!=null)){const capacity=Math.max(...available.map(t=>t.max)),required=plan.remaining.reduce((sum,item)=>sum+item.w,0);if(capacity>0)lower=Math.max(lower,Math.ceil(required/capacity))}return lower} +export function __additionalContainerBoundForTests(plan,templates){return additionalContainerBound(plan,templates)} /** First compressible box carrying more pressure than it declared it can take. * * Deliberately shaped like `overloaded` and reading the same propagated loads: the two answer @@ -884,8 +947,11 @@ function usedVolume(placements){ const loads=placements.some(p=>p.item.maxCompressionKpa!=null) ?topLoads(placements.map(constraintBox)):null; let total=placements.reduce((s,p,i)=>s+occupiedVolume(p,loads===null?0:Number(loads[i])),0n),overlap=0n; - for(let i=0;iBigInt(front.max)*reaction.denominator) ||(rear.max!=null&&reaction.rear>BigInt(rear.max)*reaction.denominator)} +// Whether one item, alone in an empty container, has an x-origin that overloads +// neither axle. Each limit is linear in the item's doubled centre, so the feasible origins +// form one interval whose ends are exact BigInt quotients; O(1). Not a proof: a +// counterweight behind the rear axle can relieve the front one. +function floorDivBig(a,b){const q=a/b;return a%b!==0n&&a<0n?q-1n:q} +function axleAdmitsAlone(container,weight,envelopeLength){ + const last=container.d[0]-envelopeLength;if(last<0)return false; + if(container.axleSpec==null)return true; + if(weight<=0)return !axleOverloaded(container,[]); + const [front,rear]=container.axleSpec,w=BigInt(weight),l=BigInt(envelopeLength),tare=BigInt(container.tare); + const total=tare+w,denominator=2n*BigInt(rear.position-front.position),tareMoment=tare*BigInt(container.d[0]); + let lowest=0n,highest=BigInt(last); + if(front.max!=null){const required=2n*total*BigInt(rear.position)-BigInt(front.max)*denominator-tareMoment; + const origin=-floorDivBig(-(-floorDivBig(-required,w)-l),2n);if(origin>lowest)lowest=origin} + if(rear.max!=null){const allowed=BigInt(rear.max)*denominator+2n*total*BigInt(front.position)-tareMoment; + const origin=floorDivBig(floorDivBig(allowed,w)-l,2n);if(originedge<=c.d[k])&&(!c.bag||(d[0]+d[2]<=c.d[0]&&d[1]+d[2]<=c.d[1]))} +// 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, +// else stock or max_containers exhausted for every such container that would take it. +// max_items, cumulative payload, tag_limits, stop access and an axle's share of a full +// load can only refuse an item a fresh container would take, so they surface as the +// inventory. `opened` counts the layout's containers per template, built once; O(C*R). +function limitReason(i,templates,opened,maxContainers,clear){ + const envelopes=uniqueRotations(i.d,i.rots).map(([,d])=>d.map(x=>x+2*clear)); + let compatible=false;const admitting=[]; + for(const c of templates){ + const fitting=envelopes.filter(d=>fitsTemplateAlone(c,d)); + if(!fitting.length||(c.max!=null&&i.w>c.max))continue; + if(i.eligibleTags.length&&!i.eligibleTags.some(tag=>(c.tags??[]).includes(tag)))continue; + compatible=true; + if(fitting.some(d=>axleAdmitsAlone(c,i.w,d[0])))admitting.push(c); + } + if(!compatible)return null; + if(!admitting.length)return 'axle_overloaded'; + let total=0;for(const count of opened.values())total+=count; + if(total>=maxContainers)return 'container_inventory_exhausted'; + return admitting.every(c=>c.quantity!=null&&(opened.get(c.id)??0)>=c.quantity)?'container_inventory_exhausted':null; +} function overlapXY(a,b){const dx=Math.max(0,Math.min(a.x+a.d[0],b.x+b.d[0])-Math.max(a.x,b.x));const dy=Math.max(0,Math.min(a.y+a.d[1],b.y+b.d[1])-Math.max(a.y,b.y));return dx*dy} // `overlapXY` on a placement's own fields: the same arithmetic without first copying the // placement into a `{x,y,z,d}` box, which the candidate sweep did once per comparison. @@ -1005,21 +1112,23 @@ function sameNestingColumn(left,right){const leftNesting=placementNesting(left), // One candidate's exact direct supporters in O(n). A nested predecessor replaces only // shadowed face contacts from its own type/footprint column; unrelated face supporters // retain their original order and semantics. +// Shared by every floor candidate, which rests on nothing; never written to. +const NO_SUPPORTERS=Object.freeze([]); function directSupporters(candidate,placed,topPlane=null){const dimensions=placementDimensions(candidate);let predecessor=null; // With the scene bucketed by top face, a non-nesting candidate reads only the placements // whose top is its own base: no predecessor can exist and no supporter is shadowed, so // the full scan below reduces to its second loop over that one bucket, in the same order. if(topPlane!==null&&placementNesting(candidate)==null){const supporters=[],level=topPlane.get(candidate.z); if(level===undefined)return supporters; - for(const other of level){if(other===candidate)continue; + for(let index=0;index0)supporters.push({placement:other,area})} return supporters} - for(const other of placed){if(other===candidate||other.z>=candidate.z||!sameNestingColumn(other,candidate))continue; + for(let index=0;index=candidate.z||!sameNestingColumn(other,candidate))continue; if(predecessor==null||other.z>=predecessor.z)predecessor=other} if(predecessor!=null&&predecessor.z+placementDimensions(predecessor)[2]-candidate.z!==placementNesting(predecessor))predecessor=null; const supporters=[]; - for(const other of placed){if(other===candidate)continue;const otherDimensions=placementDimensions(other); + for(let index=0;index=-MAX_SAFE_BIGINT&&v<=MAX_SAFE_BIGINT)return safeDecimalString(Number(v),divisor,places)} let ticks=typeof v==='bigint'?v:BigInt(Math.trunc(v));const negative=ticks<0n;if(negative)ticks=-ticks;const div=BigInt(divisor); let whole=ticks/div,remainder=ticks%div,digits=''; for(let i=0;idivisor||(remainder*2===divisor&&lastOdd)){ + const chars=digits.split('');let i=chars.length-1; + for(;i>=0&&chars[i]==='9';i--)chars[i]='0'; + if(i>=0)chars[i]=String(Number(chars[i])+1);else whole+=1; + digits=chars.join('') + } + digits=digits.replace(/0+$/,''); + const text=digits===''?String(whole):`${whole}.${digits}`; + return text==='0'?'0':(negative?'-':'')+text +} function outLength(v,u){return {ticks:v,value:decimalString(v,LEN[u]),unit:u}} function outDims(d,u){return {length:outLength(d[0],u),width:outLength(d[1],u),height:outLength(d[2],u)}} function outPoint(p,u){return {x:outLength(p.x,u),y:outLength(p.y,u),z:outLength(p.z,u)}} @@ -1078,6 +1209,7 @@ export const REASON_MESSAGES=Object.freeze({ search_exhausted:'was not placed before the configured search strategies were exhausted', exact_search_incomplete:'was not placed because the exact search ended before proving a final answer', container_inventory_exhausted:'requires another compatible container, but the declared inventory is exhausted', + axle_overloaded:'would overload an axle wherever it stood alone in any container that otherwise admits it', }); export function explainReason(reason){ const message=REASON_MESSAGES[reason]; @@ -1213,18 +1345,18 @@ function routeContactAllowed(candidate,placed,supports,sweep=null){ // already valid; the one same-column face above may need an O(n) predecessor lookup // to distinguish a real face support from a shadowed non-adjacent nested contact. const candidateStop=candidate.item.stopIndex??Infinity; - if(supports.some(({placement})=>candidateStop>(placement.item.stopIndex??Infinity)))return false; + // Every comparison below is `stop > Infinity` when nothing on the route declares a stop, + // so the rule cannot refuse and the scan is skipped for the request that has no route at + // all -- which is every request that is not a multi-drop route. + if(sweep!==null&&candidateStop===Infinity&&!sweep.placedStops)return true; + for(let index=0;index(supports[index].placement.item.stopIndex??Infinity))return false; const dimensions=placementDimensions(candidate);let scene=null; if(sweep!==null){ - // Every comparison below is `stop > Infinity` when nothing on the route declares a - // stop, so the rule cannot refuse and the scan is skipped for the request that has no - // route at all -- which is every request that is not a multi-drop route. - if(candidateStop===Infinity&&!sweep.placedStops)return true; // A non-nesting candidate is never a nested predecessor, so only the placements whose // base is its top can rest on it: read that one bucket instead of the whole scene. if(candidate.item.nesting==null){const level=sweep.bottomPlane().get(candidate.z+dimensions[2]); - if(level!==undefined)for(const upper of level) - if(footprintOverlap(upper,candidate.x,candidate.y,dimensions[0],dimensions[1])>0&&(upper.item.stopIndex??Infinity)>candidateStop)return false; + if(level!==undefined)for(let index=0;index0&&(upper.item.stopIndex??Infinity)>candidateStop)return false} return true} } for(const upper of placed){const upperDimensions=placementDimensions(upper),upperStop=upper.item.stopIndex??Infinity; @@ -1340,6 +1472,20 @@ function sweptHits([sx1,sy1,sz1,sx2,sy2,sz2],box){ return sx1other.tags.includes(t))||other.incompatible.some(t=>tags.includes(t)))tagConflict=true; - if(!other.stackable)anyUnstackable=true; + let tagConflict=false,anyRefusesLoad=false,placedMaxTop=false,placedCompressible=false,placedMaxStacked=false,placedStops=false; + for(let index=0;indexp.z+placementDimensions(p)[2]);return byTop}, bottomPlane(){if(byBottom===null)byBottom=bucketByPlane(placed,p=>p.z);return byBottom}}} function bucketByPlane(placed,planeOf){const buckets=new Map(); - for(const p of placed){const plane=planeOf(p),bucket=buckets.get(plane);if(bucket)bucket.push(p);else buckets.set(plane,[p])} + for(let index=0;index0)return false} - if(!candidate.item.stackable){const level=sweep.bottomPlane().get(box.z+box.d[2]); - if(level!==undefined)for(const p of level)if(footprintOverlap(p,box.x,box.y,box.d[0],box.d[1])>0)return false} + if(scene.anyRefusesLoad){const level=sweep.topPlane().get(box.z); + if(level!==undefined)for(let index=0;index0)return false}} + if(refusesLoad(candidate.item)){const level=sweep.bottomPlane().get(box.z+box.d[2]); + if(level!==undefined)for(let index=0;index0)return false}} } metrics.support_checks++; const ratio=Math.max(globalSupportPpm,candidate.item.supportPpm); - const supports=box.z===0?[]:directSupporters(candidate,placed,sweep===null?null:sweep.topPlane()); - if(supports.some(({placement})=>placement.item.stackable===false))return false; + const supports=box.z===0?NO_SUPPORTERS:directSupporters(candidate,placed,sweep===null?null:sweep.topPlane()); + for(let index=0;index0){ const area=supports.reduce((total,support)=>total+support.area,0); if(areatotal+support.area,0); + const dimensions=placementDimensions(placement),area=directSupporters(placement,placed,topPlane).reduce((total,support)=>total+support.area,0); return area/(dimensions[0]*dimensions[1]); } +// The leading `maximum_volume` key: the share of the requested nominal item volume left +// unpacked, in parts per million. Rounded up so an incomplete answer never scores zero, +// and a ratio rather than the raw volume because cubic ticks pass 2^53 for an ordinary +// container -- which is also why both operands arrive as BigInt. +function unpackedVolumePpm(unpackedVolume,requestedVolume){return requestedVolume>0n?Number((unpackedVolume*1000000n+requestedVolume-1n)/requestedVolume):0} function compareScore(left,right){for(let i=0;i0 - ||Object.keys(container.tag_limits??{}).length||container.max_stack_density!=null)return null; + ||Object.keys(container.tag_limits??{}).length||container.max_stack_density!=null + // The lattice counts cells against the rigid frame alone; a bag's girth rule couples + // 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; const inner=dims(container.inner_dimensions,u),outer=container.outer_dimensions?dims(container.outer_dimensions,u):inner; templates.push({...container,d:inner,outerD:outer,max:container.max_payload==null?null:scalar(container.max_payload,'g',WT), tare:scalar(container.tare_weight??0,'g',WT),rate:parseRateTable(container.rate_table)}) @@ -1610,7 +1783,9 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver const envelope=physical.map(edge=>edge+2*clear),nx=Math.floor(template.d[0]/envelope[0]),ny=Math.floor(template.d[1]/envelope[1]); let nz=Math.floor(template.d[2]/envelope[2]); if(nx>0&&ny>0&&nz>0)dimensionsFitSomewhere=true; - if(raw.must_be_on_floor||raw.stackable===false)nz=Math.min(nz,1); + // Every lattice edge joins two items of this one type, so the stacking-group rule + // holds for the whole lattice exactly when the type carries itself. + if(raw.must_be_on_floor||!carriesItself(raw))nz=Math.min(nz,1); if(raw.max_top_load!=null&&weight>0){ const safeLayers=BigInt(scalar(raw.max_top_load,'g',WT))/BigInt(weight)+1n; if(safeLayers>>0||0x9e3779b1; @@ -1806,6 +1985,21 @@ function seededOrder(items,seed,startIndex){ // A portfolio's reported effort is what the whole portfolio spent, not what its winner // spent. Each run here is a separate `packFallback` call with its own metrics closure, so // without this the record understates the work by the number of runs. +// The counters each effort limit caps, as `effortExceeded` reads them. +const EFFORT_COUNTERS=[['max_search_nodes','search_nodes_expanded'],['max_candidates_evaluated','feasible_candidates'],['max_placement_attempts','orientations_considered']]; +/** What the request's effort budget leaves after `runs`; a spent limit is 0. + * + * The budget belongs to the request, not to each start: Python, PHP and Rust share it across + * the portfolio, so a later start gets only what the earlier ones left, and one left with + * nothing still starts and stops at its first counted step, as in Python. Each start used to + * get the whole budget, which went unseen while `balanced` ran one ordering. O(r). */ +function remainingEffort(effort,runs){ + const left={...effort}; + for(const [limit,counter] of EFFORT_COUNTERS) + if(effort[limit]!=null)left[limit]=Math.max(0,effort[limit]-runs.reduce((total,run)=>total+run.algorithm.metrics[counter],0)); + return left +} +const withEffort=(request,effort)=>effort==null?request:{...request,configuration:{...(request.configuration??{}),effort_budget:effort}}; function withPortfolioEffort(winner,runs){ const metrics={}; for(const key of Object.keys(winner.algorithm.metrics)) @@ -1822,6 +2016,136 @@ function unstartedRecord(solverAlias,index,globalDeadlineReached){return { id:startRecordId(solverAlias,index),started:false,completed:false,truncated:false,selected:false, global_deadline_reached:globalDeadlineReached, }} +// A portfolio's start records are its runs' own, in run order: only the winning run keeps +// its selected start, and a deadline that passed during a later run is every run's. +function portfolioStarts(runs,winnerIndex,globalDeadlineReached){ + return runs.flatMap((run,index)=>run.termination.starts.map(start=>({...start, + selected:index===winnerIndex&&start.selected, + global_deadline_reached:start.global_deadline_reached||globalDeadlineReached, + }))) +} +// Lattice nodes the block start added to `fast` and `balanced` may enumerate per mode -- +// the bound `quality` gives the same solver through `container_plan_node_limit`, which +// those two profiles leave at one: too few for the enumeration to reach its first block. +const BLOCK_START_NODE_LIMIT=100000; +// Lattice nodes between two clock reads of the block enumeration. +const BLOCK_CLOCK_PERIOD=64; +// One (space, type, rotation) group of the block enumeration visits every (nx, ny) with +// nx <= maximumNx and nx*ny <= capacity, ny <= maximumY, one node each, in nx-major order. +// The three functions below replace that per-node walk without changing what it decides: +// how many nodes the group holds, the node a counted limit or the clock stops it at, and +// the group's first largest count. O(maximumNx) for the first, O(1 + clock reads) for the +// second, at most the group's own nodes for the third. +function blockGroupNodes(maximumNx,maximumY,capacity){let nodes=0; + for(let nx=1;nx<=maximumNx;nx++)nodes+=Math.min(maximumY,Math.floor(capacity/nx)); + return nodes} +// The node a walk of `groupNodes` nodes from `nodes` stops at, or -1 if it walks them all. A +// node at or past `stopAt`, where the counted limits trip, stops it before the clock is read; +// before that the clock is read at every node whose index is a multiple of the period, as the +// per-node walk read it, so a wall-clock stop can only land on the nodes it could before. +function blockStopNode(nodes,groupNodes,stopAt,deadline){const end=nodes+groupNodes,counted=Math.min(end,stopAt); + for(let tick=nodes+(BLOCK_CLOCK_PERIOD-nodes%BLOCK_CLOCK_PERIOD)%BLOCK_CLOCK_PERIOD;tickstopAt?stopAt:-1} +// The group's first largest count `nx*ny*nz`, nz = min(maximumZ, floor(capacity/(nx*ny))). +// `upper` bounds every count in the group, min(capacity, maximumNx*maximumY*maximumZ), and +// a later node replaces the incumbent only when strictly larger, so the walk ends at the +// first node that reaches it. +function blockGroupBest(maximumNx,maximumY,maximumZ,capacity,upper){let count=0,bestNx=0,bestNy=0,bestNz=0; + walk:for(let nx=1;nx<=maximumNx;nx++)for(let ny=1,maximumNy=Math.min(maximumY,Math.floor(capacity/nx));ny<=maximumNy;ny++){ + const nz=Math.min(maximumZ,Math.floor(capacity/(nx*ny))); + if(nx*ny*nz>count){count=nx*ny*nz;bestNx=nx;bestNy=ny;bestNz=nz;if(count===upper)break walk}} + return {count,nx:bestNx,ny:bestNy,nz:bestNz}} +// Fields of one type's entry in a space's tally: the capacity it was counted for, the nodes +// of all its groups, its largest count, and the lattice and extents of the group that leads. +const BLOCK_TALLY_FIELDS=7; +// Every group of one type in one space at one capacity: the nodes they hold together and the +// group that leads among them. Inside one type and space the key's lead (count, fill and +// volume) is decided by the count alone, the type and the anchor are shared, and the +// rotations are distinct, so the leader is the largest count and, among equal counts, the +// rotation that sorts first; inside a group it is the group's first largest count. Nothing +// here depends on the incumbent, so it holds for the space until the capacity moves. +// O(R*maximumNx) plus the groups' own nodes for the counts. +function blockTypeTally(space,lattices,capacity,tally,offset){let nodes=0,count=0,leader=-1,bestNx=0,bestNy=0,bestNz=0; + for(let index=0;indexspace.d[0]||ed[1]>space.d[1])continue; + const maximumNx=Math.min(Math.floor(space.d[0]/ed[0]),capacity),maximumY=Math.floor(space.d[1]/ed[1]),maximumZ=Math.floor(space.d[2]/ed[2]); + nodes+=blockGroupNodes(maximumNx,maximumY,capacity); + // No count in the group exceeds `upper`: a group bounded below the leader cannot lead. + const upper=Math.min(capacity,maximumNx*maximumY*maximumZ);if(upper===0||uppercount||group.count===count&&compareCodePoints(lattices[index].rotation,lattices[leader].rotation)<0){ + count=group.count;leader=index;bestNx=group.nx;bestNy=group.ny;bestNz=group.nz}} + tally[offset]=capacity;tally[offset+1]=nodes;tally[offset+2]=count;tally[offset+3]=leader;tally[offset+4]=bestNx;tally[offset+5]=bestNy;tally[offset+6]=bestNz} +// A free space of the block enumeration: its exact volume, and the tally `blockTypeTally` +// keeps for it while it survives. +function blockSpace(x,y,z,d){return {x,y,z,d,volume:volume(d),tally:null}} +const compareBlockValue=(a,b)=>typeof a==='bigint'?(ab?1:0):typeof a==='string'?compareCodePoints(a,b):a-b; +const compareBlockKey=(a,b)=>{for(let i=0;i{const used=BigInt(block.count)*block.volume,fill=used*1000000n/block.space.volume; + const lead=volumeFirst?[-used,-fill,-BigInt(block.count)]:[-BigInt(block.count),-fill,-used]; + return [...lead,block.space.z,block.space.y,block.space.x,block.itemId,block.rotation,block.nx,block.ny,block.nz]}; +// Whether a block ranks strictly before the incumbent under the exact key. A count, or a +// float volume with a relative error near 1e-16, already orders most pairs; only a pair +// that lead cannot separate pays for both exact BigInt keys, so the answer is unchanged. +const blockLeads=(candidate,best,volumeFirst)=>{ + if(!volumeFirst&&candidate.count!==best.count)return candidate.count>best.count; + if(volumeFirst&&candidate.approximateUsed>best.approximateUsed*(1+BLOCK_FLOAT_MARGIN))return true; + if(best.key==null)best.key=blockKey(best,volumeFirst); + return compareBlockKey(blockKey(candidate,volumeFirst),best.key)<0}; +// Whether the cheap lead alone already places a block after the incumbent. +const blockTrails=(count,approximateUsed,best,volumeFirst)=>volumeFirst?approximateUsed=inner.x+inner.d[0]&&outer.y+outer.d[1]>=inner.y+inner.d[1]&&outer.z+outer.d[2]>=inner.z+inner.d[2]} +// `spaces` is containment-free (a single whole space or this function's own output), +// so a survivor untouched by the carve can neither contain nor be contained by +// another survivor, and a new slab cannot contain one either (the slab's own parent +// could not); only new slabs need the dominance scan: O(new*s) instead of O(s^2). +// For the same reason no slab repeats a survivor (it would be contained in its parent), so +// only slabs are keyed to drop repeats, the first of equal slabs keeping its place. +function subtractBlock(spaces,box,metrics){metrics.space_partitions+=spaces.length;const out=[],fresh=new Set(),slabs=new Set(); + const push=(x,y,z,X,Y,Z)=>{if(X>x&&Y>y&&Z>z){const key=`${x}:${y}:${z}:${X-x}:${Y-y}:${Z-z}`;if(slabs.has(key))return;slabs.add(key); + const part=blockSpace(x,y,z,[X-x,Y-y,Z-z]);out.push(part);fresh.add(part)}}; + for(const space of spaces){const X=space.x+space.d[0],Y=space.y+space.d[1],Z=space.z+space.d[2],bX=box.x+box.d[0],bY=box.y+box.d[1],bZ=box.z+box.d[2]; + if(!intersects(space,box)){out.push(space);continue} + push(space.x,space.y,space.z,Math.min(X,box.x),Y,Z);push(Math.max(space.x,bX),space.y,space.z,X,Y,Z); + push(space.x,space.y,space.z,X,Math.min(Y,box.y),Z);push(space.x,Math.max(space.y,bY),space.z,X,Y,Z); + push(space.x,space.y,space.z,X,Y,Math.min(Z,box.z));push(space.x,space.y,Math.max(space.z,bZ),X,Y,Z)} + // In this order a space that contains another sorts before it: its origin is no greater + // on every axis, and at an equal origin its volume is larger (no two spaces here are equal), + // so a slab is tested only against the spaces ahead of it. + const kept=out.sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||(a.volume>b.volume?-1:1)); + let result=kept.filter((space,index)=>!fresh.has(space)||!containedByEarlier(kept,index)); + if(result.length>256)result=result.sort((a,b)=>a.volume>b.volume?-1:a.volumea.z-b.z||a.y-b.y||a.x-b.x); + return result} +// The result writer's shortcuts, held to the code paths they bypass by +// `test/result-writer.test.mjs`. +export const __resultWriterForTests=Object.freeze({decimalString,usedVolume,validNesting,occupiedVolume}); +export const __blockGroupForTests=Object.freeze({nodes:blockGroupNodes,stop:blockStopNode,best:blockGroupBest,typeTally:blockTypeTally, + space:blockSpace,subtract:subtractBlock}); // The engine's own unit tables and parser, handed to the preflight so a measure means there // exactly what it means when the model is built. const MEASURES=Object.freeze({length:{units:LEN,parse:(value,unit)=>scalar(value,unit,LEN)},weight:{units:WT,parse:(value,unit)=>scalar(value,unit,WT)}}); @@ -1839,7 +2163,23 @@ export function packFallback(req,clock=Date.now,solverAlias=null,startIndex=null if(!isObject(req))throw new InvalidRequestError('wrong_type','','must be an object'); rejectUnsupported(req); checkRequest(req,MEASURES); - return solveChecked(req,clock,solverAlias,startIndex,sharedDeadline); + const result=solveChecked(req,clock,solverAlias,startIndex,sharedDeadline); + warnUnsupportedPlacements(result,req.configuration?.minimum_support_ratio??0); + return result; +} +// The default minimum_support_ratio of 0 is kept so that no packing answer changes, +// and a caller who never set it is told instead. The count reads the support ratio every +// placement already reports, O(placements) with no geometry: an obstacle is geometry only +// and gives no support, so an item on one counts, while a fixed placement is a supporter +// like any other. A lattice summary is always floor-up and has no placements. Every engine +// emits this text byte for byte; each alternative is a result and carries its own. +function warnUnsupportedPlacements(result,minimumSupportRatio){ + for(const alternative of result.alternatives??[])warnUnsupportedPlacements(alternative,minimumSupportRatio); + if(minimumSupportRatio!==0)return; + let count=0; + for(const container of result.containers)for(const placement of container.placements??[]) + if(Number(placement.support_ratio)===0&&placement.position.z.ticks>0)count++; + if(count>0)result.warnings=[...result.warnings,`unsupported_placements: ${count} placement(s) rest on nothing above the container floor; set configuration.minimum_support_ratio above 0 to require support`]; } /** * The rule table alone, for a caller about to hand the request to the native engine: the @@ -1862,8 +2202,10 @@ function asRequestError(error){ } // One frame of the solve: the outermost call, a portfolio member or one start of a multi-start // run. Every frame reads the request into its model before it searches; `phase` is shared by -// all frames of one call and flips when the first search begins. -function solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,phase){ +// all frames of one call and flips when the first search begins. `addedBlockStart` marks the +// frame as the block start `fast` and `balanced` add to their own; such a frame answers +// null, before it searches, for a request the lattice cannot express in full. +function solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,phase,addedBlockStart=false){ // Admit the doors here, beside the other request-admission checks, and not // where they are canonicalised. The container template is built after the // uniform-lattice fast path has already returned, so validating there let a request @@ -1882,9 +2224,14 @@ const requestedItemCount=(req.items??[]).reduce((total,item)=>total+(item.quanti if(requestedSolvers.includes('exact_small')&&requestedItemCount>exactItemLimit)throw new RangeError('exact-small item limit exceeded'); const effort=req.configuration?.effort_budget??null; // A null limit is an absent one, as in every other engine (docs/ERRORS.md). -for(const [name,value] of Object.entries(effort??{}))if(value!=null&&(!Number.isSafeInteger(value)||value<=0))throw new RangeError(`effort_budget.${name} must be a positive safe integer`); -const multiStartOrders=req.configuration?.multi_start_orders??1; -if(!Number.isSafeInteger(multiStartOrders)||multiStartOrders<1)throw new RangeError('multi_start_orders must be a positive safe integer'); +// Only the request's own budget is checked: a start inside the portfolio may be handed a +// spent limit of 0 by `remainingEffort`. +if(sharedDeadline===null)for(const [name,value] of Object.entries(effort??{}))if(value!=null&&(!Number.isSafeInteger(value)||value<=0))throw new RangeError(`effort_budget.${name} must be a positive safe integer`); +const requestedOrders=req.configuration?.multi_start_orders??8; +if(!Number.isSafeInteger(requestedOrders)||requestedOrders<1)throw new RangeError('multi_start_orders must be a positive safe integer'); +// Eight orderings and one under `fast`, as in Python, PHP and Rust. The default was 1 until +// 1.6.0: faster, but `balanced` and `quality` never tried a large-items-first ordering. +const multiStartOrders=(req.configuration?.solver_profile??'balanced')==='fast'?1:requestedOrders; const restartLimit=effort?.max_restarts??Number.MAX_SAFE_INTEGER; // Every recursive solver/start shares one absolute deadline. Resetting it per child made // a k-start request consume up to k*time_limit_ms while still reporting one portfolio @@ -1895,15 +2242,41 @@ const deadline=sharedDeadline??new Deadline(req.configuration?.time_limit_ms??10 // Rust, Python and PHP refuse at. A child solver/start run instead hands its result // back sentinel and all, so a portfolio sibling with a priceable answer is not aborted // by one run's refusal. Idempotent: the quality re-entry finalizes inside its callee. -const finalizeOutermost=result=>{ - if(solverAlias!==null||startIndex!==null)return result; +const profile=req.configuration?.solver_profile??'balanced'; +// Single-type blocks fill a carton load in milliseconds where a per-item search spends its +// whole budget, so the low-latency profiles run them as one more start and let the score +// decide. It runs last, so a restart budget drops it before any of the profile's own +// starts; one item type is the uniform lattice's case, which is exact there. +const addsBlockStart=solverAlias===null&&startIndex===null&&requestedSolvers.length===0&&(profile==='fast'||profile==='balanced') + &&restartLimit>multiStartOrders&&new Set((req.items??[]).map(item=>item.id)).size>1; +// The start is there to improve an answer, not to replace an equal one: a lattice knows +// nothing of the ordering the profile's own starts honour, so on a tie or worse its +// packing is dropped and only the record that it ran, and what it spent, is kept. A start +// the deadline left no time for is recorded as never started, like any other restart. +const withAddedBlockStart=own=>{ + const late=deadline.expired(),left=effort==null?null:remainingEffort(effort,[own]); + const blocks=solveFrame(withEffort({...req,configuration:{...(req.configuration??{}),multi_start_orders:1}},left),clock,'homogeneous_blocks',null,deadline,phase,true); + if(blocks===null)return own; + const runs=late?[own]:[own,blocks],winnerIndex=!late&&compareScore(blocks.score,own.score)<0?1:0,winner=runs[winnerIndex],globalDeadlineReached=deadline.expired(); + const starts=portfolioStarts(runs,winnerIndex,globalDeadlineReached);if(late)starts.push(unstartedRecord('homogeneous_blocks',0,globalDeadlineReached)); + winner.termination=aggregateTermination(starts); + winner.algorithm=withPortfolioEffort(winner,runs); + if(winner.algorithm.effort_limit_reached&&!winner.algorithm.time_limit_reached&&!globalDeadlineReached)winner.termination.code='effort_limit'; + // The profile's own answer is a real alternative to a winning lattice, as a losing + // solver's is in the quality portfolio; the sentinel never is (review). + if(winnerIndex===1&&!own.unpriceableDetail&&(req.configuration?.alternatives??3)>1)winner.alternatives=[own]; + return winner +}; +const finalizeOutermost=answer=>{ + if(solverAlias!==null||startIndex!==null)return answer; + const result=addsBlockStart?withAddedBlockStart(answer):answer; if(result.unpriceableDetail!=null)throw unpriceableRefusal(result.unpriceableDetail); // Belt and braces behind the portfolio branch's own filter: a sentinel-scored run // must never leave the outermost frame by any route. if(result.alternatives?.length)result.alternatives=result.alternatives.filter(a=>!a.unpriceableDetail); return result }; -if(solverAlias===null&&requestedSolvers.length===0&&(req.configuration?.solver_profile??'balanced')==='quality'){ +if(solverAlias===null&&requestedSolvers.length===0&&profile==='quality'){ const child={...req,configuration:{...(req.configuration??{}),solvers:['homogeneous_blocks','extreme_points','maximal_spaces','layer']}}; return solveFrame(child,clock,null,null,deadline,phase) } @@ -1911,22 +2284,22 @@ if(solverAlias===null&&requestedSolvers.length){ let remainingStarts=restartLimit; const plans=[]; for(const name of requestedSolvers){ - const count=Math.min(name==='homogeneous_blocks'?1:multiStartOrders,remainingStarts); + // A lattice and the layer start sort by their own key, so a second ordering repeats the + // first, as Python's order-insensitive solvers do. + const count=Math.min(ORDER_INSENSITIVE_SOLVERS.has(name)?1:multiStartOrders,remainingStarts); if(count<1)break; plans.push({name,count});remainingStarts-=count; } const runs=[]; for(const plan of plans){ if(runs.length&&deadline.expired())break; - const childRequest={...req,configuration:{...(req.configuration??{}),solvers:[],multi_start_orders:plan.count}}; + const left=effort==null?null:remainingEffort(effort,runs); + const childRequest=withEffort({...req,configuration:{...(req.configuration??{}),solvers:[],multi_start_orders:plan.count}},left); runs.push(solveFrame(childRequest,clock,plan.name,null,deadline,phase)); } let winnerIndex=0;for(let index=1;indexrun.termination.starts.map(start=>({...start, - selected:index===winnerIndex&&start.selected, - global_deadline_reached:start.global_deadline_reached||globalDeadlineReached, - }))); + const starts=portfolioStarts(runs,winnerIndex,globalDeadlineReached); for(const plan of plans.slice(runs.length)) for(let index=0;index1){ const plannedStarts=Math.min(multiStartOrders,restartLimit),runs=[]; - const stagedPlanSearch=(req.configuration?.solver_profile??'balanced')==='quality'&&(req.configuration?.container_plan_beam_width??16)>1; + const stagedPlanSearch=profile==='quality'&&(req.configuration?.container_plan_beam_width??16)>1; for(let index=0;index1){ return finalizeOutermost(winner); } const u=req.units?.length??'mm',ou=req.output?.length_unit??u,ow=req.output?.weight_unit??'g',clear=scalar(req.configuration?.clearance??0,u,LEN); -const objective=req.configuration?.objective??'default';if(!['default','lowest_cost','shipping_cost','lowest_landed_cost','open_dimension_height','maximum_value'].includes(objective))throw new RangeError(`unknown objective ${JSON.stringify(objective)}`); +const objective=req.configuration?.objective??'default';if(!['default','lowest_cost','shipping_cost','lowest_landed_cost','open_dimension_height','maximum_value','maximum_volume'].includes(objective))throw new RangeError(`unknown objective ${JSON.stringify(objective)}`); +// `maximum_volume` fills space before it counts pieces, so every comparison below that +// leads with a piece count leads with placed volume instead when this is set. +const byVolume=objective==='maximum_volume'; const dimDivisor=req.configuration?.dimensional_weight_divisor??null,dimLengthUnit=req.configuration?.dimensional_weight_length_unit??'in',dimWeightUnit=req.configuration?.dimensional_weight_weight_unit??'lb'; if(dimDivisor!=null&&(!Number.isSafeInteger(dimDivisor)||dimDivisor<=0))throw new RangeError('dimensional_weight_divisor must be a positive safe integer'); // Both objectives price the same billed weight, so both need the divisor. There is no @@ -1979,10 +2356,15 @@ if(objective==='lowest_landed_cost'){ if(!['mm','cm','m','in','ft'].includes(dimLengthUnit))throw new RangeError('dimensional_weight_length_unit must be mm, cm, m, in or ft'); if(!['mg','g','kg','oz','lb'].includes(dimWeightUnit))throw new RangeError('dimensional_weight_weight_unit must be mg, g, kg, oz or lb'); const dimensionalWeight=d=>Number(volume(d)*BigInt(WT[dimWeightUnit])/(BigInt(LEN[dimLengthUnit])**3n*BigInt(dimDivisor??1))); +// 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) + :placements.length?dimensionalWeight(bagExtent(placements)):0; const globalSupportRatio=req.configuration?.minimum_support_ratio??0; const globalSupportPpm=globalSupportRatio>0?Math.max(1,Math.round(globalSupportRatio*SUPPORT_SCALE)):0; const maxCandidatePoints=req.configuration?.max_candidate_points??4096; -const qualityProfile=(req.configuration?.solver_profile??'balanced')==='quality'; +const qualityProfile=profile==='quality'; const maxCandidatesPerItem=req.configuration?.max_candidates_per_item??(qualityProfile?16:1); const containerPlanBeamWidth=req.configuration?.container_plan_beam_width??(qualityProfile?16:1); const containerPlanNodeLimit=req.configuration?.container_plan_node_limit??(qualityProfile?100000:1); @@ -2024,17 +2406,19 @@ const policyRules=parsePolicy(req.policy); // registered policy rule. const compact=(policyRules.length||objective==='lowest_landed_cost')?null:compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortExceeded,effortRemaining,deadline}); if(compact!==null)return compact; -const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scalar(raw.weight??0,'g',WT),rots=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)),nesting=raw.nesting_height==null?null:scalar(raw.nesting_height,u,LEN); +const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scalar(raw.weight??0,'g',WT),rots=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)),nesting=raw.nesting_height==null?null:scalar(raw.nesting_height,u,LEN),accepts=acceptedStackingGroups(raw); // The ordering keys below are functions of the item alone, computed once per type here // rather than as fresh BigInts on both sides of every comparison the sort makes. - const vol=volume(d),longest=Math.max(...d); - for(let i=1;i<=(raw.quantity??1);i++)items.push({raw,d,w,rots,vol,longest,id:`${raw.id}#${i}`, + const vol=volume(d),longest=Math.max(...d),base=BigInt(d[0])*BigInt(d[1]); + for(let i=1;i<=(raw.quantity??1);i++)items.push({raw,d,w,rots,vol,longest,base,id:`${raw.id}#${i}`, stackable:raw.stackable!==false,maxTop:raw.max_top_load==null?null:scalar(raw.max_top_load,'g',WT), supportPpm:Math.round((raw.minimum_support_ratio??0)*SUPPORT_SCALE),priority:raw.priority??0, tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null, nesting,maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null, stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],value:raw.value??0, + stackingGroup:raw.stacking_group??null,accepts, ...parseShape(raw,d,u,nesting)})} +const requestedVolume=items.reduce((sum,item)=>sum+item.vol,0n),nominalVolume=list=>list.reduce((sum,item)=>sum+item.vol,0n); // Priority is a preference, not a guarantee: it leads the ordering so a caller can // bias the search, but ties (the default, priority 0 for all items) fall through to // the volume key unchanged. @@ -2044,7 +2428,16 @@ const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scal // ASCII. Only the sign of a key matters to the sort, so the volume key compares BigInts // directly instead of materialising their difference. const compareId=compareCodePoints,ascendingVolume=(a,b)=>a.volb.vol?1:0; -items.sort((a,b)=>{ +// Small items first places the most pieces; large items first fills the most space. The +// quality profile opens with its two small-first orderings, so under `maximum_volume` the +// large-first ordering at the bottom takes the first start and those two follow it. +const smallFirstStart=qualityProfile?(startIndex??0)-(byVolume?1:0):null; +// Whether the route unloads through -x alone: a property of the request, read once rather +// than by every comparison of the sort below. +const unloadsMinusX=(req.containers??[]).some(c=>(c.access_directions??[]).includes("-x")&&!(c.access_directions??[]).includes("+x")); +// The keys every ordering leads with, a shuffled one included, as Python's `ordering_lead`: +// priority, then value under `maximum_value`, then the route's stop order. +const leadOrder=(a,b)=>{ const priority=b.priority-a.priority;if(priority)return priority; // Under `maximum_value` the second objective key is the value left behind, so the // most valuable item must get first refusal on the space. This single pass has no @@ -2056,20 +2449,29 @@ items.sort((a,b)=>{ // must be loaded to end up underneath. An item with no stop rides the whole route and // loads with the last one. Every stop is Infinity when nothing declares one, so an // unrouted request keeps the ordering below untouched. - const minusX=(req.containers??[]).some(c=>(c.access_directions??[]).includes("-x")&&!(c.access_directions??[]).includes("+x"));{const stop=minusX?((a.stopIndex??Infinity)-(b.stopIndex??Infinity)):((b.stopIndex??Infinity)-(a.stopIndex??Infinity));if(stop)return stop} - if(qualityProfile&&(startIndex===null||startIndex===0))return a.longest-b.longest||ascendingVolume(a,b)||compareId(a.id,b.id); - if(qualityProfile&&startIndex===1)return ascendingVolume(a,b)||a.longest-b.longest||compareId(a.id,b.id); - if(solverAlias==='layer')return (b.d[2]-a.d[2])||(b.d[0]*b.d[1]-a.d[0]*a.d[1])||compareId(a.id,b.id); + {const stop=unloadsMinusX?((a.stopIndex??Infinity)-(b.stopIndex??Infinity)):((b.stopIndex??Infinity)-(a.stopIndex??Infinity));if(stop)return stop} + return 0}; +items.sort((a,b)=>{ + const lead=leadOrder(a,b);if(lead)return lead; + // The layer start keeps its own key in every profile, as Python's and Rust's + // order-insensitive layer solvers do. Under `quality` it used to take the small-first + // ordering every other start there takes, so no start tried large items first. + if(solverAlias==='layer')return (b.basea.base?1:0)||ascendingVolume(b,a)||(b.w-a.w)||compareId(a.id,b.id); + if(smallFirstStart===0)return a.longest-b.longest||ascendingVolume(a,b)||compareId(a.id,b.id); + if(smallFirstStart===1)return ascendingVolume(a,b)||a.longest-b.longest||compareId(a.id,b.id); if(solverAlias==='maximal_spaces')return (b.longest-a.longest)||ascendingVolume(b,a)||compareId(a.id,b.id); // `exact_small` deliberately has no ordering of its own. It used to sort by id, which // was harmless while it was greedy-in-disguise and actively harmful once the search // became real: smallest-first is the worst descent order, so the first branch failed to // pack everything and the bound never pruned. - return ascendingVolume(b,a)||compareId(a.id,b.id); + // Equal volumes tie on the longest edge, then the heavier item, as Python's `volume` + // order does; an id tie-break let a name decide which item took the last floor cell. + return ascendingVolume(b,a)||(b.longest-a.longest)||(b.w-a.w)||compareId(a.id,b.id); }); // Start 0 is the ordering above, so a single-start request is byte-identical to what it -// produced before restarts existed; every later start re-solves a shuffle of it. -if(startIndex!==null&&startIndex>0&&(!qualityProfile||startIndex>2))items.splice(0,items.length,...seededOrder(items,req.configuration?.seed??42,startIndex)); +// produced before restarts existed; every later start re-solves a shuffle of it that keeps +// the lead keys, so a shuffle never puts a low-priority item ahead of a high-priority one. +if(startIndex!==null&&startIndex>0&&(!qualityProfile||startIndex>2))items.splice(0,items.length,...seededOrder(items,req.configuration?.seed??42,startIndex).sort(leadOrder)); const templates=req.containers.map(c=>{const d=dims(c.inner_dimensions,u),axleSpec=c.axles==null?null:c.axles.map(a=>({position:scalar(a.position,u,LEN),max:a.max_load==null?null:scalar(a.max_load,'g',WT)})); 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=Math.round((c.void_fill_reserve_ratio??0)*SUPPORT_SCALE); @@ -2085,7 +2487,7 @@ const templates=req.containers.map(c=>{const d=dims(c.inner_dimensions,u),axleSp // Python, PHP and Rust decoders apply, and the reason all four agree on the answer. validateDirections(c.access_directions??[]); const doors=Object.freeze(ALL_DIRECTIONS.filter(d=>(c.access_directions??[]).includes(d))); - return {...c,d,outerD,doors,max:c.max_payload==null?null:scalar(c.max_payload,'g',WT),tare:scalar(c.tare_weight??0,'g',WT),axleSpec,reservePpm, + return {...c,d,outerD,doors,bag:c.deformable_boundary!=null,machine:parseMachine(c.made_to_measure,u),max:c.max_payload==null?null:scalar(c.max_payload,'g',WT),tare:scalar(c.tare_weight??0,'g',WT),axleSpec,reservePpm, innerVolume:volume(d),reserve:volume(d)*BigInt(reservePpm)/BigInt(SUPPORT_SCALE), rate:parseRateTable(c.rate_table),tagLimits:c.tag_limits??{},maxStackDensity, obs:(c.obstacles??[]).flatMap(o=>[o,...(o.additional_boxes??[])]).map(o=>({x:scalar(o.origin?.x??0,u,LEN),y:scalar(o.origin?.y??0,u,LEN),z:scalar(o.origin?.z??0,u,LEN),d:dims(o.dimensions,u)}))}}).sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost'?(dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))):((a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d)))); @@ -2127,7 +2529,7 @@ const batchesOf=groupBatches; // item's declared rotations -- deterministic without a tiebreak key. const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ if(item.eligibleTags.length&&!item.eligibleTags.some(tag=>(tmpl.tags??[]).includes(tag)))return []; - if(item.tags.some(tag=>tmpl.tagLimits[tag]!=null&&state.placements.filter(p=>p.item.tags.includes(tag)).length>=tmpl.tagLimits[tag]))return []; + if(item.tags.length&&item.tags.some(tag=>tmpl.tagLimits[tag]!=null&&state.placements.filter(p=>p.item.tags.includes(tag)).length>=tmpl.tagLimits[tag]))return []; // Every rule form is a statement about this item, this container and what the container // already holds -- none of them depends on where in the container the item would go, so // the check belongs here beside the eligibility and tag-limit gates rather than inside @@ -2140,7 +2542,7 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ // Nesting is excluded: a nesting predecessor *replaces* the face edges of everything in // its column, so one new placement can rewrite edges arbitrarily far from itself and the // delta is no longer local. Nesting keeps the from-scratch path. - const nestingPresent=item.nesting!=null||state.placements.some(p=>p.item.nesting!=null); + const nestingPresent=item.nesting!=null||anyPlaced(state.placements,'nesting'); let loadBaseGraph; const loadBase=nestingPresent?null:()=>{ if(loadBaseGraph===undefined){ @@ -2161,10 +2563,10 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ // on inside `allowed` would have turned an O(m*|D|) check into O(m^2*|D|) per candidate. // A container that states no doors leaves the base inert at the cost of one pass over the // stops, which is what every request that is not a multi-drop route pays. - const accessBase=stopAccessibilityBase(item.stopIndex,state.placements,tmpl,tmpl.doors); - const compressionSensitive=item.shapeType==='compressible' - ||state.placements.some(placement=>placement.item.shapeType==='compressible'); - const found=[]; + // With no doors the base is inert and admits every candidate, so the sweep skips building it. + const accessBase=tmpl.doors.length?stopAccessibilityBase(item.stopIndex,state.placements,tmpl,tmpl.doors):null; + const compressionSensitive=item.shapeType==='compressible'||anyCompressiblePlaced(state.placements); + const found=[];let bestScore=0; const candidates=points.length>maxCandidatePoints?points.slice(0,maxCandidatePoints):points; // 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 @@ -2176,21 +2578,33 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ const placements=state.placements,nests=item.nesting!=null,sweep=sweepContext(item,placements); const payloadBlocked=tmpl.max!=null&&state.payload+item.w>tmpl.max,countBlocked=tmpl.max_items!=null&&placements.length>=tmpl.max_items; const exactHull=item.shapeType==='convex_hull'&&item.stopIndex==null&&clear===0; - const orientations=item.rots.map(r=>{const pd=rotate(item.d,r),ed=pd.map(x=>x+2*clear); - return {r,pd,ed,shape:exactHull?shapeFor(item.hullVertices,r):null, - volumeBlocked:!compressionSensitive&&!nests&&used+occupiedVolume({pd,r,item})+tmpl.reserve>tmpl.innerVolume}}); + // The free volume the gate below compares against, fixed for the sweep: used + v + reserve + // exceeds the inner volume exactly when v exceeds it, so each orientation pays one BigInt + // comparison instead of two additions and a comparison. + // A box occupies its own volume in every rotation, the item's `vol`, so only a hull or a + // compressible item asks `occupiedVolume` per orientation. + const volumeRoom=tmpl.innerVolume-tmpl.reserve-used,orientations=[],volumeGated=!compressionSensitive&&!nests; + const boxVolumeBlocked=item.shapeType!=='convex_hull'&&item.maxCompressionKpa==null?volumeGated&&item.vol>volumeRoom:null; + for(const r of item.rots){const pd=rotate(item.d,r),ed=grown(pd,2*clear); + 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; - candidatePoints:for(const pt of candidates){if(candidateEffortExceeded())break;metrics.candidate_points_considered++;const x=pt[0],y=pt[1],z=pt[2]; - for(const orientation of orientations){if(candidateEffortExceeded())break candidatePoints;metrics.orientations_considered++;if(deadline.expired()){timeLimitReached=true;break candidatePoints} + // 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. + // Read as scalars, not destructured, so a rigid container pays nothing for the rule. + const bag=Boolean(tmpl.bag),bagExtents=bag?bagExtent(placements):null,bagX=bag?bagExtents[0]:0,bagY=bag?bagExtents[1]:0,bagZ=bag?bagExtents[2]:0; + candidatePoints:for(let pointIndex=0;pointIndexinnerX||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(payloadBlocked||countBlocked||orientation.volumeBlocked)continue; - const candidate={x,y,z,pd,ed,r,item}; - if(nests&&!compressionSensitive&&used+usedVolumeDelta(placements,candidate)+tmpl.reserve>tmpl.innerVolume)continue; + let candidate=nests?{x,y,z,pd,ed,r,item}:null,box=null; + if(nests&&!compressionSensitive&&usedVolumeDelta(placements,candidate)>volumeRoom)continue; let collision=false; - const candidateShape=orientation.shape,box={x,y,z,d:ed}; - for(const obstacle of obstacles){metrics.collision_checks++; - if(intersects(box,obstacle)&&solidsOverlap(candidateShape,box,null,obstacle)){collision=true;break}} + const candidateShape=orientation.shape; + for(let obstacleIndex=0;obstacleIndex{ if(ix2===ix1+1&&iy2===iy1+1&&iz2===iz1+1){ const bucket=index.cells.get(cellKey(ix1,iy1,iz1)); if(bucket){ - for(const position of bucket){ - const placed=placements[position],pe=placed.ed;metrics.collision_checks++; + for(let slot=0;slotplaced.x&&yplaced.y&&zplaced.z &&!(nests&&validNesting(candidate,placed)) - &&solidsOverlap(candidateShape,box,placedHull(placed),{x:placed.x,y:placed.y,z:placed.z,d:pe})){collision=true;break} + &&solidsOverlapAt(candidateShape,x,y,z,ed,placed)){collision=true;break} } } }else{ const stamp=++index.gen; scan:for(let ix=ix1;ixplaced.x&&yplaced.y&&zplaced.z &&!(nests&&validNesting(candidate,placed)) - &&solidsOverlap(candidateShape,box,placedHull(placed),{x:placed.x,y:placed.y,z:placed.z,d:pe})){collision=true;break scan}} + &&solidsOverlapAt(candidateShape,x,y,z,ed,placed)){collision=true;break scan}} } } } if(collision)continue; + if(candidate===null)candidate={x,y,z,pd,ed,r,item}; if(axleOverloaded(tmpl,placements,candidate))continue; if(!allowed(candidate,placements,tmpl,globalSupportPpm,metrics,loadBase,accessBase,sweep))continue; // With zero load the candidate is at its largest, and appending it can only shrink @@ -2241,22 +2656,28 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ :routeSensitive ?x2*1e9+y2*1e4+z2 :z2*1e9+y2*1e4+x2; - if(width===1){if(!found.length||score({score,...candidate})); + if(width!=null)return found.sort(compareRetained).map(entry=>entry.candidate); found.sort((a,b)=>a.score-b.score); - return found + return found.map(entry=>entry.candidate) }; const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const seeded=seedState(tmpl),state=seeded.state;const next=[]; // Running `usedVolume` of `state.placements`, maintained incrementally rather than // recomputed per candidate. Kept local to this call, not on `state`, so it // cannot leak into the packed container the caller spreads. let used=seeded.used; + // Whether a compressible item is placed, kept alongside the placements (and restored with + // them) rather than rediscovered by a scan of every placement per placed item. + let placedCompressible=state.placements.some(placement=>placement.item.shapeType==='compressible'); const points=seeded.points,index=seeded.index; for(const batch of batchesOf(itemsRemaining)){ - const snapshotPlacements=state.placements.slice(),snapshotPayload=state.payload,snapshotUsed=used; + const snapshotPlacements=state.placements.slice(),snapshotPayload=state.payload,snapshotUsed=used,snapshotCompressible=placedCompressible; const snapshotPoints=batch.length>1?points.slice():null,snapshotIndex=batch.length>1?copyIndex(index):null;let ok=true; for(const item of batch){ if(deadline.expired()){timeLimitReached=true;ok=false;break} @@ -2265,14 +2686,13 @@ const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const seeded=seedState(tmpl),s const best=candidatesFor(tmpl,item,state,points,index,used,1)[0]; if(!best){ok=false;break} state.payload+=item.w; - const compressionSensitive=item.shapeType==='compressible' - ||state.placements.some(placement=>placement.item.shapeType==='compressible'); + const compressionSensitive=item.shapeType==='compressible'||placedCompressible; used=compressionSensitive?usedVolume([...state.placements,best]):used+usedVolumeDelta(state.placements,best); - state.placements.push(best); + state.placements.push(best);if(item.shapeType==='compressible')placedCompressible=true; indexAdd(index,state.placements.length-1,{x:best.x,y:best.y,z:best.z,d:best.ed}); retirePointsForPlacement(points,best); for(const point of pointsFrom(best))insertPoint(points,point)} - if(!ok){state.placements=snapshotPlacements;state.payload=snapshotPayload;used=snapshotUsed; + if(!ok){state.placements=snapshotPlacements;state.payload=snapshotPayload;used=snapshotUsed;placedCompressible=snapshotCompressible; if(snapshotPoints)points.splice(0,points.length,...snapshotPoints); if(snapshotIndex){index.cells=snapshotIndex.cells;index.owned=snapshotIndex.owned;index.seen=snapshotIndex.seen}next.push(...batch)}} return {state,next,used} @@ -2296,8 +2716,10 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ // call, so its cumulative volume/weight arrays are hoisted by the caller and // passed in here -- falls back to building them on the fly for the trivial future=[] // call sites below, which never reach the sort's cost. Reused arrays are read-only. + // Each future item counts what it occupies, as `node.used` does: two wedges each + // declaring the whole crate both fit in it. const lowerBound=(node,future,cumulativeVolumes=null,cumulativeWeights=null)=>{let possible=future.length; - if(!future.some(item=>item.nesting!=null))possible=Math.min(possible,maxCount(cumulativeVolumes??cumulativeCosts(future.map(item=>volume(item.d))),volume(tmpl.d)-node.used)); + if(!future.some(item=>item.nesting!=null))possible=Math.min(possible,maxCount(cumulativeVolumes??cumulativeCosts(future.map(itemOccupiedVolume)),volume(tmpl.d)-node.used)); if(tmpl.max!=null)possible=Math.min(possible,maxCount(cumulativeWeights??cumulativeCosts(future.map(item=>BigInt(item.w))),BigInt(Math.max(0,tmpl.max-node.state.payload)))); return node.unplaced.length+future.length-possible}; // Compared states are complete; placement always mutates a fresh clone first. @@ -2306,7 +2728,19 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ const stateKey=state=>{let key=stateKeys.get(state);if(key===undefined){ key={maxZ:state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0),signature:undefined};stateKeys.set(state,key)}return key}; const signature=state=>{const key=stateKey(state);if(key.signature===undefined)key.signature=state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|');return key.signature}; - const compareNode=(a,b,bounds=null)=>{let difference=bounds===null?a.unplaced.length-b.unplaced.length:bounds.get(a)-bounds.get(b);if(difference)return difference; + // An upper bound on the volume a node can still end with: what is placed, plus the + // future items' total volume capped by the free space. Nominal volumes are not additive + // under nesting (`futureVolume` is null), so the free space alone is the bound there. + const reachableVolume=(node,future,futureVolume)=>{if(!future.length)return node.used; + const inner=volume(tmpl.d),free=inner>node.used?inner-node.used:0n; + return node.used+(futureVolume===null||futureVolume>free?free:futureVolume)}; + const compareNodeByVolume=(a,b,bounds)=>{const reachA=bounds===null?a.used:bounds.get(a),reachB=bounds===null?b.used:bounds.get(b); + if(reachA!==reachB)return reachA>reachB?-1:1;if(a.used!==b.used)return a.used>b.used?-1:1; + const difference=a.unplaced.length-b.unplaced.length;if(difference)return difference; + const az=stateKey(a.state).maxZ,bz=stateKey(b.state).maxZ;if(az!==bz)return az-bz; + return compareCodePoints(signature(a.state),signature(b.state))}; + const compareNode=(a,b,bounds=null)=>{if(byVolume)return compareNodeByVolume(a,b,bounds); + let difference=bounds===null?a.unplaced.length-b.unplaced.length:bounds.get(a)-bounds.get(b);if(difference)return difference; difference=a.unplaced.length-b.unplaced.length;if(difference)return difference; difference=b.state.placements.length-a.state.placements.length;if(difference)return difference; const az=stateKey(a.state).maxZ,bz=stateKey(b.state).maxZ; @@ -2329,13 +2763,15 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ // The incumbent is only ever compared and returned (state + unplaced); it never // re-enters the beam, so cloning the candidate points and spatial index for it // was pure allocation per expansion. - // More unplaced items already loses compareNode's first criterion, so the copies - // below are made only for a candidate that can displace the incumbent. - for(const node of expansions){if(node.unplaced.length+future.length>incumbent.unplaced.length)continue;const complete={state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used,unplaced:[...node.unplaced,...future]};if(compareNode(complete,incumbent)<0)incumbent=complete} + // More unplaced items -- less placed volume under `maximum_volume` -- already loses + // compareNode's first criterion, so the copies below are made only for a candidate + // that can displace the incumbent. + for(const node of expansions){if(byVolume?node.usedincumbent.unplaced.length)continue;const complete={state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used,unplaced:[...node.unplaced,...future]};if(compareNode(complete,incumbent)<0)incumbent=complete} if(!expansions.length||exhausted){pendingFrom=position;break} - const futureVolumes=future.some(item=>item.nesting!=null)?null:cumulativeCosts(future.map(item=>volume(item.d))); + const futureVolumes=future.some(item=>item.nesting!=null)?null:cumulativeCosts(future.map(itemOccupiedVolume)); const futureWeights=tmpl.max!=null?cumulativeCosts(future.map(item=>BigInt(item.w))):null; - const bounds=new Map(expansions.map(node=>[node,lowerBound(node,future,futureVolumes,futureWeights)])); + const futureVolume=futureVolumes===null?null:futureVolumes.length?futureVolumes[futureVolumes.length-1]:0n; + const bounds=new Map(expansions.map(node=>[node,byVolume?reachableVolume(node,future,futureVolume):lowerBound(node,future,futureVolumes,futureWeights)])); expansions.sort((a,b)=>compareNode(a,b,bounds));beam=expansions.slice(0,containerPlanBeamWidth)} // Without the unreached tail a beam leader looks better than every complete incumbent, // and choosing it dropped those items outright: neither placed nor reported unpacked. @@ -2392,12 +2828,24 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ const packed=work.state.placements.length?[work.state]:[]; return planScore({packed,remaining:next})}; const profiles=new Map(itemsRemaining.map(item=>[item.id,{ - volume:volume(item.d),weight:item.w, + occupiedVolume:itemOccupiedVolume(item),weight:item.w, minimumHeight:Math.min(...item.rots.map(rotation=>rotate(item.d,rotation)[2]+2*clear)), }])); const suffixItems=Array.from({length:batches.length+1},()=>[]); for(let index=batches.length-1;index>=0;index--)suffixItems[index]=[...batches[index],...suffixItems[index+1]]; const ascendingBigInts=values=>values.sort((a,b)=>ab?1:0); + // The least nominal volume any descendant of `work` leaves unpacked, as the leading + // `maximum_volume` key: what was skipped for good, plus whatever of the suffix cannot + // fit the free space. That cap holds only while occupied volume is nominal volume -- + // nesting, a hull and compression all occupy less -- so otherwise the whole suffix is + // assumed placeable. O(n) per node. + const volumesAdditive=[...itemsRemaining,...(tmpl.fixed??[]).map(p=>p.item)].every(item=>item.nesting==null&&item.shapeType==='rigid_cuboid'); + const remainingVolume=nominalVolume(itemsRemaining); + const unpackedVolumeFloorPpm=(work,future)=>{ + const futureVolume=nominalVolume(future),placedVolume=work.state.placements.reduce((sum,p)=>sum+(p.fixed?0n:p.item.vol),0n); + const inner=volume(tmpl.d),free=inner>work.used?inner-work.used:0n; + const placeable=volumesAdditive&&free{ const optimisticCompletionScore=(work,future)=>{ if(work.state.placements.length===0&&future.length===0)return [0,0,0,0,0]; const containerVolume=volume(tmpl.d),nestingInvolved=work.state.placements.some(p=>p.item.nesting!=null)||future.some(item=>item.nesting!=null); - const volumes=ascendingBigInts(future.map(item=>profiles.get(item.id).volume)); + const volumes=ascendingBigInts(future.map(item=>profiles.get(item.id).occupiedVolume)); let placeable=future.length,smallestVolumeSum=volumes.reduce((sum,value)=>sum+value,0n); if(!nestingInvolved&&containerVolume>0n){ placeable=0;smallestVolumeSum=0n; @@ -2425,7 +2873,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ smallestVolumeSum=volumes.slice(0,placeable).reduce((sum,value)=>sum+value,0n); const permanentlySkipped=itemsRemaining.length-(work.state.placements.length-fixedCount)-future.length; const unpackedFloor=permanentlySkipped+future.length-placeable; - if(placeable===0&&work.state.placements.length===0)return [unpackedFloor,0,0,0,0]; + if(placeable===0&&work.state.placements.length===0)return byVolume?[unpackedVolumeFloorPpm(work,future),unpackedFloor,0,0,0]:[unpackedFloor,0,0,0,0]; const largestVolumeSum=volumes.slice(Math.max(0,volumes.length-placeable)).reduce((sum,value)=>sum+value,0n); const usedCeiling=work.used+largestVolumeSum<(containerVolume>work.used?containerVolume:work.used) ?work.used+largestVolumeSum:(containerVolume>work.used?containerVolume:work.used); @@ -2440,7 +2888,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ const height=tmpl.d[2]>0?Math.floor(minimumHeight*1000000/tmpl.d[2]):0; const weights=future.map(item=>profiles.get(item.id).weight).sort((a,b)=>a-b); const grossWeight=weights.slice(0,placeable).reduce((sum,value)=>sum+value,work.state.payload+tmpl.tare); - const billable=objective==='shipping_cost'||objective==='lowest_landed_cost'?Math.max(grossWeight,dimensionalWeight(tmpl.outerD)):0; + const billable=objective==='shipping_cost'||objective==='lowest_landed_cost'?Math.max(grossWeight,billedDimensionalWeight(tmpl,work.state.placements)):0; // A promotional bracket may be cheaper than a lighter bracket, so pricing the // lightest possible completion is not an admissible lower bound. Tariff charges are // non-negative; zero is the general money floor and only loosens this exact search. @@ -2451,6 +2899,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ if(objective==='lowest_landed_cost')return [unpackedFloor,landed,1,unused,height]; if(objective==='open_dimension_height')return [unpackedFloor,minimumHeight,1,cost,unused]; if(objective==='maximum_value')return [unpackedFloor,0,1,cost,unused]; + if(byVolume)return [unpackedVolumeFloorPpm(work,future),unpackedFloor,1,cost,unused]; return [unpackedFloor,1,cost,unused,height] }; let best=freshWork(),bestRank=rankedWork(best); @@ -2460,10 +2909,13 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ if(effortExceeded())return; metrics.search_nodes_expanded++; const unpackedHere=itemsRemaining.length-(work.state.placements.length-fixedCount); - if(unpackedHere<=bestRank[0]){const workRank=rankedWork(work);if(compareScore(workRank,bestRank)<0){best=work;bestRank=workRank}} + // Both count gates -- this one and the reachable-count cut below -- assume the vector + // leads with the piece count. Under `maximum_volume` fewer pieces can be more volume, + // so every node is ranked and only the objective bound prunes. + if(byVolume||unpackedHere<=bestRank[0]){const workRank=rankedWork(work);if(compareScore(workRank,bestRank)<0){best=work;bestRank=workRank}} if(compareScore(bestRank,completeLowerBound)===0)return; if(depth>=batches.length)return; - if(work.state.placements.length+reachable=0)return; const batch=batches[depth],rest=reachable-batch.length; for(const child of childrenOf(work,batch)){ @@ -2478,71 +2930,95 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ }; // Deterministic solid single-type block search. Candidate enumeration is // O(B*S*T*R*X*Y) time and O(S+n) space; the shared deadline/effort counters and -// containerPlanNodeLimit bound it exactly as in Python, PHP and Rust. +// containerPlanNodeLimit bound it exactly as in Python, PHP and Rust. The start added to +// `fast` and `balanced` carries its own node bound, and `fast`, being latency-bound, runs +// only the one enumeration its objective ranks by. +const blockNodeLimit=addedBlockStart?BLOCK_START_NODE_LIMIT:containerPlanNodeLimit; +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) + &&Object.keys(t.tagLimits).length===0&&t.maxStackDensity==null&&t.reservePpm===0&&!t.bag) &&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); -const compareBlockValue=(a,b)=>typeof a==='bigint'?(ab?1:0):typeof a==='string'?compareCodePoints(a,b):a-b; -const compareBlockKey=(a,b)=>{for(let i=0;iouter.x<=inner.x&&outer.y<=inner.y&&outer.z<=inner.z - &&outer.x+outer.d[0]>=inner.x+inner.d[0]&&outer.y+outer.d[1]>=inner.y+inner.d[1]&&outer.z+outer.d[2]>=inner.z+inner.d[2]; -// `spaces` is containment-free (a single whole space or this function's own output), -// so a survivor untouched by the carve can neither contain nor be contained by -// another survivor, and a new slab cannot contain one either (the slab's own parent -// could not); only new slabs need the dominance scan: O(new*s) instead of O(s^2). -const subtractBlock=(spaces,box)=>{metrics.space_partitions+=spaces.length;const out=[],fresh=new Set(); - const push=(x,y,z,X,Y,Z)=>{if(X>x&&Y>y&&Z>z){const part={x,y,z,d:[X-x,Y-y,Z-z]};out.push(part);fresh.add(part)}}; - for(const space of spaces){const X=space.x+space.d[0],Y=space.y+space.d[1],Z=space.z+space.d[2],bX=box.x+box.d[0],bY=box.y+box.d[1],bZ=box.z+box.d[2]; - if(!intersects(space,box)){out.push(space);continue} - push(space.x,space.y,space.z,Math.min(X,box.x),Y,Z);push(Math.max(space.x,bX),space.y,space.z,X,Y,Z); - push(space.x,space.y,space.z,X,Math.min(Y,box.y),Z);push(space.x,Math.max(space.y,bY),space.z,X,Y,Z); - push(space.x,space.y,space.z,X,Y,Math.min(Z,box.z));push(space.x,space.y,Math.max(space.z,bZ),X,Y,Z)} - const unique=new Map(out.map(space=>[[space.x,space.y,space.z,...space.d].join(':'),space])); - const kept=[...unique.values()].sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||(volume(a.d)>volume(b.d)?-1:1)); - let result=kept.filter((space,index)=>!fresh.has(space)||!kept.some((other,otherIndex)=>index!==otherIndex&&containsSpace(other,space))); - if(result.length>256)result=result.sort((a,b)=>volume(a.d)>volume(b.d)?-1:volume(a.d)a.z-b.z||a.y-b.y||a.x-b.x); - return result}; -const blockMode=(tmpl,itemsRemaining,volumeFirst)=>{let spaces=[{x:0,y:0,z:0,d:tmpl.d.slice()}],nodes=0,reached=false; + &&i.supportPpm===0&&(i.groundRule==null||i.groundRule==='free')&&i.nesting==null&&i.stopIndex==null + // Block-on-block contacts would need a second implementation of the stacking-group + // rule; the per-item search already walks every edge. + &&i.accepts==null); +// The distinct rotations of one item type, with what every lattice of them shares. +const blockLattices=prototype=>{const seen=new Set(),lattices=[]; + for(const rotation of prototype.rots){const pd=rotate(prototype.d,rotation),physicalKey=pd.join(':');if(seen.has(physicalKey))continue;seen.add(physicalKey); + lattices.push({rotation,pd,ed:grown(pd,2*clear),volume:volume(pd),approximateVolume:pd[0]*pd[1]*pd[2]})} + return lattices}; +const blockMode=(tmpl,itemsRemaining,volumeFirst)=>{let spaces=[blockSpace(0,0,0,tmpl.d.slice())],nodes=0,reached=false; const byType=new Map();for(const item of itemsRemaining){const values=byType.get(item.raw.id)??[];values.push(item);byType.set(item.raw.id,values)} + const itemIds=[...byType.keys()].sort(),lattices=new Map(); const state={tmpl,placements:[],payload:0}; while(spaces.length&&[...byType.values()].some(values=>values.length)){ - let best=null; - spacesLoop:for(const space of spaces)for(const itemId of [...byType.keys()].sort()){ + // The counted limits cannot move during one round's enumeration: the candidate counters + // change only when a block is placed, and the search-node counter moves with `nodes`. + const stopAt=candidateEffortExceeded()?nodes:Math.min(blockNodeLimit, + effort?.max_search_nodes==null?Infinity:nodes+Math.max(0,effort.max_search_nodes-metrics.search_nodes_expanded)); + // A type's capacity and lattices do not depend on the space they are tried in. + const types=[]; + for(let slot=0;slot0)capacity=Math.min(capacity,Math.floor(Math.max(0,tmpl.max-state.payload)/prototype.w)); if(capacity<=0)continue; - const seen=new Set();for(const rotation of prototype.rots){const pd=rotate(prototype.d,rotation),physicalKey=pd.join(':');if(seen.has(physicalKey))continue;seen.add(physicalKey); - const ed=pd.map(edge=>edge+2*clear),maximumX=Math.floor(space.d[0]/ed[0]),maximumY=Math.floor(space.d[1]/ed[1]),maximumZ=Math.floor(space.d[2]/ed[2]); - for(let nx=1;nx<=Math.min(maximumX,capacity);nx++)for(let ny=1;ny<=Math.min(maximumY,Math.floor(capacity/nx));ny++){ - if(nodes>=containerPlanNodeLimit||deadline.expired()||effortExceeded()){reached=true;break spacesLoop} - nodes++;metrics.search_nodes_expanded++;const nz=Math.min(maximumZ,Math.floor(capacity/(nx*ny)));if(nz<=0)continue; - const count=nx*ny*nz,used=BigInt(count)*volume(pd),fill=used*1000000n/volume(space.d); - const lead=volumeFirst?[-used,-fill,-BigInt(count)]:[-BigInt(count),-fill,-used]; - const key=[...lead,space.z,space.y,space.x,itemId,rotation,nx,ny,nz],candidate={space,itemId,rotation,pd,ed,nx,ny,nz,count,key}; - if(best==null||compareBlockKey(key,best.key)<0)best=candidate}}} - if(best==null||reached)break; + if(!lattices.has(itemId))lattices.set(itemId,blockLattices(prototype)); + const typeLattices=lattices.get(itemId);let minimumX=Infinity,minimumY=Infinity; + for(const lattice of typeLattices){minimumX=Math.min(minimumX,lattice.ed[0]);minimumY=Math.min(minimumY,lattice.ed[1])} + types.push({itemId,slot,capacity,lattices:typeLattices,minimumX,minimumY})} + // Indexed loops: this nest runs mostly before the JIT has warmed up, where for-of and + // destructuring pay for the iterator protocol. + const round={nodes,best:null,volumeFirst}; + for(let spaceIndex=0;spaceIndex=0?stop:round.nodes; + metrics.search_nodes_expanded+=end-nodes;nodes=end; + if(stop>=0){reached=true;break} + const best=round.best;if(best==null)break; const available=byType.get(best.itemId),chosen=available.splice(0,best.count);let index=0; for(let z=0;zbyType.get(key));return {state,next,reached}} +const blockModeKey=candidate=>{const placements=candidate.state.placements,used=usedVolume(placements),top=placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0); + return byVolume?[-used,candidate.next.length,top]:[candidate.next.length,-used,top]}; +const blockModeSignature=candidate=>candidate.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|'); const packBlocksIntoTemplate=(tmpl,itemsRemaining)=>{if(!homogeneousBlocksSupported()||tmpl.fixed)return tryPackIntoTemplate(tmpl,itemsRemaining); - let best=null;for(const volumeFirst of [false,true]){if(deadline.expired()){timeLimitReached=true;break}const candidate=blockMode(tmpl,itemsRemaining,volumeFirst);if(candidate.reached&&deadline.expired())timeLimitReached=true; - const used=usedVolume(candidate.state.placements),top=candidate.state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0),signature=candidate.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|'),key=[candidate.next.length,-used,top,signature]; - if(best==null||compareBlockKey(key,best.key)<0)best={...candidate,key}} + // Modes are ranked by (lead, top, placement signature). A single mode is never ranked, and + // the signature, a string over every placement, is built only for two modes the rest ties. + let best=null,bestKey=null;for(const volumeFirst of blockModes){if(deadline.expired()){timeLimitReached=true;break}const candidate=blockMode(tmpl,itemsRemaining,volumeFirst);if(candidate.reached&&deadline.expired())timeLimitReached=true; + if(best==null){best=candidate;continue} + if(bestKey==null)bestKey=blockModeKey(best); + const key=blockModeKey(candidate),order=compareBlockKey(key,bestKey); + if(order<0||order===0&&compareCodePoints(blockModeSignature(candidate),blockModeSignature(best))<0){best=candidate;bestKey=key}} return best??{state:{tmpl,placements:[],payload:0},next:itemsRemaining.slice()}} +// The added start runs only where the lattice can express the whole request -- a start +// that fell back to the per-item search would repeat one the profile already ran -- and +// only where every item leads the ordering alike: a lattice takes whichever type fills +// best, so it cannot honour a caller's say over which item goes first. A stop index is +// already outside the lattice's support. The lattice also reads an item as its nominal +// box and never checks what a block rests on, so a hull, a compressible item and a +// request-wide support ratio are left to the per-item search. +if(addedBlockStart&&!(homogeneousBlocksSupported()&&globalSupportPpm===0 + &&items.every(i=>i.shapeType==='rigid_cuboid'&&i.priority===items[0].priority&&(objective!=='maximum_value'||i.value===items[0].value))))return null; const packIntoTemplate=solverAlias==='exact_small'?packExactIntoTemplate:solverAlias==='homogeneous_blocks'?packBlocksIntoTemplate:containerPlanBeamWidth>1?packBeamIntoTemplate:tryPackIntoTemplate; const planScore=(plan,unpacked=plan.remaining)=>{let cost=0,unused=0,height=0,billable=0,landed=0,achieved=0; for(const state of plan.packed){cost+=state.tmpl.cost_minor??0;const inner=volume(state.tmpl.d),used=usedVolume(state.placements);unused+=Number((inner-used)*1000000n/inner); const top=state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0);height+=Number(BigInt(top)*1000000n/BigInt(state.tmpl.d[2]));achieved+=top; if(objective==='shipping_cost'||objective==='lowest_landed_cost'){ - const billed=Math.max(state.payload+state.tmpl.tare,dimensionalWeight(state.tmpl.outerD)); + const billed=Math.max(state.payload+state.tmpl.tare,billedDimensionalWeight(state.tmpl,state.placements)); if(objective==='shipping_cost')billable+=billed;else landed=addLanded(landed,state.tmpl,billed)}} const base=[unpacked.length,plan.packed.length,cost,unused,height]; if(objective==='lowest_cost')return [base[0],base[2],base[1],base[3],base[4]]; @@ -2550,11 +3026,49 @@ const planScore=(plan,unpacked=plan.remaining)=>{let cost=0,unused=0,height=0,bi if(objective==='lowest_landed_cost')return [base[0],landed,base[1],base[3],base[4]]; if(objective==='open_dimension_height')return [base[0],achieved,base[1],base[2],base[3]]; if(objective==='maximum_value')return [base[0],unpacked.reduce((sum,item)=>sum+(item.value??0),0),base[1],base[2],base[3]]; + if(byVolume)return [unpackedVolumePpm(nominalVolume(unpacked),requestedVolume),base[0],base[1],base[2],base[3]]; return base}; -const additionalContainerBound=plan=>{const available=templates.filter(t=>plan.inventory.get(t.id)>0);if(!plan.remaining.length||!available.length)return 0;let lower=0; - if(!plan.remaining.some(item=>item.nesting!=null)){const capacity=available.reduce((best,t)=>volume(t.d)>best?volume(t.d):best,0n),required=plan.remaining.reduce((sum,item)=>sum+volume(item.d),0n);if(capacity>0n)lower=Math.max(lower,Number((required+capacity-1n)/capacity))} - if(available.every(t=>t.max!=null)){const capacity=Math.max(...available.map(t=>t.max)),required=plan.remaining.reduce((sum,item)=>sum+item.w,0);if(capacity>0)lower=Math.max(lower,Math.ceil(required/capacity))}return lower}; -const planBound=(plan)=>{const key=planScore(plan,[]),index=objective==='default'?1:2;key[index]+=additionalContainerBound(plan);return key}; +const planBound=(plan)=>{const key=planScore(plan,[]),index=objective==='default'?1:2;key[index]+=additionalContainerBound(plan,templates);return key}; +// The greedy round's key for one trial, read from the container the trial built: a +// made-to-measure trial carries its fabricated box, so the key prices the box that ships, never +// the machine maximum. The id closes every key, so ties go to the smaller id. +// `lowest_landed_cost` ranks the round in money: the trial's charge first, then estimated +// rounds remaining, then progress -- the key order Rust, Python and PHP use. +// `planScore`'s finished vector leads with unpacked count, which is right for whole plans but +// inverted for one round: an unpriceable-but-roomier trial out-ranked a priceable one on +// progress, refusing or over-paying requests the other three engines ship. The greedy loop +// commits the trial verbatim, so its billed weight is final here and the tariff can be read +// now; an unpriceable trial still sorts behind every priceable alternative via `addLanded`'s +// sentinel. Otherwise the round's lead is its progress: pieces placed, or exact (BigInt) volume +// placed under `maximum_volume`; both sides of a comparison always carry the same type. +const roundScore=(trial,itemsRemaining)=>{const tmpl=trial.state.tmpl,placed=trial.state.placements.length; + if(objective==='lowest_landed_cost'){ + const billed=Math.max(trial.state.payload+tmpl.tare,billedDimensionalWeight(tmpl,trial.state.placements)); + return [addLanded(0,tmpl,billed),Math.ceil(itemsRemaining.length/Math.max(placed,1)),-placed,tmpl.id]} + if(solverAlias==='exact_small')return [...planScore({packed:[trial.state],remaining:trial.next}),tmpl.id]; + const used=usedVolume(trial.state.placements); + return [byVolume?-used:-placed,tmpl.cost_minor??0,Number(volume(tmpl.d)-used),tmpl.id]}; +const compareRound=(left,right)=>{for(let i=0;itmpl.machine==null?{trial:packIntoTemplate(tmpl,itemsRemaining),trials:1}:madeToMeasureTrial(tmpl,itemsRemaining); +// 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). +const madeToMeasureTrial=(tmpl,itemsRemaining)=>{ + const rungs=solvingEnvelopes(tmpl.machine,tmpl.d,itemsRemaining.map(item=>item.d.map(edge=>edge+2*clear))); + let best=null,bestScore=null,last=null,trials=0; + for(const rung of rungs){ + const trial=packIntoTemplate({...tmpl,d:rung,innerVolume:volume(rung),reserve:0n},itemsRemaining);trials++;last=trial; + if(trial.state.placements.length){const cut=fabricatedTrial(tmpl,trial);if(!trial.next.length)return {trial:cut,trials}; + const score=roundScore(cut,itemsRemaining);if(bestScore==null||compareRound(score,bestScore)<0){best=cut;bestScore=score}} + if(deadline.expired()){timeLimitReached=true;break} + if(effortExceeded())break} + 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}}}}; // 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 @@ -2570,8 +3084,10 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ const initial={packed:packed.slice(),remaining:remaining.slice(),inventory:new Map(inventory),seq};let beam=fixedReached?[]:[initial],incumbent=initial,planNodes=0; while(beam.length&&planNodes=maxContainers){if(compareScore(planScore(plan),planScore(incumbent))<0)incumbent=plan;continue} - for(const tmpl of templates){if(planNodes>=containerPlanNodeLimit)break;if(deadline.expired()||effortExceeded()){exhausted=true;break}if(plan.inventory.get(tmpl.id)<=0)continue;planNodes++; - const trial=packIntoTemplate(tmpl,plan.remaining);if(!trial.state.placements.length)continue;const nextInventory=new Map(plan.inventory);nextInventory.set(tmpl.id,nextInventory.get(tmpl.id)-1); + for(const tmpl of templates){if(planNodes>=containerPlanNodeLimit)break;if(deadline.expired()||effortExceeded()){exhausted=true;break}if(plan.inventory.get(tmpl.id)<=0)continue; + // Each rung is one single-container solve, so the node limit keeps bounding the work + // of a made-to-measure chain. + const {trial,trials}=trialFor(tmpl,plan.remaining);planNodes+=trials;if(!trial.state.placements.length)continue;const nextInventory=new Map(plan.inventory);nextInventory.set(tmpl.id,nextInventory.get(tmpl.id)-1); const child={packed:[...plan.packed,{...trial.state,seq:plan.seq+1}],remaining:trial.next,inventory:nextInventory,seq:plan.seq+1};expansions.push(child);if(compareScore(planScore(child),planScore(incumbent))<0)incumbent=child} if(exhausted)break} if(exhausted||!expansions.length)break; @@ -2592,36 +3108,10 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ let winner=null,winnerScore=null; for(const tmpl of eligible){ if(deadline.expired()){timeLimitReached=true;break} - const trial=packIntoTemplate(tmpl,remaining); + const {trial}=trialFor(tmpl,remaining); if(!trial.state.placements.length)continue; - let score,better; - // `lowest_landed_cost` ranks the round in money: the trial's charge first, then - // estimated rounds remaining, then progress -- the key order Rust, Python and PHP - // use. `planScore`'s finished vector leads with unpacked count, which is - // right for whole plans but inverted for one round: an unpriceable-but-roomier - // trial out-ranked a priceable one on progress, refusing or over-paying requests - // the other three engines ship. The greedy loop commits the trial verbatim, so - // its billed weight is final here and the tariff can be read now; an unpriceable - // trial still sorts behind every priceable alternative via `addLanded`'s sentinel. - if(objective==='lowest_landed_cost'){ - const placed=trial.state.placements.length; - const billed=Math.max(trial.state.payload+tmpl.tare,dimensionalWeight(tmpl.outerD)); - score=[addLanded(0,tmpl,billed),Math.ceil(remaining.length/Math.max(placed,1)),-placed]; - const comparison=winnerScore==null?-1:compareScore(score,winnerScore); - better=comparison<0||(comparison===0&&tmpl.id1&&solverAlias!=='exact_small'){ // containers open first, so a fixed placement's instance n is the container emitted as `T#n`. {const counts=new Map(); 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); +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.outer_dimensions?dims(c.tmpl.outer_dimensions,u):c.tmpl.d,ou),payload_weight:outWeight(c.payload,ow),gross_weight:outWeight(c.payload+c.tmpl.tare,ow),used_volume_ticks3:used.toString(),volume_utilization:(Number(used)/Number(volume(c.tmpl.d))).toFixed(6),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:(p.fixed?p.fixedSupport:supportRatioOf(p,c.placements)).toFixed(6),top_load:outWeight(loads[i],ow),...(p.fixed?{fixed:true}:{})}))}}); -const fitsWithRotations=(i,rots)=>templates.some(c=>rots.some(r=>{const d=rotate(i.d,r).map(x=>x+2*clear);return d.every((edge,k)=>edge<=c.d[k])})); + 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:(Number(used)/Number(volume(c.tmpl.d))).toFixed(6),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:(p.fixed?p.fixedSupport:supportRatioOf(p,c.placements,topPlane)).toFixed(6),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); const unpacked=remaining.map(i=>{ // Same geometric check with every physical orientation allowed, not only the // item's own restricted set: distinguishes "genuinely too big in any rotation" @@ -2647,7 +3138,7 @@ const unpacked=remaining.map(i=>{ // whatever a policy says, and naming the policy first would send a caller to change the // wrong thing. const cited=policyRules.length?provesUnplaceable(policyRules,i.tags,templates):null; - const reason=!fitsAnyRotation?'no_compatible_container_dimensions':!dimensionFit?'rotation_restricted':!weightFit?'payload_exceeded':!eligibleFit?'no_eligible_container':cited!==null?'policy_rule':timeLimitReached?'time_limit':effortExceeded()?'effort_limit':i.group!==null?'group_cannot_fit_together':'search_exhausted',details=reason==='policy_rule'?[cited]:[]; + const reason=!fitsAnyRotation?'no_compatible_container_dimensions':!dimensionFit?'rotation_restricted':!weightFit?'payload_exceeded':!eligibleFit?'no_eligible_container':cited!==null?'policy_rule':timeLimitReached?'time_limit':effortExceeded()?'effort_limit':i.group!==null?'group_cannot_fit_together':limitReason(i,templates,openedByType,maxContainers,clear)??'search_exhausted',details=reason==='policy_rule'?[cited]:[]; return {item_id:i.id,item_type:i.raw.id,reason,details,proof:proofForReason(reason,details)} }); // Canonical objective vector — see docs/OBJECTIVE.md. Five lexicographic keys, @@ -2661,7 +3152,7 @@ for(const c of packed){scoreCost+=c.tmpl.cost_minor??0; scoreAchievedHeight+=top; if(innerHeight>0n)scoreHeight+=Number(BigInt(top)*SCORE_SCALE/innerHeight); if(objective==='shipping_cost'||objective==='lowest_landed_cost'){ - const billed=Math.max(c.payload+c.tmpl.tare,dimensionalWeight(c.tmpl.outerD)); + const billed=Math.max(c.payload+c.tmpl.tare,billedDimensionalWeight(c.tmpl,c.placements)); if(objective==='shipping_cost')scoreBillable+=billed;else scoreLanded=addLanded(scoreLanded,c.tmpl,billed);}} // The search ranks an unpriceable packing worst so that any priceable alternative beats // it; reaching here means none existed in this run. Returning such a packing would @@ -2673,7 +3164,7 @@ for(const c of packed){scoreCost+=c.tmpl.cost_minor??0; // below, a search device that never serializes. let unpriceableDetail=null; if(objective==='lowest_landed_cost')for(const c of packed){ - const grams=billedGrams(Math.max(c.payload+c.tmpl.tare,dimensionalWeight(c.tmpl.outerD))),table=c.tmpl.rate; + const grams=billedGrams(Math.max(c.payload+c.tmpl.tare,billedDimensionalWeight(c.tmpl,c.placements))),table=c.tmpl.rate; if(table!=null&&chargeMinor(table,grams)!=null)continue; // A missing table is already refused at admission, so `0` here is unreachable rather // than a real bound -- but reading a bracket off `null` would replace the refusal @@ -2686,7 +3177,7 @@ const status=unpacked.length?(timeLimitReached?'time_limit':'best_found'):'feasi const solverName=solverAlias?`${solverAlias}:javascript_fallback`:'javascript_fallback'; const starts=[{id:solverName,started:true,completed:!timeLimitReached&&!effortLimitReached,truncated:timeLimitReached||effortLimitReached,selected:true,global_deadline_reached:timeLimitReached}],termination=aggregateTermination(starts);if(effortLimitReached&&!timeLimitReached)termination.code='effort_limit'; const scoreValueForgone=remaining.reduce((sum,i)=>sum+(i.value??0),0); -const defaultScore=[unpacked.length,containers.length,scoreCost,scoreUnused,scoreHeight],score=objective==='lowest_cost'?[defaultScore[0],defaultScore[2],defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='shipping_cost'?[defaultScore[0],scoreBillable,defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='lowest_landed_cost'?[defaultScore[0],scoreLanded,defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='open_dimension_height'?[defaultScore[0],scoreAchievedHeight,defaultScore[1],defaultScore[2],defaultScore[3]]:objective==='maximum_value'?[defaultScore[0],scoreValueForgone,defaultScore[1],defaultScore[2],defaultScore[3]]:defaultScore; +const defaultScore=[unpacked.length,containers.length,scoreCost,scoreUnused,scoreHeight],score=objective==='lowest_cost'?[defaultScore[0],defaultScore[2],defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='shipping_cost'?[defaultScore[0],scoreBillable,defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='lowest_landed_cost'?[defaultScore[0],scoreLanded,defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='open_dimension_height'?[defaultScore[0],scoreAchievedHeight,defaultScore[1],defaultScore[2],defaultScore[3]]:objective==='maximum_value'?[defaultScore[0],scoreValueForgone,defaultScore[1],defaultScore[2],defaultScore[3]]:byVolume?[unpackedVolumePpm(nominalVolume(remaining),requestedVolume),defaultScore[0],defaultScore[1],defaultScore[2],defaultScore[3]]:defaultScore; const result={status,feasibility:{code:complete?'feasible':'unknown'},termination,optimality:{code:complete?'not_proven':'best_found'},complete,objective,algorithm:{profile:req.configuration?.solver_profile??'balanced',solver:solverName,duration_ms:0,seed:req.configuration?.seed??42,time_limit_reached:timeLimitReached,effort_limit_reached:effortLimitReached,candidates_evaluated:metrics.feasible_candidates,placements_attempted:metrics.orientations_considered,metrics},summary:{container_count:containers.length,packed_item_count:items.length-unpacked.length,unpacked_item_count:unpacked.length},score,containers,unpacked_items:unpacked,catalog_versions_used:catalogVersionsUsed(req.catalog_versions_used),warnings:['JavaScript fallback is active; build the Rust addon for the native portfolio'],alternatives:[]}; if(unpriceableDetail!=null)Object.defineProperty(result,'unpriceableDetail',{value:unpriceableDetail,enumerable:false,writable:false,configurable:true}); return finalizeOutermost(result)} @@ -2702,7 +3193,7 @@ function rebalanceContext(req,result){ const globalSupportPpm=Math.round((req.configuration?.minimum_support_ratio??0)*SUPPORT_SCALE); const items=new Map(); for(const raw of req.items??[]){ - const d=dims(raw.dimensions,unit),w=scalar(raw.weight??0,'g',WT); + 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)), @@ -2712,6 +3203,7 @@ function rebalanceContext(req,result){ nesting:raw.nesting_height==null?null:scalar(raw.nesting_height,unit,LEN), maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null, stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[], + stackingGroup:raw.stacking_group??null,accepts, ...parseShape(raw,dims(raw.dimensions,unit),unit, raw.nesting_height==null?null:scalar(raw.nesting_height,unit,LEN)), }) @@ -2722,7 +3214,7 @@ function rebalanceContext(req,result){ 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, + const template={...raw,d,outerD:raw.outer_dimensions?dims(raw.outer_dimensions,unit):d,bag:raw.deformable_boundary!=null, max:raw.max_payload==null?null:scalar(raw.max_payload,'g',WT), tare:scalar(raw.tare_weight??0,'g',WT),axleSpec, reservePpm:Math.round((raw.void_fill_reserve_ratio??0)*SUPPORT_SCALE), @@ -2751,7 +3243,7 @@ function rebalanceContext(req,result){ resultTicks(placement.dimensions?.width,`placements[${pindex}].dimensions.width`), resultTicks(placement.dimensions?.height,`placements[${pindex}].dimensions.height`), ]; - return {x:position.x-clear,y:position.y-clear,z:position.z-clear,pd,ed:pd.map(edge=>edge+2*clear), + return {x:position.x-clear,y:position.y-clear,z:position.z-clear,pd,ed:grown(pd,2*clear), r:placement.orientation,item,publicPlacement:placement} })} }); @@ -2785,6 +3277,7 @@ function rebalanceValid(context,result){ for(const [tag,maximum] of Object.entries(state.tmpl.tagLimits))if((tagCounts.get(tag)??0)>maximum)return false; if(usedVolume(state.placements)+volume(state.tmpl.d)*BigInt(state.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)>volume(state.tmpl.d))return false; if(axleOverloaded(state.tmpl,state.placements))return false; + if(state.tmpl.bag&&!bagAdmits(state.tmpl,state.placements))return false; const placed=[]; for(const candidate of [...state.placements].sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||compareCodePoints(a.item.id,b.item.id))){ if(!allowed(candidate,placed,state.tmpl,context.globalSupportPpm,{support_checks:0}))return false; @@ -2830,6 +3323,8 @@ function publicRebalancedContainers(req,context){ 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); 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 @@ -2973,7 +3468,7 @@ function replayLoadingNormalized(boxes,container,order,directions){validatePermu export function replayRemovalOrder(boxes,container,order,directions=ALL_DIRECTIONS){validateDirections(directions);const [normalized,dimensions]=sequenceInputs(boxes,container);replayRemovalNormalized(normalized,dimensions,order,directions)} export function replayLoadingOrder(boxes,container,order,directions=ALL_DIRECTIONS){validateDirections(directions);const [normalized,dimensions]=sequenceInputs(boxes,container);replayLoadingNormalized(normalized,dimensions,order,directions)} export function safeRemovalOrder(boxes,container,directions=ALL_DIRECTIONS){validateDirections(directions);const [normalized,dimensions]=sequenceInputs(boxes,container),dependencies=unloadingDependencies(normalized),present=new Set(normalized.map((_,index)=>index)),order=[]; - while(present.size){const chosen=[...present].sort((a,b)=>a-b).find(index=>dependencies[index].every(dependency=>!present.has(dependency))&&clearDirection(index,normalized,present,dimensions,directions)!=null);if(chosen==null)throw new SequenceError(present);order.push(chosen);present.delete(chosen)} + while(present.size){let chosen=null;for(let i=0;i!present.has(dependency))&&clearDirection(i,normalized,present,dimensions,directions)!=null){chosen=i;break}}if(chosen==null)throw new SequenceError(present);order.push(chosen);present.delete(chosen)} replayRemovalNormalized(normalized,dimensions,order,directions);return order} export function safeLoadingOrder(boxes,container,directions=ALL_DIRECTIONS){const order=safeRemovalOrder(boxes,container,directions).reverse();const [normalized,dimensions]=sequenceInputs(boxes,container);replayLoadingNormalized(normalized,dimensions,order,directions);return order} export function safeLoadingOrderForPlacements(placements,container,directions=ALL_DIRECTIONS){const dimensions=container.dimensions??container;const order=safeLoadingOrder(placements,dimensions,directions);verifyLoadingPrefixBusinessRules(placements,order,container);return order} @@ -3016,6 +3511,10 @@ export function placementReachability(boxes,container,stops=null,directions=ALL_ export function verifyLoadingPrefixBusinessRules(placements,order,container){ validatePermutation(placements,order); const maxDensity=container.max_stack_density??null,present=[]; + // Any scene may arrive here, validated or not, so the stacking-group rule is re-checked: + // only when some item declares a list, and only on the new step's own edges, since every + // earlier edge was judged at its own step. + const grouped=placements.some(raw=>(raw.accepts_stacking_groups??[]).length>0); order.forEach((index,step)=>{ const raw=placements[index],origin=raw.origin??{x:raw.x,y:raw.y,z:raw.z}; const dims=raw.dimensions??{length:raw.length,width:raw.width,height:raw.height}; @@ -3027,7 +3526,8 @@ export function verifyLoadingPrefixBusinessRules(placements,order,container){ z:sequenceInteger(origin.z,`placements[${index}].origin.z`), d,ed:d,w:raw.weight??0,maxTop:raw.max_top_load??null,maxStacked:raw.max_stacked_items??null, itemType:raw.item_type??null,nesting:raw.nesting_height==null?null:sequenceInteger(raw.nesting_height,`placements[${index}].nesting_height`), - stackable:raw.stackable??true,item:{groundRule:raw.ground_contact_rule??null}}; + stackable:raw.stackable??true,item:{groundRule:raw.ground_contact_rule??null}, + stackingGroup:raw.stacking_group??null,accepts:acceptedStackingGroups(raw)}; present.push(box); const needsLoads=maxDensity!=null||present.some(candidate=>candidate.maxTop!=null),graph=contactGraph(present),loads=needsLoads?topLoads(present,graph):null; if(overloaded(present,loads))throw new SequenceReplayError(index,step,'top_load_exceeded'); @@ -3035,6 +3535,10 @@ export function verifyLoadingPrefixBusinessRules(placements,order,container){ if(maxDensity!=null&&stackDensityExceeded(present,maxDensity,loads))throw new SequenceReplayError(index,step,'stack_density_exceeded'); const children=supportChildren(present,graph); if(present.some((b,i)=>b.stackable===false&&children[i].length>0))throw new SequenceReplayError(index,step,'non_stackable_item_has_load'); + if(grouped){const last=present.length-1; + if(graph.supporters[last].some(([k])=>stackingRefusal(present[k],box)!==null) + ||children[last].some(k=>stackingRefusal(box,present[k])!==null)) + throw new SequenceReplayError(index,step,'stacking_group_incompatible')} if(!groundContactAllowed(box,present.slice(0,-1)))throw new SequenceReplayError(index,step,'ground_contact_violation') }) } diff --git a/index.d.ts b/index.d.ts index 3a38e9d..5b045df 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,9 +1,11 @@ export type ExactScalar=string|number|{value:string|number;unit?:string}; export interface Dimensions{length:ExactScalar;width:ExactScalar;height:ExactScalar} -export interface Item{id:string;quantity?:number;dimensions:Dimensions;weight?:ExactScalar;allowed_rotations?:string[];keep_upright?:boolean;stackable?:boolean;must_be_on_floor?:boolean;max_top_load?:ExactScalar;max_stacked_items?:number;nesting_height?:ExactScalar;minimum_support_ratio?:number;ground_contact_rule?:'free'|'covered'|'single'|'multiple';group?:string;tags?:string[];incompatible_tags?:string[];eligible_container_tags?:string[];priority?:number;value?:number;metadata?:Record} +export interface Item{id:string;quantity?:number;dimensions:Dimensions;weight?:ExactScalar;allowed_rotations?:string[];keep_upright?:boolean;stackable?:boolean;must_be_on_floor?:boolean;max_top_load?:ExactScalar;max_stacked_items?:number;nesting_height?:ExactScalar;minimum_support_ratio?:number;ground_contact_rule?:'free'|'covered'|'single'|'multiple';group?:string;tags?:string[];incompatible_tags?:string[];eligible_container_tags?:string[];priority?:number;value?:number;stacking_group?:string|null;accepts_stacking_groups?:string[];metadata?:Record} export interface Axle{position:ExactScalar;max_load?:ExactScalar} -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;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';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}} +/**: 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}>}>} +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'} @@ -32,7 +34,7 @@ export interface SequenceDimensions{length:number;width:number;height:number} export interface SequenceBox{origin:SequencePoint;dimensions:SequenceDimensions} export interface SequenceStep{index:number;direction:MovementDirection;depends_on:number[]} export interface Reachability{index:number;reachable:boolean;blocked_by_support:number[];blocked_by_neighbors:number[];blocked_by_route:number[]} -export interface SequenceBusinessRulePlacement extends SequenceBox{weight?:number;max_top_load?:number;max_stacked_items?:number;stackable?:boolean;ground_contact_rule?:'free'|'covered'|'single'|'multiple'} +export interface SequenceBusinessRulePlacement extends SequenceBox{weight?:number;max_top_load?:number;max_stacked_items?:number;stackable?:boolean;ground_contact_rule?:'free'|'covered'|'single'|'multiple';stacking_group?:string|null;accepts_stacking_groups?:string[]} export interface SequenceBusinessRuleContainer{max_stack_density?:number} export const ALL_DIRECTIONS:readonly MovementDirection[]; export class InvalidDirectionError extends RangeError{readonly code:'invalid_direction';readonly direction:string} diff --git a/index.js b/index.js index bf1720b..a582b5e 100644 --- a/index.js +++ b/index.js @@ -80,7 +80,7 @@ export function rebalanceWeight(request, result, { maxMoves = 64 } = {}) { return rebalanceFallback(request, result, { maxMoves }); } -export const version = () => native?.version?.() ?? '1.5.0-js-fallback'; +export const version = () => native?.version?.() ?? '1.6.0-js-fallback'; /** * The exported commercial and control-plane API: a quote, a policy decision and catalog diff --git a/made-to-measure.js b/made-to-measure.js new file mode 100644 index 0000000..46afddd --- /dev/null +++ b/made-to-measure.js @@ -0,0 +1,95 @@ +/** + * Made-to-measure containers (docs/MADE-TO-MEASURE-CONTAINERS.md "1.6 implementation + * contract"): the exact arithmetic every engine shares. + * + * A machine is `{minimum, increment, price, tarePerSquareMetre, allowance}`: three-element + * tick arrays and integer prices. The declared container's inner dimensions are the largest + * box it can cut. Ticks fit a safe integer; every area, volume and product is a BigInt, so + * the rungs, the box and its price agree with Python, PHP and Rust to the unit. + * + * This module is package-internal: `fallback.js` and `request-errors.js` import it. + */ + +/** One square metre in square length ticks: a tick is 1/16 000 000 m. */ +const SQUARE_METRE_TICKS2 = 16000000n * 16000000n; +/** Each compact rung's side as a fraction of the integer cube root of the round's volume. */ +const RUNG_FACTORS = [[5n, 4n], [3n, 2n], [2n, 1n]]; + +/** The smallest size the machine can cut on `axis` that is at least `value`. */ +export function grid(machine, axis, value) { + const minimum = machine.minimum[axis], step = machine.increment[axis]; + // Remainder and exact quotient only: no floating-point division decides a size. + const span = Math.max(value, minimum) - minimum, remainder = span % step; + return minimum + (span - remainder) + (remainder ? step : 0); +} + +/** The inner surface of a closed box, 2(LW + LH + WH), in square ticks. */ +export function surfaceArea([length, width, height]) { + const [l, w, h] = [BigInt(length), BigInt(width), BigInt(height)]; + return 2n * (l * w + l * h + w * h); +} + +/** + * ceil(rate * area / 1 m^2), as `p*q + ceil(p*r / T^2)` with `q, r = divmod(area, T^2)`: the + * split Rust needs to stay inside i128, kept here so the engines share one formula. + */ +export function perSquareMetre(rate, area) { + const price = BigInt(rate), whole = area / SQUARE_METRE_TICKS2, fraction = area % SQUARE_METRE_TICKS2; + return price * whole + (price * fraction + SQUARE_METRE_TICKS2 - 1n) / SQUARE_METRE_TICKS2; +} + +/** A box's price: the container's fee plus its material, rounded up. Grows with every side. */ +export const boxCost = (price, fee, sides) => BigInt(fee) + perSquareMetre(price, surfaceArea(sides)); + +/** The smallest non-negative c with c^3 >= value: doubling, then bisection, O(log value) steps. */ +export function icbrtCeil(value) { + let high = 1n; + while (high * high * high < value) high *= 2n; + let low = 0n; + while (low < high) { + const middle = (low + high) / 2n; + if (middle * middle * middle >= value) high = middle; + else low = middle + 1n; + } + return low; +} + +/** + * The rungs one trial tries, ascending on every axis, the machine maximum last. `edges` are each + * remaining instance's envelope edges (nominal plus twice the clearance). O(r) over the r + * instances plus the cube root; the list depends only on their multiset. + */ +export function solvingEnvelopes(machine, maximum, edges) { + let total = 0n; + const floors = [0, 0, 0]; + for (const instance of edges) { + total += BigInt(instance[0]) * BigInt(instance[1]) * BigInt(instance[2]); + const ranked = [...instance].sort((a, b) => b - a); + for (let rank = 0; rank < 3; rank++) floors[rank] = Math.max(floors[rank], ranked[rank]); + } + const side = icbrtCeil(total), rungs = []; + for (const [numerator, denominator] of RUNG_FACTORS) { + const scaled = Number((side * numerator + denominator - 1n) / denominator); + const rung = [0, 1, 2].map((axis) => Math.min(maximum[axis], grid(machine, axis, Math.max(scaled, floors[axis])))); + const kept = rungs.some((other) => other.every((value, axis) => value === rung[axis])); + if (!kept && BigInt(rung[0]) * BigInt(rung[1]) * BigInt(rung[2]) >= total) rungs.push(rung); + } + if (!rungs.some((rung) => rung.every((value, axis) => value === maximum[axis]))) rungs.push(maximum.slice()); + return rungs; +} + +/** + * The box cut for an achieved extent: its inner and outer sides, price and tare. O(1). The grid + * never rounds below the extent or past the maximum, which is what lets the placements stand. + */ +export function fabricate(machine, maximum, achieved, fee, tare) { + const sides = [0, 1, 2].map((axis) => grid(machine, axis, achieved[axis])); + if (sides.some((side, axis) => side > maximum[axis])) throw new Error('fabricated box exceeds the machine'); + const area = surfaceArea(sides); + return { + sides, + outer: sides.map((side, axis) => side + machine.allowance[axis]), + cost: Number(BigInt(fee) + perSquareMetre(machine.price, area)), + tare: Number(BigInt(tare) + perSquareMetre(machine.tarePerSquareMetre, area)), + }; +} diff --git a/package.json b/package.json index 4486ddc..7ca2288 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name":"@packvium/engine", - "version":"1.5.0", + "version":"1.6.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"], @@ -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"}}, - "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","request-errors.js","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","revisions.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","request-errors.js","made-to-measure.js","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","revisions.d.ts","README.md","SECURITY.md"], "engines":{"node":">=16"}, "scripts":{ "test":"node test/run-tests.mjs", diff --git a/request-errors.js b/request-errors.js index 5adcd62..0c455d2 100644 --- a/request-errors.js +++ b/request-errors.js @@ -22,9 +22,10 @@ import { compareCodePoints } from './commerce-model.js'; import { jsonSpelling } from './canonical-json.js'; +import { boxCost } from './made-to-measure.js'; export const SOLVER_PROFILES = Object.freeze(['fast', 'balanced', 'quality', 'exact_small']); -export const OBJECTIVES = Object.freeze(['default', 'lowest_cost', 'shipping_cost', 'lowest_landed_cost', 'open_dimension_height', 'maximum_value']); +export const OBJECTIVES = Object.freeze(['default', 'lowest_cost', 'shipping_cost', 'lowest_landed_cost', 'open_dimension_height', 'maximum_value', 'maximum_volume']); export const ACCESS_DIRECTIONS = Object.freeze(['+x', '-x', '+y', '-y', '+z', '-z']); /** The request is not one any engine may answer. Nothing was solved. */ @@ -171,7 +172,8 @@ export function checkRequest(data, measures) { items.forEach((raw, index) => checkItem(raw, pointer('items', index), length, weight)); requireUniqueIds(items, 'items'); const containers = requiredList(request, 'containers', ''); - containers.forEach((raw, index) => checkContainer(raw, pointer('containers', index), length, weight)); + const lengthTicks = (value) => measures.length.parse(value, unit); + containers.forEach((raw, index) => checkContainer(raw, pointer('containers', index), length, weight, lengthTicks)); requireUniqueIds(containers, 'containers'); } @@ -214,9 +216,20 @@ function checkItem(raw, where, length, weight) { optional(item, 'max_compression_pressure_kpa', where, atLeast(0)); optional(item, 'minimum_support_ratio', where, requireRatio); optional(item, 'compression_ratio', where, requireUnbounded); + optional(item, 'stacking_group', where, stackingGroupName); + optional(item, 'accepts_stacking_groups', where, stackingGroupNames); } -function checkContainer(raw, where, length, weight) { +function stackingGroupName(value, field) { + if (typeof value !== 'string') throw new InvalidRequestError('wrong_type', field, 'must be a string'); + if (value === '') throw new InvalidRequestError('invalid_value', field, 'must not be empty'); +} + +function stackingGroupNames(raw, where) { + requireList(raw, where).forEach((name, index) => stackingGroupName(name, where + pointer(index))); +} + +function checkContainer(raw, where, length, weight, lengthTicks) { const container = requireObject(raw, where); requiredString(container, 'id', where); optional(container, 'quantity', where, atLeast(1)); @@ -230,6 +243,68 @@ function checkContainer(raw, where, length, weight) { optional(container, 'tag_limits', where, tagLimits); optional(container, 'rate_table', where, rateTable); optional(container, 'obstacles', where, (v, f) => obstacles(v, f, length)); + optional(container, 'deformable_boundary', where, (v, f) => deformableBoundary(container, v, f, where)); + optional(container, 'made_to_measure', where, (v, f) => madeToMeasure(container, v, f, where, { length, weight, lengthTicks })); +} + +/** Every key the request schema's `made_to_measure` declares; it sets `additionalProperties: false`. */ +const MADE_TO_MEASURE_FIELDS = ['cost_per_square_metre_minor', 'increment', 'minimum', 'outer_allowance', + 'tare_per_square_metre']; + +/** + * The made-to-measure block's shape top to bottom, then each axis's four rules, then the + * price ceiling. Run after every other container field, so an existing error always wins. O(1). + */ +function madeToMeasure(container, raw, field, where, { length, weight, lengthTicks }) { + const machine = requireObject(raw, field); + requireKnownFields(machine, field, MADE_TO_MEASURE_FIELDS); + for (const name of ['minimum', 'increment']) required(machine, name, field); + for (const name of ['minimum', 'increment', 'outer_allowance']) optional(machine, name, field, (v, f) => dimensions(v, f, length)); + optional(machine, 'cost_per_square_metre_minor', field, atLeast(0)); + optional(machine, 'tare_per_square_metre', field, weight); + if (container.outer_dimensions != null) { + throw new InvalidRequestError('not_allowed', where + pointer('outer_dimensions'), 'cannot be combined with made_to_measure'); + } + const ticks = (sides) => SIDES.map((side) => lengthTicks(sides[side])); + const inner = ticks(container.inner_dimensions), minimum = ticks(machine.minimum), increment = ticks(machine.increment); + SIDES.forEach((side, axis) => { + if (minimum[axis] === 0) throw new InvalidRequestError('invalid_value', field + pointer('minimum', side), 'must be positive'); + if (increment[axis] === 0) throw new InvalidRequestError('invalid_value', field + pointer('increment', side), 'must be positive'); + if (minimum[axis] > inner[axis]) { + throw new InvalidRequestError('invalid_value', field + pointer('minimum', side), 'exceeds inner_dimensions'); + } + if ((inner[axis] - minimum[axis]) % increment[axis] !== 0) { + throw new InvalidRequestError('invalid_value', field + pointer('increment', side), 'does not divide inner_dimensions minus minimum'); + } + }); + if (boxCost(machine.cost_per_square_metre_minor ?? 0, container.cost_minor ?? 0, inner) > BigInt(MAX_EXACT_MAGNITUDE)) { + throw new InvalidRequestError('invalid_value', field + pointer('cost_per_square_metre_minor'), + `prices the largest box above ${MAX_EXACT_MAGNITUDE}`); + } +} + +/**: the kinds `deformable_boundary` admits. */ +export const DEFORMABLE_BOUNDARY_KINDS = Object.freeze(['flat_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']); + +function deformableBoundary(container, raw, field, where) { + const boundary = requireObject(raw, field); + requireOneOf(required(boundary, 'kind', field), field + pointer('kind'), DEFORMABLE_BOUNDARY_KINDS); + requireKnownFields(boundary, field, ['kind']); + 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'); + } + } +} + +// 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; + if (name === 'void_fill_reserve_ratio') return value != null && value > 0; + return value != null; } function accessDirections(raw, where) { diff --git a/test/beam-ordering-hulls.test.mjs b/test/beam-ordering-hulls.test.mjs new file mode 100644 index 0000000..90a04b4 --- /dev/null +++ b/test/beam-ordering-hulls.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { packFallback } from '../fallback.js'; + +/** + * The `quality` beam's ordering bound against `convex_hull` items. + * + * The bound compares the future items' volumes with the occupied free space, and a hull + * occupies its own solid. Counted by declared box, every wedge claimed a whole cube cell, + * so a branch keeping room for wedge pairs looked unable to place them and lost its place + * in the beam. Pinned by counted work, never by the clock. + */ + +const vertex = ([x, y, z]) => ({ x: String(x), y: String(y), z: String(z) }); + +const wedge = (id, quantity, upper) => { + const base = upper ? [[100, 100], [100, 0], [0, 100]] : [[0, 0], [100, 0], [0, 100]]; + return { + id, quantity, weight: '1 kg', + dimensions: { length: '100', width: '100', height: '100' }, + shape_type: 'convex_hull', + hull_vertices: [0, 100].flatMap((z) => base.map(([x, y]) => vertex([x, y, z]))), + }; +}; + +const cuboid = (id, quantity, length, width, height) => ({ + id, quantity, weight: '500 g', + dimensions: { length: String(length), width: String(width), height: String(height) }, +}); + +test('the quality beam keeps room for wedge pairs whose boxes overflow the crate', () => { + // Six cube cells. Four wedge pairs fill four of them exactly, so ten of the thirteen + // items fit; ranked by box volume the beam settled for nine. + const result = packFallback({ + units: { length: 'mm' }, + configuration: { + solver_profile: 'quality', time_limit_ms: 600_000, max_containers: 1, + effort_budget: { + max_candidates_evaluated: 10_000_000, max_placement_attempts: 10_000_000, + max_search_nodes: 10_000_000, + }, + }, + items: [ + wedge('lower', 4, false), wedge('upper', 4, true), + cuboid('cube', 3, 100, 100, 100), cuboid('slab', 2, 200, 100, 50), + ], + containers: [{ + id: 'crate', quantity: 1, + inner_dimensions: { length: '300', width: '200', height: '100' }, + }], + }); + + assert.equal(result.termination.any_start_truncated, false); + assert.deepEqual(result.score, [3, 1, 0, 0, 1_000_000]); +}); diff --git a/test/block-enumeration.test.mjs b/test/block-enumeration.test.mjs new file mode 100644 index 0000000..80333ea --- /dev/null +++ b/test/block-enumeration.test.mjs @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { __blockGroupForTests } from '../fallback.js'; +import { compareCodePoints } from '../commerce-model.js'; + +/** + * The block enumeration's shortcuts, held to the per-node walk they replaced. + * + * The block solver used to visit every lattice node of every (space, type, rotation) + * group one by one, check the counted limits and the clock at each, and rank every node. + * It now counts a group's nodes in closed form, finds the node a limit stops at from the + * node indices alone, reads only each group's first largest count, and keeps a type's + * tally per space until the type's capacity moves. Each reference below is the 1.6.0 + * code path written out plainly, and every case must agree with it exactly: the same + * nodes, the same stop node, the same clock reads, the same leading block. + */ + +const { nodes, stop, best, typeTally, space: blockSpace, subtract } = __blockGroupForTests; + +// A deterministic stream of integers, so every run tries the same cases. +const stream = (seed) => { + let state = seed >>> 0; + return (low, high) => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + return low + (state % (high - low + 1)); + }; +}; + +// A clock that has expired after its `reads`-th read, counting every read. +const clockExpiringAfter = (reads) => ({ + reads: 0, + expired() { this.reads += 1; return this.reads > reads; }, +}); + +// The 1.6.0 group walk: one node at a time, counted limits first, the clock every 64th. +const walkGroup = (maximumX, maximumY, maximumZ, capacity, start, stopAt, deadline) => { + let visited = start, count = 0, bestNx = 0, bestNy = 0, bestNz = 0; + for (let nx = 1; nx <= Math.min(maximumX, capacity); nx++) { + for (let ny = 1; ny <= Math.min(maximumY, Math.floor(capacity / nx)); ny++) { + if (visited >= stopAt || ((visited & 63) === 0 && deadline.expired())) return { stopped: visited }; + visited++; + const nz = Math.min(maximumZ, Math.floor(capacity / (nx * ny))); + if (nx * ny * nz > count) { count = nx * ny * nz; bestNx = nx; bestNy = ny; bestNz = nz; } + } + } + return { stopped: -1, visited: visited - start, count, nx: bestNx, ny: bestNy, nz: bestNz }; +}; + +test('a group is counted, stopped and ranked exactly as the per-node walk did', () => { + const next = stream(7); + for (let round = 0; round < 20_000; round++) { + const maximumX = next(0, 14), maximumY = next(0, 14), maximumZ = next(0, 6), capacity = next(1, 48); + const start = next(0, 300), stopAt = next(0, 3) === 0 ? Infinity : start + next(0, 120); + const reads = next(0, 3) === 0 ? Infinity : next(0, 3); + const referenceClock = clockExpiringAfter(reads); + const reference = walkGroup(maximumX, maximumY, maximumZ, capacity, start, stopAt, referenceClock); + const maximumNx = Math.min(maximumX, capacity), groupNodes = nodes(maximumNx, maximumY, capacity); + const clock = clockExpiringAfter(reads), stoppedAt = stop(start, groupNodes, stopAt, clock); + const where = { maximumX, maximumY, maximumZ, capacity, start, stopAt, reads }; + assert.equal(stoppedAt, reference.stopped, JSON.stringify(where)); + assert.equal(clock.reads, referenceClock.reads, JSON.stringify(where)); + if (reference.stopped >= 0) continue; + assert.equal(groupNodes, reference.visited, JSON.stringify(where)); + const upper = Math.min(capacity, maximumNx * maximumY * maximumZ); + if (upper === 0) { assert.equal(reference.count, 0, JSON.stringify(where)); continue; } + const { count, nx, ny, nz } = best(maximumNx, maximumY, maximumZ, capacity, upper); + assert.deepEqual([count, nx, ny, nz], [reference.count, reference.nx, reference.ny, reference.nz], JSON.stringify(where)); + } +}); + +test('a stop across many groups lands on the node one long walk would reach', () => { + // Group boundaries do not matter: a run of groups stops where one walk over all of + // their nodes stops, so a type or a space can be counted in one step. + const next = stream(11); + for (let round = 0; round < 2000; round++) { + const sizes = Array.from({ length: next(1, 6) }, () => next(0, 90)); + const start = next(0, 200), stopAt = next(0, 2) === 0 ? Infinity : start + next(0, 300), reads = next(0, 4); + const whole = stop(start, sizes.reduce((total, size) => total + size, 0), stopAt, clockExpiringAfter(reads)); + const clock = clockExpiringAfter(reads); + let at = start, piecewise = -1; + for (const size of sizes) { + piecewise = stop(at, size, stopAt, clock); + if (piecewise >= 0) break; + at += size; + } + assert.equal(piecewise, whole, JSON.stringify({ sizes, start, stopAt, reads })); + } +}); + +// The exact ranking key of the 1.6.0 block solver, for one block in one space. +const exactKey = (space, itemId, rotation, volume, count, nx, ny, nz, volumeFirst) => { + const used = BigInt(count) * volume, fill = used * 1_000_000n / space.volume; + const lead = volumeFirst ? [-used, -fill, -BigInt(count)] : [-BigInt(count), -fill, -used]; + return [...lead, space.z, space.y, space.x, itemId, rotation, nx, ny, nz]; +}; +const compareKeys = (left, right) => { + for (let index = 0; index < left.length; index++) { + const a = left[index], b = right[index]; + const difference = typeof a === 'string' ? compareCodePoints(a, b) : a < b ? -1 : a > b ? 1 : 0; + if (difference) return difference; + } + return 0; +}; +const ROTATIONS = { LWH: [0, 1, 2], WLH: [1, 0, 2], LHW: [0, 2, 1], HLW: [2, 0, 1], WHL: [1, 2, 0], HWL: [2, 1, 0] }; + +test('a type tally leads with the block the exact key ranks first, in either mode', () => { + const next = stream(23); + for (let round = 0; round < 3000; round++) { + const d = [next(1, 9), next(1, 9), next(1, 9)], clear = next(0, 1); + const names = Object.keys(ROTATIONS).filter(() => next(0, 2) > 0); + if (!names.length) names.push('HWL'); + const lattices = names.map((rotation) => { + const pd = ROTATIONS[rotation].map((axis) => d[axis]); + return { rotation, pd, ed: pd.map((edge) => edge + 2 * clear), volume: BigInt(pd[0] * pd[1] * pd[2]) }; + }); + const space = blockSpace(next(0, 5), next(0, 5), next(0, 5), [next(1, 40), next(1, 40), next(1, 30)]); + const capacity = next(1, 60), tally = new Array(7).fill(-1); + typeTally(space, lattices, capacity, tally, 0); + for (const volumeFirst of [false, true]) { + let total = 0, leader = null, leaderLattice = -1, leaderGroup = null; + lattices.forEach((lattice, index) => { + if (lattice.ed[0] > space.d[0] || lattice.ed[1] > space.d[1]) return; + const maximumX = Math.floor(space.d[0] / lattice.ed[0]), maximumY = Math.floor(space.d[1] / lattice.ed[1]); + const maximumZ = Math.floor(space.d[2] / lattice.ed[2]); + for (let nx = 1; nx <= Math.min(maximumX, capacity); nx++) { + for (let ny = 1; ny <= Math.min(maximumY, Math.floor(capacity / nx)); ny++) { + total++; + const nz = Math.min(maximumZ, Math.floor(capacity / (nx * ny))); + if (nz <= 0) continue; + const key = exactKey(space, 'type', lattice.rotation, lattice.volume, nx * ny * nz, nx, ny, nz, volumeFirst); + if (leader === null || compareKeys(key, leader) < 0) { + leader = key; leaderLattice = index; leaderGroup = [nx * ny * nz, nx, ny, nz]; + } + } + } + }); + const where = JSON.stringify({ d, clear, names, space: geometry([space]), capacity, volumeFirst }); + assert.equal(tally[0], capacity, where); + assert.equal(tally[1], total, where); + if (leader === null) { assert.equal(tally[2], 0, where); continue; } + assert.deepEqual([tally[2], tally[3], tally[4], tally[5], tally[6]], [leaderGroup[0], leaderLattice, ...leaderGroup.slice(1)], where); + } + } +}); + +// The 1.6.0 carve: every survivor and slab keyed, repeats dropped through a Map. +const carveReference = (spaces, box) => { + const volume = (d) => BigInt(d[0]) * BigInt(d[1]) * BigInt(d[2]); + const intersects = (a, b) => a.x < b.x + b.d[0] && a.x + a.d[0] > b.x && a.y < b.y + b.d[1] && a.y + a.d[1] > b.y + && a.z < b.z + b.d[2] && a.z + a.d[2] > b.z; + const contains = (outer, inner) => outer.x <= inner.x && outer.y <= inner.y && outer.z <= inner.z + && outer.x + outer.d[0] >= inner.x + inner.d[0] && outer.y + outer.d[1] >= inner.y + inner.d[1] + && outer.z + outer.d[2] >= inner.z + inner.d[2]; + const out = [], fresh = new Set(); + const push = (x, y, z, X, Y, Z) => { + if (X > x && Y > y && Z > z) { const part = { x, y, z, d: [X - x, Y - y, Z - z] }; out.push(part); fresh.add(part); } + }; + for (const space of spaces) { + const X = space.x + space.d[0], Y = space.y + space.d[1], Z = space.z + space.d[2]; + const bX = box.x + box.d[0], bY = box.y + box.d[1], bZ = box.z + box.d[2]; + if (!intersects(space, box)) { out.push(space); continue; } + push(space.x, space.y, space.z, Math.min(X, box.x), Y, Z); push(Math.max(space.x, bX), space.y, space.z, X, Y, Z); + push(space.x, space.y, space.z, X, Math.min(Y, box.y), Z); push(space.x, Math.max(space.y, bY), space.z, X, Y, Z); + push(space.x, space.y, space.z, X, Y, Math.min(Z, box.z)); push(space.x, space.y, Math.max(space.z, bZ), X, Y, Z); + } + const unique = new Map(out.map((space) => [[space.x, space.y, space.z, ...space.d].join(':'), space])); + const kept = [...unique.values()].sort((a, b) => a.z - b.z || a.y - b.y || a.x - b.x || (volume(a.d) > volume(b.d) ? -1 : 1)); + let result = kept.filter((space, index) => !fresh.has(space) + || !kept.some((other, otherIndex) => index !== otherIndex && contains(other, space))); + if (result.length > 256) { + result = result.sort((a, b) => (volume(a.d) > volume(b.d) ? -1 : volume(a.d) < volume(b.d) ? 1 : a.z - b.z || a.y - b.y || a.x - b.x)) + .slice(0, 256).sort((a, b) => a.z - b.z || a.y - b.y || a.x - b.x); + } + return result; +}; +const geometry = (spaces) => spaces.map((space) => [space.x, space.y, space.z, ...space.d].join(':')); + +// The block scan writes this tally itself, without a call, for a space narrower than every +// rotation's footprint on one axis; it must be the tally `typeTally` would have written. +test('a space narrower than every rotation on one axis tallies as no nodes and no block', () => { + const next = stream(29); + let narrow = 0; + for (let round = 0; round < 3000; round++) { + const d = [next(1, 9), next(1, 9), next(1, 9)], clear = next(0, 1); + const names = Object.keys(ROTATIONS).filter(() => next(0, 2) > 0); + if (!names.length) names.push('HWL'); + const lattices = names.map((rotation) => { + const pd = ROTATIONS[rotation].map((axis) => d[axis]); + return { rotation, pd, ed: pd.map((edge) => edge + 2 * clear), volume: BigInt(pd[0] * pd[1] * pd[2]) }; + }); + const minimumX = Math.min(...lattices.map((lattice) => lattice.ed[0])); + const minimumY = Math.min(...lattices.map((lattice) => lattice.ed[1])); + const space = blockSpace(0, 0, 0, [next(1, 12), next(1, 12), next(1, 30)]); + if (space.d[0] >= minimumX && space.d[1] >= minimumY) continue; + narrow++; + const capacity = next(1, 60), tally = new Array(7).fill(-1); + typeTally(space, lattices, capacity, tally, 0); + assert.deepEqual(tally, [capacity, 0, 0, -1, 0, 0, 0], JSON.stringify({ d, clear, names, d2: space.d })); + } + assert.ok(narrow > 500, `only ${narrow} narrow spaces drawn`); +}); + +test('carving keys only the slabs and keeps the spaces, and their order, it always kept', () => { + const next = stream(41); + for (let scene = 0; scene < 300; scene++) { + const container = [next(10, 60), next(10, 60), next(10, 60)]; + let reference = [{ x: 0, y: 0, z: 0, d: container }], spaces = [blockSpace(0, 0, 0, container.slice())]; + for (let step = 0; step < 40 && spaces.length; step++) { + // A block anchored at a space's origin, as the solver anchors every block. + const anchor = spaces[next(0, spaces.length - 1)]; + const box = { x: anchor.x, y: anchor.y, z: anchor.z, d: anchor.d.map((edge) => next(1, edge)) }; + const metrics = { space_partitions: 0 }; + spaces = subtract(spaces, box, metrics); + reference = carveReference(reference, box); + assert.deepEqual(geometry(spaces), geometry(reference), JSON.stringify({ scene, step })); + assert.ok(spaces.every((space) => space.volume === BigInt(space.d[0]) * BigInt(space.d[1]) * BigInt(space.d[2]))); + assert.ok(metrics.space_partitions > 0); + } + } +}); diff --git a/test/equal-volume-order.test.mjs b/test/equal-volume-order.test.mjs new file mode 100644 index 0000000..025c9ec --- /dev/null +++ b/test/equal-volume-order.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { packFallback } from '../fallback.js'; + +/** + * Equal-volume ties and the `quality` layer start. + * + * Cartons and drums have the same volume and the same longest edge, and neither may carry + * anything. Only the drums, the heavier of the two, leave room for every carton: they + * cannot stand on the pallets, the cartons can. Pinned by counted work, never by the clock. + */ + +const cube = (id, quantity, height, weight, extra) => ({ + id, quantity, weight, + dimensions: { length: '100', width: '100', height: String(height) }, + ...extra, +}); + +const scene = (solverProfile, names = { carton: 'carton', drum: 'drum' }) => ({ + units: { length: 'mm' }, + configuration: { + solver_profile: solverProfile, time_limit_ms: 300_000, minimum_support_ratio: 1, + effort_budget: { + max_candidates_evaluated: 1_000_000, max_placement_attempts: 1_000_000, + max_search_nodes: 1_000_000, + }, + }, + items: [ + cube('pallet', 2, 110, '40', { stacking_group: 'A', accepts_stacking_groups: ['A', 'B'] }), + cube(names.carton, 2, 100, '10', { stacking_group: 'B', stackable: false }), + cube(names.drum, 2, 100, '20', { stacking_group: 'C', stackable: false }), + ], + containers: [ + { id: 'bay', quantity: 1, inner_dimensions: { length: '320', width: '100', height: '210' } }, + ], +}); + +test('an equal-volume tie goes to the heavier item, not the earlier id', () => { + // Renamed so that the carton sorts after the drum: the answer must not move. + for (const names of [{ carton: 'carton', drum: 'drum' }, { carton: 'zcarton', drum: 'drum' }]) { + const result = packFallback(scene('balanced', names)); + assert.equal(result.summary.packed_item_count, 5, JSON.stringify(names)); + } +}); + +test('quality packs the tie as well as balanced does', () => { + const quality = packFallback(scene('quality')); + assert.equal(quality.summary.packed_item_count, 5); + assert.deepEqual(quality.score, packFallback(scene('balanced')).score); +}); + +test('the quality layer start keeps its own base-area order', () => { + const pinned = scene('quality'); + pinned.configuration.solvers = ['layer']; + const result = packFallback(pinned); + assert.equal(result.algorithm.solver, 'layer:javascript_fallback'); + assert.equal(result.summary.packed_item_count, 5); +}); diff --git a/test/exact-small-hulls.test.mjs b/test/exact-small-hulls.test.mjs new file mode 100644 index 0000000..18dce47 --- /dev/null +++ b/test/exact-small-hulls.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { packFallback } from '../fallback.js'; + +/** + * Exact-small's floors against `convex_hull` items. + * + * The score counts the solid a hull occupies, so the capacity, unused-volume and height + * floors must too. Built from declared box volumes they pruned a feasible packing and left + * the early exit unreachable; both cases are pinned by counted work, never by the clock. + */ + +const LONG_ENOUGH_MS = 300_000; + +const vertex = ([x, y, z]) => ({ x: String(x), y: String(y), z: String(z) }); + +const hull = (id, [length, width, height], corners) => ({ + id, quantity: 1, + dimensions: { length: String(length), width: String(width), height: String(height) }, + shape_type: 'convex_hull', hull_vertices: corners.map(vertex), +}); + +const cuboid = (id, length, width, height) => ({ + id, quantity: 1, + dimensions: { length: String(length), width: String(width), height: String(height) }, +}); + +/** An octagonal-prism jar 80 x 80 x 100 mm: its solid is well short of its box. */ +const octagonalJar = () => { + const ring = [[23, 0], [57, 0], [80, 23], [80, 57], [57, 80], [23, 80], [0, 57], [0, 23]]; + return hull('jar3', [80, 80, 100], [0, 100].flatMap((z) => ring.map(([x, y]) => [x, y, z]))); +}; + +const exactSmallOnly = (maxSearchNodes) => ({ + solvers: ['exact_small'], solver_profile: 'exact_small', time_limit_ms: LONG_ENOUGH_MS, + // One ordering: these count one exact search's nodes. + multi_start_orders: 1, + effort_budget: { + max_candidates_evaluated: 1_000_000, max_placement_attempts: 1_000_000, + max_search_nodes: maxSearchNodes, + }, +}); + +test('exact-small seats two wedges whose boxes each fill the crate in one crate', () => { + // Counted by box volume each wedge claimed the whole crate, so the capacity floor said + // only one could ever be placed and the search opened a second crate. + const result = packFallback({ + units: { length: 'mm' }, + configuration: exactSmallOnly(1_000), + items: [ + hull('wedge-lower', [100, 100, 100], + [[0, 0, 0], [100, 0, 0], [0, 100, 0], [0, 0, 100], [100, 0, 100], [0, 100, 100]]), + hull('wedge-upper', [100, 100, 100], + [[100, 100, 0], [100, 0, 0], [0, 100, 0], [100, 100, 100], [100, 0, 100], [0, 100, 100]]), + ], + containers: [{ id: 'crate', inner_dimensions: { length: '100', width: '100', height: '100' } }], + }); + + assert.equal(result.complete, true); + assert.equal(result.containers.length, 1); + assert.deepEqual(result.score, [0, 1, 0, 0, 1_000_000]); +}); + +test('exact-small reaches its floor on a mailer order with a jar inside a counted budget', () => { + // The unused-volume floor summed the jar's box while the score counts its solid, so the + // floor sat below every reachable score and the search walked 89 thousand nodes before + // the fix. The first complete branch already meets the floor. + const result = packFallback({ + units: { length: 'mm' }, + configuration: exactSmallOnly(1_000), + items: [ + cuboid('phonecase0', 180, 100, 25), cuboid('socks1', 200, 100, 40), + cuboid('tshirt2', 280, 200, 30), octagonalJar(), cuboid('mugbox4', 120, 100, 110), + ], + containers: [{ id: 'frame', quantity: 1, inner_dimensions: { length: '610', width: '480', height: '300' } }], + }); + + assert.equal(result.complete, true); + assert.equal(result.algorithm.effort_limit_reached, false); + assert.equal(result.algorithm.metrics.search_nodes_expanded, 6); + assert.deepEqual(result.score, [0, 1, 0, 945_535, 333_333]); +}); diff --git a/test/fallback.test.mjs b/test/fallback.test.mjs index c611b91..faf18fa 100644 --- a/test/fallback.test.mjs +++ b/test/fallback.test.mjs @@ -263,7 +263,7 @@ test('explicit solver selection is reported honestly', () => { const ordered = packFallback(request( [cube('a', 50), cube('b', 40)], [box('c')], - { configuration: { solvers: ['grid', 'layer', 'extreme_points'] } }, + { configuration: { solvers: ['grid', 'layer', 'extreme_points'], multi_start_orders: 1 } }, )); assert.deepEqual( ordered.termination.starts.map(start => start.id), @@ -301,16 +301,17 @@ test('malformed public constraints fail admission instead of being ignored', () test('an unrouted item cannot bury routed cargo in a seeded restart', () => { const payload = request( - [cube('routed', 10, { stop_index: 0 }), cube('unrouted', 10)], + [cube('routed', 10, { stop_index: 0, priority: 1 }), cube('unrouted', 10)], [box('box', 10, 10, 20, { quantity: 1 })], - { configuration: { time_limit_ms: 60_000 } }, + { configuration: { time_limit_ms: 60_000, multi_start_orders: 1 } }, ); - // Seeded start 4 visits the routed item first. The old JS-only exemption for a - // candidate without stop_index then put the unrouted item above it. All languages - // define an absent stop as Infinity: it rides the whole route and may block routed - // cargo from below, but it may never be placed above that cargo. - const result = packFallback(payload, Date.now, null, 4); + // Priority leads every ordering, so the routed item is visited first. The old JS-only + // exemption for a candidate without stop_index then put the unrouted item above it. All + // languages define an absent stop as Infinity: it rides the whole route and may block + // routed cargo from below, but it may never be placed above that cargo. A seeded start + // reached this ordering until 1.6.0, when shuffles began keeping the lead keys. + const result = packFallback(payload); assert.deepEqual(validate(payload, result), []); assert.deepEqual(placements(result).map(placement => placement.item_type), ['routed']); @@ -1216,7 +1217,7 @@ test('compact lattice reports structured metrics that mirror legacy counters', ( const result = packFallback(request( [cube('a', 10, { quantity: 3 })], [box('c')], - { configuration: { require_placement_coordinates: false } }, + { configuration: { require_placement_coordinates: false, multi_start_orders: 1 } }, )); const { metrics } = result.algorithm; for (const field of [ @@ -1322,6 +1323,120 @@ test('maximum_value scores the total value of whatever is left unpacked', () => assert.equal(result.summary.unpacked_item_count, 1); }); +// ------------------------------------------------------------------ maximum volume +// +// The score leads with the share of the requested nominal volume left unpacked, in parts +// per million and rounded up; the default objective's first four keys follow it. Every +// expected number below is worked out by hand from the dimensions, and the validator's +// own recomputation -- which shares no code with the solver -- must agree with it. + +const byVolume = (items, containers, configuration = {}) => request( + items, containers, { configuration: { objective: 'maximum_volume', ...configuration } }, +); +const packedTypes = (result) => placements(result).map((placement) => placement.item_type).sort(); + +test('maximum_volume rounds the unpacked share up, so an incomplete answer never scores zero', () => { + // 1 mm^3 left behind out of 10^9 + 1: far below one part per million, reported as one. + const req = byVolume([cube('big', 1000), cube('speck', 1)], [box('c', 1000, 1000, 1000, { quantity: 1 })]); + const result = packFallback(req); + assert.equal(result.objective, 'maximum_volume'); + assert.deepEqual(packedTypes(result), ['big']); + assert.deepEqual(result.score, [1, 1, 1, 0, 0]); + assert.deepEqual(recomputeObjective(req, result), result.score); + assert.deepEqual(validate(req, result), []); +}); + +test('maximum_volume scores a complete answer zero and keeps the default keys behind it', () => { + const req = byVolume([cube('a', 100), cube('b', 50)], [box('c', 200, 100, 100, { cost_minor: 7 })]); + const result = packFallback(req); + assert.equal(result.complete, true); + // 2,000,000 mm^3 of container holding 1,125,000: 437,500 ppm unused. + assert.deepEqual(result.score, [0, 0, 1, 7, 437_500]); + assert.deepEqual(recomputeObjective(req, result), result.score); +}); + +test('maximum_volume reports the same vector from the uniform-lattice path', () => { + // One item type takes the closed-form path. Two of three fit: ceil(10^6 / 3). + const req = byVolume([cube('a', 100, { quantity: 3 })], [box('c', 200, 100, 100, { quantity: 1 })]); + const result = packFallback(req); + assert.deepEqual(result.score, [333_334, 1, 1, 0, 0]); + assert.deepEqual(recomputeObjective(req, result), result.score); +}); + +test('maximum_volume keeps one large box where the default keeps more pieces', () => { + // The large cube and the three small ones cannot share the container. Small: 375,000 + // of 1,375,000 mm^3 packed; large: 1,000,000 of it. + const items = [cube('large', 100), cube('small', 50, { quantity: 3 })]; + const containers = [box('c', 100, 100, 100, { quantity: 1 })]; + const configuration = { solver_profile: 'quality', time_limit_ms: 60_000 }; + const pieces = packFallback(request(items, containers, { configuration })); + assert.deepEqual(packedTypes(pieces), ['small', 'small', 'small']); + assert.equal(pieces.score[0], 1); + + const req = byVolume(items, containers, configuration); + const volume = packFallback(req); + assert.deepEqual(packedTypes(volume), ['large']); + assert.deepEqual(volume.score, [272_728, 3, 1, 0, 0]); + assert.deepEqual(recomputeObjective(req, volume), volume.score); +}); + +test('maximum_volume leads every solver and every start, not only the portfolio winner', () => { + const items = [cube('large', 100), cube('small', 50, { quantity: 3 })]; + const containers = [box('c', 100, 100, 100, { quantity: 1 })]; + const quality = { solver_profile: 'quality', time_limit_ms: 60_000 }; + for (const solver of ['extreme_points', 'homogeneous_blocks', 'layer', 'maximal_spaces', 'exact_small']) { + const result = packFallback(byVolume(items, containers, { ...quality, solvers: [solver] })); + assert.deepEqual(packedTypes(result), ['large'], solver); + } + // Three quality starts: large-first, then the two small-first orderings. The first wins. + const started = packFallback(byVolume(items, containers, + { ...quality, solvers: ['extreme_points'], multi_start_orders: 3 })); + assert.deepEqual(packedTypes(started), ['large']); + assert.equal(started.termination.starts.findIndex((start) => start.selected), 0); +}); + +test('maximum_volume exact search ranks by volume when nothing at all can be placed', () => { + const req = byVolume([cube('huge', 200)], [box('c', 100, 100, 100)], { solvers: ['exact_small'] }); + const result = packFallback(req); + assert.deepEqual(result.score, [1_000_000, 1, 0, 0, 0]); + assert.deepEqual(recomputeObjective(req, result), result.score); +}); + +test('maximum_volume opens the container type that takes the most volume, not the most pieces', () => { + // One container allowed. The small type holds all five small cubes and nothing else; + // the large type holds the large cube, which leaves no room for a small one. + const items = [cube('large', 100), cube('small', 10, { quantity: 5 })]; + const containers = [box('roomy', 100, 100, 100), box('snug', 50, 50, 50)]; + const pieces = packFallback(request(items, containers, { configuration: { max_containers: 1 } })); + assert.equal(pieces.containers[0].container_type, 'snug'); + assert.equal(pieces.summary.packed_item_count, 5); + + const req = byVolume(items, containers, { max_containers: 1 }); + const volume = packFallback(req); + assert.equal(volume.containers[0].container_type, 'roomy'); + // 5,000 of 1,005,000 mm^3 left behind: ceil(4975.12...). + assert.deepEqual(volume.score, [4_976, 5, 1, 0, 0]); + assert.deepEqual(recomputeObjective(req, volume), volume.score); +}); + +test('maximum_volume is deterministic: the same request twice gives the same JSON', () => { + // Bounded by effort, not the clock, so the two runs stop at the same place. + const req = byVolume( + [ + { id: 'slab', dimensions: mm(60, 40, 30), quantity: 6 }, + { id: 'tile', dimensions: mm(50, 50, 20), quantity: 5 }, + cube('block', 30, { quantity: 8 }), + ], + [box('c', 120, 100, 80, { quantity: 1 })], + { solver_profile: 'quality', time_limit_ms: 60_000, effort_budget: { max_search_nodes: 2_000 } }, + ); + const masked = (result) => JSON.stringify(result, (key, value) => (key === 'duration_ms' ? 0 : value)); + const first = packFallback(req); + assert.equal(masked(packFallback(req)), masked(first)); + assert.deepEqual(validate(req, first), []); + assert.deepEqual(recomputeObjective(req, first), first.score); +}); + // ---------------------------------------------------- landed cost // // A 200mm cube container against a `cm`/`kg` divisor of 8000 has a dimensional weight @@ -1566,15 +1681,15 @@ test('lowest_landed_cost refuses a request it cannot price', () => { }); // One pinned solver crams everything into the snug box, the other splits the load and -// prices it. `extreme_points` (volume-descending) seats the lid first and stacks every -// brick on it, so both boxes take all nine items in one round; both bill 3300 g, the -// tie falls to the snug box, and its ladder stops at 2000 g -- unpriceable. `layer` -// (height-descending) floors the bricks first, which walls the lid out of the spot the -// other order used, so it ships bricks in the snug box (1350 g billed, 900) and the lid -// in the roomy one (2500 g billed, 1500). +// prices it. `layer` (base-area-descending, as in Python and Rust) seats the thin lid +// first and stacks every brick on it, so both boxes take all nine items in one round; +// both bill 3300 g, the tie falls to the snug box, and its ladder stops at 2000 g -- +// unpriceable. `extreme_points` (volume-descending) floors the bricks first, which walls +// the lid out of the spot the other order used, so it ships bricks in the snug box and +// the lid in the roomy one (2400 in all). const splitPortfolio = (betaBracketG, extra = {}) => request( [ - { id: 'lid', dimensions: mm(300, 300, 100), weight: '2500 g' }, + { id: 'lid', dimensions: mm(300, 300, 10), weight: '2500 g' }, { id: 'brick', dimensions: mm(100, 100, 150), weight: '100 g', quantity: 8 }, ], [ @@ -1590,14 +1705,15 @@ const splitPortfolio = (betaBracketG, extra = {}) => request( minimum_support_ratio: 1, solvers: ['extreme_points', 'layer'], alternatives: 3, + multi_start_orders: 1, ...extra, }, }, ); test('a portfolio returns the priceable sibling instead of propagating a child run refusal', () => { - // The engine used to throw the no-published-price refusal inside the extreme_points - // child run, aborting the request its layer sibling could price at 2400. The refusal + // The engine used to throw the no-published-price refusal inside one child run, + // aborting the request its sibling could price at 2400. The refusal // now fires once, at the outermost frame, on the packing actually selected for // return -- the choke point Rust, Python and PHP already refuse at (second // review) -- so the priceable sibling wins on the ordinary score comparison. @@ -1610,7 +1726,7 @@ test('a portfolio returns the priceable sibling instead of propagating a child r }); test('alternatives never surface the unpriceable sentinel', () => { - // The losing extreme_points run carries score[1] = MAX_SAFE_INTEGER. The sentinel is + // The losing layer run carries score[1] = MAX_SAFE_INTEGER. The sentinel is // a search device, never an answer -- alternatives included (review): the run // is filtered out rather than offered as a packing costing 2^53-1 minor units. const filtered = packFallback(splitPortfolio(2600)); @@ -2377,7 +2493,7 @@ test('exact_small stops at an admissible complete objective floor', () => { [cube('cube', 45, { quantity: 8, allowed_rotations: ['LWH'] })], [box('bin', 90, 90, 90, { quantity: 1 })], { configuration: { - solvers: ['exact_small'], solver_profile: 'exact_small', exact_item_limit: 8, + solvers: ['exact_small'], solver_profile: 'exact_small', exact_item_limit: 8, multi_start_orders: 1, max_containers: 1, time_limit_ms: 60_000, effort_budget: { @@ -2438,8 +2554,10 @@ test('every requested restart runs and is recorded', () => { const one = build(1); const eight = build(8); - assert.equal(one.termination.starts.length, 1); - assert.equal(eight.termination.starts.length, 8); + // Two item types and nothing the lattice cannot express: each run ends with the block + // start `balanced` adds to its own. + assert.equal(one.termination.starts.length, 2); + assert.equal(eight.termination.starts.length, 9); assert.ok( eight.algorithm.metrics.search_nodes_expanded > one.algorithm.metrics.search_nodes_expanded, 'eight restarts must cost more search than one', @@ -2486,9 +2604,14 @@ test('all restarts share one absolute deadline and preserve unstarted records', { configuration: { multi_start_orders: 3, time_limit_ms: 1 } }, ), () => ++tick); - assert.equal(result.termination.starts.length, 3); + // The three orderings, then the block start `balanced` adds: it never began either. + assert.deepEqual(result.termination.starts.map((start) => start.id), [ + 'javascript_fallback', 'javascript_fallback:seeded_1', 'javascript_fallback:seeded_2', + 'homogeneous_blocks:javascript_fallback', + ]); assert.equal(result.termination.starts.filter((start) => start.started).length, 1); - assert.equal(result.termination.starts.filter((start) => !start.started).length, 2); + assert.equal(result.termination.starts.filter((start) => !start.started).length, 3); + assert.equal(result.algorithm.solver, 'javascript_fallback'); assert.equal(result.termination.global_deadline_reached, true); assert.equal(result.termination.code, 'time_limit'); }); @@ -2656,3 +2779,239 @@ test('spatial forks share reads but isolate writes from parents and siblings', ( assert.deepEqual(grandchild.cells.get(key), [0,4]); } }); + +// The block start `fast` and `balanced` add to their own. A restart budget of one drops +// it -- it is the last start -- which is how these tests obtain the profile's own answer. +const blockStartId = 'homogeneous_blocks:javascript_fallback'; +const startIds = (result) => result.termination.starts.map((start) => start.id); +const cartons = (profile, configuration = {}) => request( + [ + ...['b1', 'b2', 'b4'].map((id) => ({ id, dimensions: mm(350, 250, 140) })), + { id: 'b3', dimensions: mm(350, 230, 130) }, + ], + [box('carton-box', 600, 400, 250)], + // One ordering, so the added block start is the only other start these tests look at. + { configuration: { solver_profile: profile, multi_start_orders: 1, ...configuration } }, +); +const ownAnswer = (payload) => packFallback({ + ...payload, configuration: { ...payload.configuration, effort_budget: { max_restarts: 1 } }, +}); +const withoutDuration = (result) => JSON.stringify(result, (key, value) => (key === 'duration_ms' ? 0 : value)); + +for (const profile of ['fast', 'balanced']) { + test(`${profile} tries the block solver as one more start after its own`, () => { + const result = packFallback(cartons(profile)); + assert.deepEqual(startIds(result), ['javascript_fallback', blockStartId]); + assert.equal(result.termination.starts.filter((start) => start.selected).length, 1); + assert.equal(result.termination.starts.every((start) => start.started && start.completed), true); + assert.equal(result.algorithm.profile, profile); + }); + + test(`${profile} answers a supported request identically every time`, () => { + assert.equal(withoutDuration(packFallback(cartons(profile))), withoutDuration(packFallback(cartons(profile)))); + }); +} + +test('the added block start replaces an answer it strictly beats', () => { + const payload = cartons('balanced'); + const own = ownAnswer(payload); + const result = packFallback(payload); + assert.deepEqual(startIds(own), ['javascript_fallback']); + assert.equal(own.summary.container_count, 2); + assert.equal(result.summary.container_count, 1); + assert.equal(compareScores(result.score, own.score) < 0, true); + assert.equal(result.algorithm.solver, blockStartId); + assert.deepEqual(result.termination.starts.map((start) => start.selected), [false, true]); + assert.deepEqual(validate(payload, result), []); + assert.deepEqual(recomputeObjective(payload, result), result.score); + // The profile's own answer stays on offer, as a losing solver's does under `quality`. + assert.deepEqual(result.alternatives.map((alternative) => alternative.score), [own.score]); + assert.deepEqual(packFallback(cartons('balanced', { alternatives: 1 })).alternatives, []); + // Both runs are counted: the reported effort is the portfolio's, not the winner's. + assert.equal(result.algorithm.metrics.search_nodes_expanded > own.algorithm.metrics.search_nodes_expanded, true); + assert.equal(result.algorithm.candidates_evaluated > own.algorithm.candidates_evaluated, true); +}); + +test('the added block start is discarded when it only ties', () => { + const payload = request( + [cube('light', 100, { weight: 1 }), cube('heavy', 100, { weight: 3 })], + [box('column', 100, 100, 200)], + { configuration: { solver_profile: 'fast' } }, + ); + const own = ownAnswer(payload); + const result = packFallback(payload); + assert.deepEqual(startIds(result), ['javascript_fallback', blockStartId]); + assert.deepEqual(result.termination.starts.map((start) => start.selected), [true, false]); + assert.equal(result.algorithm.solver, 'javascript_fallback'); + assert.deepEqual(result.score, own.score); + assert.deepEqual(result.containers, own.containers); + assert.deepEqual(result.alternatives, []); +}); + +test('fast enumerates one block mode where balanced enumerates both', () => { + const spent = (profile) => packFallback(cartons(profile)).algorithm.metrics.search_nodes_expanded; + const own = ownAnswer(cartons('fast')).algorithm.metrics.search_nodes_expanded; + assert.equal(spent('fast') > own, true); + assert.equal(spent('balanced') - own, 2 * (spent('fast') - own)); +}); + +test('the added block start carries its own node limit, not the container plan limit', () => { + // Selected by name on `balanced` the solver keeps the profile's limit of one node and + // stops before its first block; the added start must not inherit that. + const selected = packFallback(cartons('balanced', { solvers: ['homogeneous_blocks'] })); + assert.equal(selected.summary.packed_item_count, 0); + assert.equal(packFallback(cartons('balanced', { container_plan_node_limit: 1 })).summary.container_count, 1); +}); + +test('the added block start stays out where it does not belong', () => { + const floorBound = cartons('balanced'); + floorBound.items[3].must_be_on_floor = true; + const prioritised = cartons('fast'); + prioritised.items[0].priority = 5; + const valued = cartons('balanced', { objective: 'maximum_value' }); + valued.items[0].value = 7; + const soft = cartons('balanced'); + soft.items[3].shape_type = 'compressible'; + soft.items[3].compression_ratio = 0.5; + soft.items[3].max_compression_pressure_kpa = 100; + const oneType = request( + [{ id: 'b1', quantity: 4, dimensions: mm(350, 250, 140) }], + [box('carton-box', 600, 400, 250, { obstacles: [{ origin: { x: '0', y: '0', z: '0' }, dimensions: mm(10, 10, 10) }] })], + ); + const cases = { + 'an explicit solver selection': cartons('balanced', { solvers: ['extreme_points'] }), + 'the exact_small profile': cartons('exact_small'), + 'a restart budget the profile uses up': cartons('balanced', { multi_start_orders: 2, effort_budget: { max_restarts: 2 } }), + 'one item type': oneType, + 'differing priorities': prioritised, + 'differing values under maximum_value': valued, + 'a must-be-on-floor item': floorBound, + 'a request-wide support ratio': cartons('fast', { minimum_support_ratio: 0.8 }), + 'an item that is not a rigid cuboid': soft, + }; + for (const [name, payload] of Object.entries(cases)) { + const result = packFallback(payload); + assert.equal(startIds(result).some((id) => id.startsWith('homogeneous_blocks')), false, name); + assert.doesNotMatch(result.algorithm.solver, /homogeneous_blocks/, name); + assert.deepEqual(result.alternatives, [], name); + } + // `quality` keeps its own portfolio, block solver first, exactly as before. + assert.deepEqual(startIds(packFallback(cartons('quality'))), [ + blockStartId, 'extreme_points:javascript_fallback', 'maximal_spaces:javascript_fallback', 'layer:javascript_fallback', + ]); +}); + +test('the added block start joins a multi-start run once, after every ordering', () => { + const result = packFallback(cartons('balanced', { multi_start_orders: 3 })); + assert.deepEqual(startIds(result), [ + 'javascript_fallback', 'javascript_fallback:seeded_1', 'javascript_fallback:seeded_2', blockStartId, + ]); + assert.equal(result.algorithm.solver, blockStartId); + assert.equal(result.summary.container_count, 1); +}); + +test('an added block start that exhausts its effort says so', () => { + const payload = cartons('balanced', { effort_budget: { max_search_nodes: 1 } }); + const result = packFallback(payload); + assert.deepEqual(startIds(result), ['javascript_fallback', blockStartId]); + assert.equal(result.termination.starts[1].truncated, true); + assert.equal(result.algorithm.solver, 'javascript_fallback'); + assert.deepEqual(result.alternatives, []); + assert.deepEqual(validate(payload, result), []); +}); + +// ------------------------------------------------------------ stacking groups + +const twoTier = (lower, upper, rest = {}) => request( + [cube('lower', 100, { must_be_on_floor: true, ...lower }), cube('upper', 100, upper)], + [box('crate', 100, 100, 200, { quantity: 1 })], rest, +); + +test('stacking groups: an accepted group rests on the lower item, an omitted one is refused', () => { + const lower = { stacking_group: 'A', accepts_stacking_groups: ['A', 'B'] }; + assert.deepEqual(packedTypes(packSound(twoTier(lower, { stacking_group: 'B' }))), ['lower', 'upper']); + assert.deepEqual(packedTypes(packSound(twoTier(lower, { stacking_group: 'C' }))), ['lower']); +}); + +test('stacking groups: empty or absent lists are unconstrained, a non-empty one is closed', () => { + for (const accepts of [undefined, null, []]) { + const lower = accepts === undefined ? {} : { accepts_stacking_groups: accepts }; + assert.deepEqual(packedTypes(packSound(twoTier(lower, {}))), ['lower', 'upper']); + } + assert.deepEqual(packedTypes(packSound(twoTier({ accepts_stacking_groups: ['A'] }, {}))), ['lower']); + assert.deepEqual(packedTypes(packSound(twoTier({ accepts_stacking_groups: ['B'] }, { stacking_group: 'b' }))), ['lower']); +}); + +test('stacking groups: non-stackable dominates a list naming the upper group', () => { + const lower = { stackable: false, accepts_stacking_groups: ['B'] }; + assert.deepEqual(packedTypes(packSound(twoTier(lower, { stacking_group: 'B' }))), ['lower']); +}); + +test('stacking groups: every supporter of a bridging item must accept it', () => { + const pillar = (id, extra) => ({ id, dimensions: mm(50, 100, 100), must_be_on_floor: true, allowed_rotations: ['LWH'], ...extra }); + const payload = (accepts) => request( + [pillar('left', { accepts_stacking_groups: accepts }), pillar('right', {}), + { id: 'wide', dimensions: mm(100, 100, 40), keep_upright: true, stacking_group: 'B' }], + [box('crate', 100, 100, 140, { quantity: 1 })], + ); + assert.deepEqual(packedTypes(packSound(payload(['A']))), ['left', 'right']); + assert.deepEqual(packedTypes(packSound(payload(['B']))), ['left', 'right', 'wide']); +}); + +test('stacking groups: a candidate slid under a fixed item is judged by its own list', () => { + const payload = (accepts) => request( + [{ id: 'drum', dimensions: mm(100, 100, 50), stacking_group: 'C' }, + cube('crate', 100, { accepts_stacking_groups: accepts })], + [box('bay', 100, 100, 150, { quantity: 1 })], + { fixed_placements: [{ item_type: 'drum', container_type: 'bay', orientation: 'LWH', + position: { x: '0', y: '0', z: '100' } }] }, + ); + assert.deepEqual(packedTypes(packSound(payload(['A']))), ['drum']); + assert.deepEqual(packedTypes(packSound(payload(['C']))), ['crate', 'drum']); +}); + +test('stacking groups: a nested column needs the item to accept its own group', () => { + const payload = (accepts) => request( + [{ id: 'tote', quantity: 3, dimensions: mm(10, 10, 10), nesting_height: '5', allowed_rotations: ['LWH'], + stacking_group: 'T', accepts_stacking_groups: accepts }], + [box('rack', 10, 10, 20, { quantity: 1 })], + { configuration: { solvers: ['extreme_points'] } }, + ); + assert.equal(placements(packSound(payload(['U']))).length, 1); + assert.equal(placements(packSound(payload(['T']))).length, 3); +}); + +test('stacking groups: the grid lattice caps at one layer when the type refuses its own group', () => { + const payload = (accepts) => request( + [cube('tile', 40, { quantity: 8, stacking_group: 'A', accepts_stacking_groups: accepts })], + [box('box', 100, 100, 100, { quantity: 1 })], + { configuration: { solver_profile: 'fast' } }, + ); + const capped = packSound(payload(['B'])); + assert.equal(placements(capped).length, 4); + assert.ok(placements(capped).every((p) => p.position.z.ticks === 0)); + assert.equal(placements(packSound(payload(['A']))).length, 8); +}); + +test('stacking groups: the block solver delegates and keeps the rule', () => { + const payload = request( + [{ id: 'a', dimensions: mm(100, 100, 50), allowed_rotations: ['LWH'], stacking_group: 'A', accepts_stacking_groups: ['A'] }, + cube('b', 50, { quantity: 8, stacking_group: 'B' })], + [box('cube', 200, 100, 100, { quantity: 1 })], + { configuration: { solver_profile: 'quality', solvers: ['homogeneous_blocks'] } }, + ); + assert.equal(placements(packSound(payload)).length, 9); +}); + +test('stacking groups: rebalancing never moves an item onto a lower item that refuses it', () => { + const payload = request( + [cube('base', 100, { weight: '100', must_be_on_floor: true, accepts_stacking_groups: ['A'] }), + cube('drum', 100, { quantity: 2, weight: '500', stacking_group: 'C' })], + [box('crate', 100, 100, 200, { quantity: 2 })], + ); + const original = packSound(payload); + const balanced = rebalanceWeight(payload, original); + assert.deepEqual(validate(payload, { ...original, containers: balanced.containers }), []); + const withBase = balanced.containers.find((c) => c.placements.some((p) => p.item_type === 'base')); + assert.deepEqual(withBase.placements.map((p) => p.item_type), ['base']); +}); diff --git a/test/fixed-placements.test.mjs b/test/fixed-placements.test.mjs index 6283ecd..355c7a0 100644 --- a/test/fixed-placements.test.mjs +++ b/test/fixed-placements.test.mjs @@ -284,6 +284,10 @@ const RULES = [ ['tag limit', [item('a', { tags: ['hazmat'] })], room({ tag_limits: { hazmat: 1 } }), [at('a'), at('a', '100')], 'tag_count_exceeded: a#1: hazmat: limit 1, would be 2'], ['non-stackable', [item('a', { stackable: false }), item('b')], room(), [at('a'), at('b', '0', '100')], 'non_stackable: a#1: a'], + ['stacking group', [item('a', { accepts_stacking_groups: ['A'] }), item('b', { stacking_group: 'C' })], room(), + [at('a'), at('b', '0', '100')], 'stacking_group_incompatible: a#1: a'], + ['non-stackable dominates a list', [item('a', { stackable: false, accepts_stacking_groups: ['B'] }), item('b', { stacking_group: 'B' })], + room(), [at('a'), at('b', '0', '100')], 'non_stackable: a#1: a'], ['top load', [item('a', { max_top_load: '500' }), item('b')], room(), [at('a'), at('b', '0', '100')], 'top_load_exceeded: box#1: a#1'], ['stacked limit', [item('a', { max_stacked_items: 1 })], room(), [at('a'), at('a', '0', '100'), at('a', '0', '200')], 'stacked_item_limit_exceeded: box#1: a#1'], diff --git a/test/flat-bag.test.mjs b/test/flat-bag.test.mjs new file mode 100644 index 0000000..e111aa4 --- /dev/null +++ b/test/flat-bag.test.mjs @@ -0,0 +1,123 @@ +/** + * Flat bags, admitted by the girth rule a + c <= L and b + c <= W. + * + * Mirrors `packvium-python/tests/test_flat_bag.py` on the oracle's fixed cases; the request + * error texts are asserted whole because they are compared across engines. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { FixedPlacementError, InvalidRequestError, packFallback, rebalanceWeight } from '../fallback.js'; +import { validate } from './validate.mjs'; + +const BAG = { deformable_boundary: { kind: 'flat_bag' } }; +const MM = 16000; + +const books = (quantity, extra = {}) => ({ + id: 'book', quantity, weight: '300 g', allowed_rotations: ['LWH'], + dimensions: { length: '200', width: '100', height: '20' }, ...extra, +}); +const mailer = (length = '240', width = '140', extra = {}) => ({ + id: 'mailer', inner_dimensions: { length, width, height: '100' }, ...BAG, ...extra, +}); +const request = (items, containers, configuration = {}) => ({ + items, containers, + configuration: { solver_profile: 'fast', time_limit_ms: 60000, effort_budget: { max_search_nodes: 100000 }, ...configuration }, +}); +const extentMm = (container) => ['length', 'width', 'height'] + .map((side) => container.achieved_dimensions[side].ticks / MM); + +function refusal(fields) { + try { + packFallback(request([books(1)], [{ id: 'mailer', inner_dimensions: { length: '300', width: '200', height: '100' }, ...fields }])); + } catch (error) { + return error; + } + return assert.fail('the request was accepted'); +} + +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: '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', + 'cannot be combined with deformable_boundary'], + [{ ...BAG, void_fill_reserve_ratio: 0.5 }, '/void_fill_reserve_ratio', 'not_allowed', 'cannot be combined with deformable_boundary'], + ]; + for (const [fields, field, reason, detail] of cases) { + const error = refusal(fields); + assert.ok(error instanceof InvalidRequestError, error.message); + assert.deepEqual([error.field, error.reason, error.detail], [`/containers/0${field}`, reason, detail]); + } + const inert = packFallback(request([books(1)], [mailer('240', '140', { obstacles: [], void_fill_reserve_ratio: 0 })])); + assert.equal(inert.containers.length, 1); +}); + +test('girth, not the frame, decides how high a bag stacks', () => { + const req = request([books(3)], [mailer('240', '140', { quantity: 1 })]); + const result = packFallback(req); + assert.deepEqual(result.containers.map(extentMm), [[200, 100, 40]]); + assert.equal(result.unpacked_items.length, 1); + assert.deepEqual(validate(req, result), []); + const opened = packFallback(request([books(3)], [mailer()])); + assert.deepEqual(opened.containers.map(extentMm), [[200, 100, 40], [200, 100, 20]]); + const compact = packFallback(request([books(3)], [mailer()], { require_placement_coordinates: false })); + assert.equal(compact.containers.length, 2); +}); + +test('an item no rotation admits is proven unplaceable', () => { + const carton = { id: 'carton', dimensions: { length: '250', width: '150', height: '80' } }; + const result = packFallback(request([carton], [mailer('300', '300')])); + assert.equal(result.unpacked_items[0].reason, 'no_compatible_container_dimensions'); + const restricted = packFallback(request([{ ...carton, allowed_rotations: ['LHW'] }], [mailer('330', '300')])); + assert.equal(restricted.unpacked_items[0].reason, 'rotation_restricted'); +}); + +test('the block search stands down for a bag', () => { + const result = packFallback(request([books(3)], [mailer()], { solver_profile: 'quality' })); + assert.deepEqual(result.containers.map(extentMm), [[200, 100, 40], [200, 100, 20]]); +}); + +test('a bag is billed on its achieved package', () => { + const rate = { weight_brackets_g: [500, 1000, 2000], prices_minor: [400, 700, 1100] }; + const carton = { id: 'carton', inner_dimensions: { length: '300', width: '200', height: '150' }, rate_table: rate }; + for (const [objective, expected] of [['lowest_landed_cost', 400], ['shipping_cost', 300 * 8_000_000]]) { + for (const solver_profile of ['fast', 'exact_small', 'quality']) { + const req = request([books(1)], [carton, mailer('300', '250', { rate_table: rate })], { + objective, solver_profile, dimensional_weight_divisor: 5000, + dimensional_weight_length_unit: 'cm', dimensional_weight_weight_unit: 'kg', + }); + const result = packFallback(req); + assert.equal(result.containers[0].container_type, 'mailer', `${objective} ${solver_profile}`); + assert.equal(result.score[1], expected, `${objective} ${solver_profile}`); + } + } +}); + +test('a fixed set past its girth cannot hold', () => { + const fixed = [0, 20, 40].map((z) => ({ + item_type: 'book', container_type: 'mailer', orientation: 'LWH', position: { x: '0', y: '0', z: String(z) }, + })); + assert.throws(() => packFallback({ ...request([books(3)], [mailer()]), fixed_placements: fixed }), (error) => { + assert.ok(error instanceof FixedPlacementError); + assert.equal(error.message, 'invalid_fixed_placement: bag_girth_exceeded: mailer#1'); + return true; + }); +}); + +test('a weight rebalance never moves an item past a bag\'s girth', () => { + const heavy = { id: 'brick', quantity: 2, weight: '5000 g', allowed_rotations: ['LWH'], + dimensions: { length: '200', width: '100', height: '20' } }; + const box = { id: 'box', quantity: 1, cost_minor: 0, inner_dimensions: { length: '200', width: '100', height: '200' } }; + const req = request([heavy, books(2)], [box, mailer('240', '140', { quantity: 1, cost_minor: 1 })]); + const result = packFallback(req); + const balanced = rebalanceWeight(req, result); + for (const container of balanced.containers.filter((c) => c.container_type === 'mailer')) { + const [a, b, c] = extentMm(container); + assert.ok(a + c <= 240 && b + c <= 140, JSON.stringify(container.achieved_dimensions)); + } +}); diff --git a/test/limit-reasons.test.mjs b/test/limit-reasons.test.mjs new file mode 100644 index 0000000..52f0bbb --- /dev/null +++ b/test/limit-reasons.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { explainReason, packFallback } from '../fallback.js'; + +/** + * One reason code per container, axle and stop limit, and: a + * restart budget that changes the plan. Mirrors packvium-python/tests/test_limit_reasons.py; + * the reason scenes are pinned across all four engines in conformance/unpacked-reasons/. + */ + +const grams = (value) => ({ value, unit: 'g' }); +const cube = (id, quantity, side = '10', fields = {}) => ({ + id, quantity, dimensions: { length: side, width: side, height: side }, ...fields, +}); +const box = (length, width, height, fields = {}) => ({ + id: 'box', inner_dimensions: { length, width, height }, ...fields, +}); +const axles = (front, rear) => [ + { position: '0', max_load: grams(front) }, { position: '100', max_load: grams(rear) }, +]; +const solve = (items, containers, configuration = {}) => packFallback({ + units: { length: 'mm' }, + configuration: { solver_profile: 'fast', time_limit_ms: 300_000, ...configuration }, + items, containers, +}); +const reasons = (result) => result.unpacked_items + .map((item) => [item.reason, item.proof.level]).sort(); +const INVENTORY = [['container_inventory_exhausted', 'inferred']]; + +test('a limit a fresh container would lift is the inventory', () => { + const cases = { + quantity: [[cube('cube', 2)], [box('10', '10', '10', { quantity: 1 })], {}], + max_containers: [[cube('cube', 2)], [box('10', '10', '10')], { max_containers: 1 }], + max_items: [[cube('cube', 2)], [box('20', '20', '20', { quantity: 1, max_items: 1 })], {}], + max_payload: [[cube('cube', 2, '10', { weight: grams('600') })], + [box('20', '20', '20', { quantity: 1, max_payload: grams('1000') })], {}], + tag_limits: [[cube('hazmat', 2, '10', { tags: ['hazmat'] })], + [box('20', '20', '20', { quantity: 1, tag_limits: { hazmat: 1 } })], {}], + stop_access: [[cube('early', 1, '100', { stop_index: 0 }), + cube('late', 1, '100', { stop_index: 1, stackable: false })], + [box('100', '100', '200', { quantity: 1, access_directions: ['+z'] })], {}], + axle_share: [[cube('payload', 2, '10', { weight: grams('500'), allowed_rotations: ['LWH'] })], + [box('100', '10', '10', { quantity: 1, axles: axles('600', '350') })], {}], + }; + for (const [name, [items, containers, configuration]] of Object.entries(cases)) { + assert.deepEqual(reasons(solve(items, containers, configuration)), INVENTORY, name); + } + assert.deepEqual(reasons(solve([cube('cube', 2)], [box('20', '20', '20', { max_items: 1 })])), []); +}); + +test('an axle that refuses the item alone is named', () => { + for (const [length, front, rear] of [['10', '400', '400'], ['100', '400', '700']]) { + const item = { id: 'load', quantity: 1, weight: grams('1000'), allowed_rotations: ['LWH'], + dimensions: { length, width: '10', height: '10' } }; + const vehicle = box('100', '10', '10', { axles: axles(front, rear) }); + assert.deepEqual(reasons(solve([item], [vehicle])), [['axle_overloaded', 'inferred']], length); + } + const item = cube('load', 2, '10', { weight: grams('1000'), allowed_rotations: ['LWH'] }); + const crate = { ...box('10', '10', '10', { quantity: 1 }), id: 'crate' }; + assert.deepEqual(reasons(solve([item], [box('100', '10', '10', { axles: axles('400', '400') }), crate])), + INVENTORY); + assert.match(explainReason('axle_overloaded'), /axle/); +}); + +test('a single restart packs worse than the full portfolio', () => { + // The shared conformance scene feature-effort-restarts-first-start-loses. + const unpacked = (restarts) => solve( + [{ id: 'panel', quantity: 4, dimensions: { length: '20', width: '25', height: '50' } }, + { id: 'crate', quantity: 4, dimensions: { length: '60', width: '40', height: '40' } }], + [box('50', '100', '60', { quantity: 1 })], + { solver_profile: 'balanced', effort_budget: { + max_candidates_evaluated: 1_000_000, max_placement_attempts: 1_000_000, + max_search_nodes: 1_000_000, ...(restarts == null ? {} : { max_restarts: restarts }) } }, + ).unpacked_items.length; + assert.equal(unpacked(1), 4); + assert.equal(unpacked(null), 3); +}); diff --git a/test/made-to-measure.test.mjs b/test/made-to-measure.test.mjs new file mode 100644 index 0000000..b701935 --- /dev/null +++ b/test/made-to-measure.test.mjs @@ -0,0 +1,162 @@ +/** + * Made-to-measure containers, pinned to the worked numbers in + * docs/MADE-TO-MEASURE-CONTAINERS.md. Mirrors `packvium-python/tests/test_made_to_measure.py` + * and `MadeToMeasureTest.php`. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InvalidRequestError, UnsupportedFeatureError, packFallback } from '../fallback.js'; +import { boxCost, fabricate, grid, icbrtCeil, perSquareMetre, solvingEnvelopes } from '../made-to-measure.js'; + +const MM = 16000; +const SQUARE_METRE = 16000000n * 16000000n; +const ticks = (...sides) => sides.map((side) => side * MM); +const mm = (length, width, height) => ({ length: String(length), width: String(width), height: String(height) }); +const machine = (minimum = 60, increment = 25, extra = {}) => ({ + minimum: ticks(minimum, minimum, minimum), increment: ticks(increment, increment, increment), + price: 0, tarePerSquareMetre: 0, allowance: [0, 0, 0], ...extra, +}); +const cubes = (count, side = 100) => Array.from({ length: count }, () => ticks(side, side, side)); + +const request = (containerFields = {}, configuration = {}) => ({ + units: { length: 'mm' }, + configuration: { solver_profile: 'fast', minimum_support_ratio: 1, time_limit_ms: 300000, + effort_budget: { max_search_nodes: 1000000 }, ...configuration }, + items: [{ id: 'cube', quantity: 8, weight: '100', dimensions: mm(100, 100, 100) }], + containers: [{ id: 'machine', cost_minor: 40, inner_dimensions: mm(1510, 1510, 1510), + made_to_measure: { minimum: mm(60, 60, 60), increment: mm(25, 25, 25), cost_per_square_metre_minor: 1000, + outer_allowance: mm(3, 3, 3) }, ...containerFields }], +}); + +test('the grid rounds up onto the machine and never below its minimum', () => { + assert.equal(grid(machine(), 0, 200 * MM), 210 * MM); + assert.equal(grid(machine(), 0, 210 * MM), 210 * MM); + assert.equal(grid(machine(), 0, 1), 60 * MM); +}); + +test('the split cost equals the direct ceiling and rounds a sliver up', () => { + let state = 2702n; + for (let index = 0; index < 100; index++) { + state = (state * 6364136223846793005n + 1442695040888963407n) % (1n << 64n); + const rate = state % (1n << 53n), area = (state * 977n) % (1n << 110n); + assert.equal(perSquareMetre(rate, area), (rate * area + SQUARE_METRE - 1n) / SQUARE_METRE); + } + assert.equal(perSquareMetre(1, 1n), 1n); + assert.equal(boxCost(1000, 40, ticks(210, 210, 210)), 305n); +}); + +test('the integer cube root is the least cube at or above the value', () => { + for (const [value, root] of [[0n, 0n], [1n, 1n], [7n, 2n], [8n, 2n], [9n, 3n], [10n ** 21n, 10n ** 7n], [10n ** 21n + 1n, 10n ** 7n + 1n]]) { + assert.equal(icbrtCeil(value), root); + } +}); + +test('the worked rung lists match the contract', () => { + assert.deepEqual(solvingEnvelopes(machine(), ticks(1510, 1510, 1510), cubes(8)), + [ticks(260, 260, 260), ticks(310, 310, 310), ticks(410, 410, 410), ticks(1510, 1510, 1510)]); + const pinned = { ...machine(), minimum: ticks(200, 100, 100), increment: ticks(70, 10, 10) }; + assert.deepEqual(solvingEnvelopes(pinned, ticks(1950, 100, 100), cubes(5)), [ticks(1950, 100, 100)]); + assert.deepEqual(solvingEnvelopes(machine(50, 10), ticks(300, 300, 300), cubes(3)), + [ticks(190, 190, 190), ticks(220, 220, 220), ticks(290, 290, 290), ticks(300, 300, 300)]); + const [first] = solvingEnvelopes(machine(), ticks(1510, 1510, 1510), [ticks(1000, 50, 50), ticks(100, 100, 100)]); + assert.ok(first[0] >= 1000 * MM && first[1] < 1000 * MM && first[2] < 1000 * MM); + const mixed = [ticks(300, 20, 40), ticks(100, 100, 100), ticks(50, 60, 70)]; + assert.deepEqual(solvingEnvelopes(machine(), ticks(1510, 1510, 1510), mixed), + solvingEnvelopes(machine(), ticks(1510, 1510, 1510), [...mixed].reverse())); +}); + +test('a box is never cut past the machine', () => { + assert.throws(() => fabricate(machine(), ticks(100, 100, 100), ticks(110, 10, 10), 0, 0), /exceeds the machine/); +}); + +test('the worked order is cut to a 210 mm box with board tare, not an 800 mm row', () => { + const data = request(); + data.containers[0].made_to_measure.tare_per_square_metre = '500'; + const result = packFallback(data), packed = result.containers[0]; + assert.deepEqual([packed.inner_dimensions.length.value, packed.outer_dimensions.height.value, + packed.achieved_dimensions.width.value], ['210', '213', '200']); + assert.equal(packed.made_to_measure.cost_minor, 305); + assert.equal(packed.made_to_measure.tare_weight.value, '132.3'); + assert.equal(packed.gross_weight.value, '932.3'); + assert.equal(result.score[2], 305); +}); + +test('the round key reads the fabricated price in the greedy and the plan beam', () => { + for (const configuration of [{}, { solver_profile: 'quality', container_plan_beam_width: 4 }, + { objective: 'lowest_landed_cost', dimensional_weight_divisor: 5000 }, { objective: 'maximum_volume' }, + { solver_profile: 'exact_small', solvers: ['exact_small'] }]) { + const data = request({}, configuration); + const rate = { weight_brackets_g: [100000], prices_minor: [100] }; + if (configuration.objective === 'lowest_landed_cost') data.containers[0].rate_table = rate; + data.containers.push({ id: 'stock', cost_minor: 300, inner_dimensions: mm(210, 210, 210), + ...(configuration.objective === 'lowest_landed_cost' ? { rate_table: rate } : {}) }); + if (configuration.solvers) data.items[0].quantity = 2; + const packed = packFallback(data).containers; + assert.equal(packed.length, 1); + } + assert.equal(packFallback({ ...request(), containers: [...request().containers, + { id: 'stock', cost_minor: 300, inner_dimensions: mm(210, 210, 210) }] }).containers[0].container_type, 'stock'); +}); + +test('the compact lattice stands down and a later round gets its own compact rungs', () => { + const compact = packFallback(request({}, { require_placement_coordinates: false })).containers[0]; + assert.equal(compact.lattice_summary, undefined); + assert.equal(compact.placements.length, 8); + const data = request({ inner_dimensions: mm(300, 300, 300), + made_to_measure: { minimum: mm(50, 50, 50), increment: mm(10, 10, 10), cost_per_square_metre_minor: 1000 } }); + data.items[0].quantity = 30; + assert.deepEqual(packFallback(data).containers.map((c) => c.made_to_measure.cost_minor), [580, 200]); +}); + +test('a counted limit reached mid-chain keeps the best rung so far', () => { + const data = request({}, { effort_budget: { max_search_nodes: 1 } }); + data.items[0].quantity = 2; + const result = packFallback(data); + assert.equal(result.containers.length, 1); + assert.equal(result.containers[0].placements.length, 1); +}); + +test('a load no rung can hold is reported unpacked', () => { + const data = request({ quantity: 1, max_items: 2 }); + const result = packFallback(data); + assert.equal(result.containers[0].placements.length, 2); + assert.equal(result.unpacked_items.length, 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)); + } + const data = request(); + data.fixed_placements = [{ item_type: 'cube', container_type: 'machine', orientation: 'LWH' }]; + assert.throws(() => packFallback(data), (error) => error.fields.includes('fixed_placements+container.made_to_measure')); + packFallback(request({ obstacles: [], void_fill_reserve_ratio: 0 })); +}); + +test('each request error names its rule', () => { + const cases = [ + [{ extra: 1 }, 'not_allowed', '/containers/0/made_to_measure/extra', 'is not a known field'], + [{ minimum: mm(60, 0, 60) }, 'invalid_value', '/containers/0/made_to_measure/minimum/width', 'must be positive'], + [{ increment: mm(25, 25, 0) }, 'invalid_value', '/containers/0/made_to_measure/increment/height', 'must be positive'], + [{ minimum: mm(1600, 60, 60) }, 'invalid_value', '/containers/0/made_to_measure/minimum/length', 'exceeds inner_dimensions'], + [{ increment: mm(25, 30, 25) }, 'invalid_value', '/containers/0/made_to_measure/increment/width', + 'does not divide inner_dimensions minus minimum'], + [{ cost_per_square_metre_minor: Number.MAX_SAFE_INTEGER }, 'invalid_value', + '/containers/0/made_to_measure/cost_per_square_metre_minor', 'prices the largest box above 9007199254740991'], + ]; + for (const [fields, reason, field, detail] of cases) { + const data = request(); + Object.assign(data.containers[0].made_to_measure, fields); + assert.throws(() => packFallback(data), (error) => error instanceof InvalidRequestError + && error.reason === reason && error.field === field && error.detail === detail); + } + 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'); +}); diff --git a/test/multi-start-orders.test.mjs b/test/multi-start-orders.test.mjs new file mode 100644 index 0000000..6166341 --- /dev/null +++ b/test/multi-start-orders.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { packFallback } from '../fallback.js'; + +/** + * Starts the JavaScript engine runs when the request does not say. + * + * Eight orderings outside `fast`, one under it, as Python, PHP and Rust default to; the + * effort budget is the request's, shared by every start; and a shuffled ordering keeps the + * lead keys, so priority survives a restart. + */ + +const cube = (id, side, extra = {}) => ({ + id, weight: '1 kg', dimensions: { length: String(side), width: String(side), height: String(side) }, ...extra, +}); +const scene = (configuration = {}, items = [cube('a', 10, { quantity: 20 })]) => ({ + units: { length: 'mm' }, + configuration: { time_limit_ms: 60_000, ...configuration }, + items, + containers: [{ id: 'c', quantity: 1, inner_dimensions: { length: '100', width: '100', height: '100' } }], +}); +const startCount = (result) => result.termination.starts.length; + +test('balanced and quality run eight orderings by default, fast runs one', () => { + assert.equal(startCount(packFallback(scene())), 8); + assert.equal(startCount(packFallback(scene({ solver_profile: 'fast' }))), 1); + assert.equal(startCount(packFallback(scene({ solver_profile: 'fast', multi_start_orders: 8 }))), 1); + assert.equal(startCount(packFallback(scene({ multi_start_orders: 3 }))), 3); +}); + +test('the effort budget is shared by every start', () => { + const result = packFallback(scene({ effort_budget: { max_search_nodes: 5 } })); + assert.equal(result.algorithm.metrics.search_nodes_expanded, 5); + assert.equal(result.termination.code, 'effort_limit'); + assert.deepEqual(result.termination.starts.map((start) => [start.started, start.truncated]), + [[true, true], ...Array.from({ length: 7 }, () => [true, true])]); +}); + +test('a shuffled ordering keeps priority ahead of size', () => { + // The box holds one of the two. Every one of the eight orderings must offer it to the + // high-priority item first, or a shuffle that leads with the big one fills the box. + const items = [cube('big', 100), cube('small', 10, { priority: 5 })]; + for (let start = 1; start < 8; start++) { + const result = packFallback(scene({}, items), Date.now, null, start); + assert.deepEqual(result.containers.flatMap((c) => c.placements.map((p) => p.item_id)), ['small#1'], `start ${start}`); + } +}); diff --git a/test/plan-bound-hulls.test.mjs b/test/plan-bound-hulls.test.mjs new file mode 100644 index 0000000..8c26c99 --- /dev/null +++ b/test/plan-bound-hulls.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { __additionalContainerBoundForTests as additionalContainerBound } from '../fallback.js'; + +/** + * The container-plan bound against `convex_hull` items. + * + * Two wedges that fill one crate between them have boxes worth two crates. Counted by box, + * the bound said a second crate was unavoidable and ranked the plan as if it already held it. + */ + +const wedge = (upper) => { + const base = upper ? [[100, 100], [100, 0], [0, 100]] : [[0, 0], [100, 0], [0, 100]]; + return { + d: [100, 100, 100], w: 1, nesting: null, rots: ['LWH'], shapeType: 'convex_hull', + hullVertices: [0, 100].flatMap((z) => base.map(([x, y]) => [x, y, z])), + }; +}; +const crate = { id: 'crate', d: [100, 100, 100], max: null }; + +test('a wedge pair needs one more crate, not two', () => { + const plan = { remaining: [wedge(false), wedge(true)], inventory: new Map([['crate', 2]]) }; + assert.equal(additionalContainerBound(plan, [crate]), 1); +}); + +test('a cuboid still counts its whole box', () => { + const cube = { d: [100, 100, 100], w: 1, nesting: null, rots: ['LWH'], shapeType: 'rigid_cuboid' }; + const plan = { remaining: [cube, cube], inventory: new Map([['crate', 2]]) }; + assert.equal(additionalContainerBound(plan, [crate]), 2); +}); diff --git a/test/request-errors.test.mjs b/test/request-errors.test.mjs index 7086cf8..cb1c161 100644 --- a/test/request-errors.test.mjs +++ b/test/request-errors.test.mjs @@ -130,6 +130,16 @@ const CASES = [ }, 'wrong_type', '/containers/0/obstacles/0/origin', 'must be an object'], ['an obstacle without dimensions', (r) => { r.containers[0].obstacles = [{ id: 'p' }]; }, 'missing_field', '/containers/0/obstacles/0/dimensions', 'is required'], + ['a numeric stacking group', (r) => { r.items[0].stacking_group = 7; }, 'wrong_type', '/items/0/stacking_group', + 'must be a string'], + ['an empty stacking group', (r) => { r.items[0].stacking_group = ''; }, 'invalid_value', '/items/0/stacking_group', + 'must not be empty'], + ['accepted groups that are not a list', (r) => { r.items[0].accepts_stacking_groups = 'A'; }, 'wrong_type', + '/items/0/accepts_stacking_groups', 'must be a list'], + ['a numeric accepted group', (r) => { r.items[0].accepts_stacking_groups = ['A', 3]; }, 'wrong_type', + '/items/0/accepts_stacking_groups/1', 'must be a string'], + ['an empty accepted group', (r) => { r.items[0].accepts_stacking_groups = ['A', '']; }, 'invalid_value', + '/items/0/accepts_stacking_groups/1', 'must not be empty'], ]; for (const [name, edit, reason, field, detail] of CASES) { diff --git a/test/result-writer.test.mjs b/test/result-writer.test.mjs new file mode 100644 index 0000000..c17d53c --- /dev/null +++ b/test/result-writer.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { __resultWriterForTests } from '../fallback.js'; + +/** + * Two result-writer shortcuts, held to the paths they bypass. + * + * `decimalString` renders a safe-integer tick count in Number arithmetic; the BigInt long + * division it skips (written out below as it stood in 1.6.0) must give the same text for + * every value, unit and rounding tie, whether the count arrives as a Number or a BigInt. + * `usedVolume` scans only nesting placements for overlapping pairs; the every-pair scan it + * replaced must give the same exact volume. + */ + +const { decimalString, usedVolume, validNesting, occupiedVolume } = __resultWriterForTests; + +const stream = (seed) => { + let state = seed >>> 0; + return (low, high) => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + return low + (state % (high - low + 1)); + }; +}; + +// The 1.6.0 BigInt long division, written out plainly: the reference for every value. +const bigIntDecimalString = (ticks, divisor, places = 8) => { + const negative = ticks < 0n; if (negative) ticks = -ticks; const div = BigInt(divisor); + let whole = ticks / div, remainder = ticks % div, digits = ''; + for (let i = 0; i < places; i++) { remainder *= 10n; digits += (remainder / div).toString(); remainder %= div; } + const lastOdd = digits.length ? Number(digits[digits.length - 1]) % 2 === 1 : whole % 2n === 1n; + if (remainder * 2n > div || (remainder * 2n === div && lastOdd)) { + const chars = digits.split(''); let i = chars.length - 1; + for (; i >= 0 && chars[i] === '9'; i--) chars[i] = '0'; + if (i >= 0) chars[i] = String(Number(chars[i]) + 1); else whole += 1n; + digits = chars.join(''); + } + digits = digits.replace(/0+$/, ''); + const text = digits === '' ? whole.toString() : `${whole}.${digits}`; + return text === '0' ? '0' : (negative ? '-' : '') + text; +}; + +// Every divisor the unit tables hold: lengths, then weights. +const DIVISORS = [1, 8000, 16000, 160000, 406400, 4876800, 16000000, 226796185, 8000000, 3628738960, 8000000000]; + +test('the Number path of decimalString renders every safe integer as the BigInt path does', () => { + const next = stream(20261004); + const values = [0, -0, 1, -1, Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER, 2 ** 52, 2 ** 52 + 1]; + for (const divisor of DIVISORS) { + // Exact ties at the eighth place, both parities, and their neighbours. + for (const whole of [0, 1, 2, 7, 1234567]) for (const k of [1, 2, 3, 5, 15]) { + const tie = whole * divisor + (k * divisor) / 2e8; + if (Number.isSafeInteger(tie)) values.push(tie, tie - 1, tie + 1, -tie); + } + for (let index = 0; index < 400; index++) { + const magnitude = next(0, 52); + const value = Math.floor((next(0, 2 ** 30) / 2 ** 30) * 2 ** magnitude) * (next(0, 1) ? 1 : -1); + values.push(value, value * divisor, value * divisor + Math.floor(divisor / 2)); + } + for (const value of values) { + if (!Number.isSafeInteger(value)) continue; + const expected = bigIntDecimalString(BigInt(value), divisor); + assert.equal(decimalString(value, divisor), expected, `${value} / ${divisor}`); + assert.equal(decimalString(BigInt(value), divisor), expected, `${value}n / ${divisor}`); + } + } + // A fractional tick count is truncated first on both paths. + for (const value of [0.5, -0.5, 12.75, -16000.999]) assert.equal(decimalString(value, 16000), bigIntDecimalString(BigInt(Math.trunc(value)), 16000)); + // Past the safe range only the BigInt path applies, and it is still the reference. + const large = 2n ** 60n; + for (const divisor of [8000000, 8000000000]) { + const div = BigInt(divisor); + // Plain, rounding to even on a tie, rounding up, and a carry into the whole part. + for (const value of [large * div, large * div + div - 1n, large * div + div / 2n, -(large * div + div - 1n), 2n ** 53n, -(2n ** 53n), 2n ** 70n + 12345n]) + assert.equal(decimalString(value, divisor), bigIntDecimalString(value, divisor), `${value}n / ${divisor}`); + } +}); + +// The every-pair scan `usedVolume` ran before it skipped placements that cannot nest. +const everyPairVolume = (placements) => { + let total = placements.reduce((sum, placement) => sum + occupiedVolume(placement), 0n), overlap = 0n; + for (let i = 0; i < placements.length; i++) for (let j = i + 1; j < placements.length; j++) + if (validNesting(placements[i], placements[j])) + overlap += BigInt(placements[i].item.nesting) * BigInt(placements[i].ed[0]) * BigInt(placements[i].ed[1]); + return total - overlap; +}; + +test('usedVolume over nesting placements alone equals the every-pair scan', () => { + const next = stream(1600); + for (let scene = 0; scene < 300; scene++) { + const types = Array.from({ length: next(1, 3) }, (_, index) => ({ + raw: { id: `t${index}` }, shapeType: 'rigid_cuboid', maxCompressionKpa: null, + nesting: next(0, 2) === 0 ? null : next(1, 4), + })); + const placements = []; + for (let index = next(0, 24); index > 0; index--) { + const item = types[next(0, types.length - 1)], pd = [next(2, 5), next(2, 5), next(3, 6)]; + placements.push({ x: next(0, 2) * 5, y: next(0, 2) * 5, z: next(0, 6), pd, ed: pd, r: 'LWH', item }); + } + assert.equal(usedVolume(placements), everyPairVolume(placements)); + } +}); diff --git a/test/sequence.test.mjs b/test/sequence.test.mjs index 998a52b..0a14188 100644 --- a/test/sequence.test.mjs +++ b/test/sequence.test.mjs @@ -199,6 +199,20 @@ test('a loading prefix that stacks onto a non-stackable item is caught',()=>{ ); }); +test('a loading prefix that breaks the stacking-group rule is caught in either order',()=>{ + const placements=[ + stacked(0,0,0,5,{accepts_stacking_groups:['A']}), + stacked(0,0,5,5,{stacking_group:'C'}), + ]; + for(const order of [[0,1],[1,0]]){ + assert.throws( + ()=>verifyLoadingPrefixBusinessRules(placements,order,{}), + error=>error instanceof SequenceReplayError&&error.step===1&&error.reason==='stacking_group_incompatible', + ); + } + verifyLoadingPrefixBusinessRules([stacked(0,0,0,5,{accepts_stacking_groups:['A']}),stacked(0,0,5,5,{stacking_group:'A'})],[0,1],{}); +}); + test('a loading prefix that violates a ground contact rule is caught',()=>{ // "single" requires resting on exactly one supporter; two half-width bases side by // side under one full-width rider violate it. @@ -228,3 +242,24 @@ test('a malformed business rule order is rejected before any rule check',()=>{ SequenceReplayError, ); }); + +test('safeRemovalOrder tie-breaks multiple unblocked items by lowest index in linear scan',()=>{ + const container={length:40,width:20,height:20}; + const b0=box(0,0,0,10,10,10); + const b1=box(10,0,0,10,10,10); + const b2=box(20,0,0,10,10,10); + const order=safeRemovalOrder([b0,b1,b2],container,['+z']); + assert.deepEqual(order,[0,1,2]); +}); + +test('safeRemovalOrder throws SequenceError on circular deadlock',()=>{ + const container={length:20,width:20,height:20}; + const b0=box(0,0,0,10,10,10); + const b1=box(0,0,10,10,10,10); + // b1 rests on b0 (b0 cannot unload until b1 is unloaded). + // With only -z allowed, b1 is blocked by b0 below it. + assert.throws( + ()=>safeRemovalOrder([b0,b1],container,['-z']), + SequenceError, + ); +}); diff --git a/test/unsupported-placements.test.mjs b/test/unsupported-placements.test.mjs new file mode 100644 index 0000000..d41a9dc --- /dev/null +++ b/test/unsupported-placements.test.mjs @@ -0,0 +1,69 @@ +/** + * The default minimum_support_ratio of 0 lets a placement rest on nothing, and says so. + * + * Mirrors `packvium-python/tests/test_unsupported_placements.py` and + * `UnsupportedPlacementTest.php`. The warning is asserted whole because it is compared across + * engines. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { packFallback } from '../fallback.js'; + +const FALLBACK_WARNING = 'JavaScript fallback is active; build the Rust addon for the native portfolio'; +const warning = (count) => `unsupported_placements: ${count} placement(s) rest on nothing above the container floor; set configuration.minimum_support_ratio above 0 to require support`; + +const cube = (id, quantity, height = '100') => ({ + id, quantity, weight: '500', dimensions: { length: '100', width: '100', height }, +}); +const fixed = (itemType, x, y, z) => ({ + item_type: itemType, container_type: 'crate', position: { x, y, z }, orientation: 'LWH', +}); +// Two stacked bases, plus a slab in mid-air and a slab on an obstacle when floating. +const scene = ({ floating = true, configuration = {} } = {}) => ({ + items: [cube('base', 2), cube('loose', 4), ...(floating ? [cube('slab', 2, '50')] : [])], + containers: [{ + id: 'crate', quantity: 1, inner_dimensions: { length: '400', width: '200', height: '400' }, + obstacles: [{ id: 'shelf', origin: { x: '200', y: '0', z: '0' }, + dimensions: { length: '100', width: '100', height: '100' } }], + }], + fixed_placements: [ + fixed('base', '0', '0', '0'), fixed('base', '0', '0', '100'), + ...(floating ? [fixed('slab', '100', '100', '300'), fixed('slab', '200', '0', '100')] : []), + ], + configuration: { + solver_profile: 'quality', alternatives: 3, + effort_budget: { max_search_nodes: 20000 }, time_limit_ms: 60000, ...configuration, + }, +}); +const unsupportedWarnings = (result) => result.warnings.filter((line) => line.startsWith('unsupported_placements:')); + +test('a placement in mid-air and one on an obstacle are both counted, after the existing warning', () => { + assert.deepEqual(packFallback(scene()).warnings, [FALLBACK_WARNING, warning(2)]); +}); + +test('every alternative carries its own warning', () => { + const result = packFallback(scene()); + assert.ok(result.alternatives.length > 0); + for (const alternative of result.alternatives) assert.deepEqual(unsupportedWarnings(alternative), [warning(2)]); +}); + +test('a fully supported packing has no warning', () => { + assert.deepEqual(packFallback(scene({ floating: false })).warnings, [FALLBACK_WARNING]); +}); + +test('requiring support silences the warning', () => { + const result = packFallback(scene({ floating: false, configuration: { minimum_support_ratio: 0.5 } })); + assert.deepEqual(result.warnings, [FALLBACK_WARNING]); +}); + +test('a lattice answer is floor-up and never warns', () => { + const result = packFallback({ + items: [cube('base', 8)], + containers: [{ id: 'crate', inner_dimensions: { length: '200', width: '200', height: '200' } }], + configuration: { require_placement_coordinates: false }, + }); + assert.ok(result.containers.some((container) => 'lattice_summary' in container)); + assert.deepEqual(result.warnings, [FALLBACK_WARNING]); +}); diff --git a/test/validate.mjs b/test/validate.mjs index 2ba1663..50992ea 100644 --- a/test/validate.mjs +++ b/test/validate.mjs @@ -252,6 +252,26 @@ function fixedPlacementIssues(request, result, lengthUnit) { return issues; } +/** + * 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. + */ +function flatBagIssues(template, container, boxes, inner) { + if (template.deformable_boundary == null) { + const soft = template.made_to_measure != null; + return container.achieved_dimensions != null && !soft ? ['achieved_dimensions_mismatch'] : []; + } + const [a, b, c] = bagExtent(boxes); + 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'); + return issues; +} + +const bagExtent = (boxes) => [0, 1, 2].map((axis) => boxes.reduce((top, box) => Math.max(top, box.at[axis] + box.size[axis]), 0)); + export function validate(request, result) { const issues = []; const itemsById = new Map(request.items.map((item) => [item.id, item])); @@ -275,6 +295,7 @@ export function validate(request, result) { 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)); boxes.forEach((box, index) => { if (seen.has(box.id)) issues.push('duplicate_item'); @@ -311,8 +332,12 @@ export function validate(request, result) { box.at[axis] < other.at[axis] + other.size[axis] && box.at[axis] + box.size[axis] > other.at[axis]); if (!sharesFootprint) continue; const otherItem = itemsById.get(other.type); - if (other.at[2] + other.size[2] === box.at[2] && otherItem?.stackable === false) { + if (other.at[2] + other.size[2] !== box.at[2]) continue; + const accepts = otherItem?.accepts_stacking_groups ?? []; + if (otherItem?.stackable === false) { issues.push('non_stackable'); + } else if (accepts.length > 0 && !accepts.includes(item.stacking_group)) { + issues.push('stacking_group_incompatible'); } } @@ -399,6 +424,19 @@ function addCharge(total, container, billedTicks) { * * @returns {number[]} */ +/** + * A container's price: its declared `cost_minor`, plus, for a box cut to measure, its + * material at `cost_per_square_metre_minor`, rounded up, on the reported inner size. + */ +function containerCost(template, inner) { + const fee = template?.cost_minor ?? 0; + const price = BigInt(template?.made_to_measure?.cost_per_square_metre_minor ?? 0); + if (price === 0n) return fee; + const [l, w, h] = inner.map(BigInt), squareMetre = 16000000n ** 2n; + const area = 2n * (l * w + l * h + w * h); + return fee + Number((price * area + squareMetre - 1n) / squareMetre); +} + 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])); @@ -412,9 +450,9 @@ export function objective(request, result) { const latticeSequences = new Map(); for (const container of result.containers) { - cost += containersById.get(container.container_type)?.cost_minor ?? 0; - const inner = size(container.inner_dimensions); + cost += containerCost(containersById.get(container.container_type), inner); + const total = volume(inner); const boxes = boxesOf(container, latticeSequences); // Occupied volume follows the shape: a hull's own volume, a compressible item's height @@ -455,7 +493,10 @@ 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); - const dimensional = volume(size(container.outer_dimensions)) * weightTicks + // 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); + const dimensional = volume(billedSize) * weightTicks / (lengthTicks ** 3n * divisor); const billed = BigInt(ticks(container.gross_weight)) > dimensional ? BigInt(ticks(container.gross_weight)) : dimensional; @@ -483,5 +524,18 @@ export function objective(request, result) { (sum, item) => sum + (itemsById.get(item.item_type)?.value ?? 0), 0); return [standard[0], valueForgone, standard[1], standard[2], standard[3]]; } + if (selected === 'maximum_volume') { + // Nominal volume, from the request's own dimensions: the share left unpacked, rounded + // up so an incomplete answer never scores zero. + const nominal = (item) => volume(['length', 'width', 'height'].map( + (edge) => requestLengthTicks(item.dimensions[edge], lengthUnit))); + const requested = request.items.reduce( + (sum, item) => sum + nominal(item) * BigInt(item.quantity ?? 1), 0n); + const unpackedVolume = result.unpacked_items.reduce( + (sum, item) => sum + (itemsById.has(item.item_type) ? nominal(itemsById.get(item.item_type)) : 0n), 0n); + const unpackedPpm = requested > 0n + ? Number((unpackedVolume * SCORE_SCALE + requested - 1n) / requested) : 0; + return [unpackedPpm, standard[0], standard[1], standard[2], standard[3]]; + } return standard; }