From f840bc6b915d0fabb18399b5cff810617a66451c Mon Sep 17 00:00:00 2001 From: Anton Kara Date: Tue, 1 Sep 2026 11:00:55 -0400 Subject: [PATCH] chore(release): sync 1.0.0 from workspace --- CHANGELOG.md | 60 ++- README.md | 7 +- SECURITY.md | 4 +- contact-graph.js | 172 +++++-- docs/GUARANTEES.md | 19 +- docs/PUBLIC-API.md | 26 + docs/UNITS-AND-NUMERICS.md | 27 ++ examples/shapes.mjs | 155 ++++++ fallback.js | 786 ++++++++++++++++++++++++++++++- index.js | 2 +- package.json | 8 +- test/contact-graph.test.mjs | 137 +++++- test/fallback.test.mjs | 287 ++++++++++- test/legacy-smoke.mjs | 22 + test/run-tests.mjs | 22 + test/stop-accessibility.test.mjs | 83 ++++ test/validate.mjs | 173 ++++++- 17 files changed, 1914 insertions(+), 76 deletions(-) create mode 100644 examples/shapes.mjs create mode 100644 test/legacy-smoke.mjs create mode 100644 test/run-tests.mjs create mode 100644 test/stop-accessibility.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f498e..e1c9512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,64 @@ # Changelog The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project -adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the caveat -that the public API is not frozen until `1.0.0`. Pin an exact version. +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). As of `1.0.0` the +public API, the request and result schemas, the numeric and units policy, the validation +rules and the compatibility policy are frozen: breaking any of them costs a major version. + +## [1.0.0] + +The stable core release. It freezes the contract that already exists rather than adding a +feature wave: the public API, the request and result schemas, the numeric and units policy, +the validation rules and the compatibility policy will not break without a major version. + +### Read this before depending on 1.0.0 + +- **Platforms exercised for this release: Linux x86_64, Linux aarch64 and macOS arm64.** + Python wheels are built and installed into a clean Python 3.9 on all three; the PHP FFI + bridge is verified against the real shared library on PHP 8.2, 8.3, 8.4 and 8.5 on both + Linux architectures. **Windows and macOS x86_64 native binaries are not built and not + published.** Without a native binary the pure Python and PHP engines run unchanged — that + is the documented default, and it costs speed rather than correctness. +- **Release artifacts carry no build-provenance attestation.** `gh attestation verify` will + not succeed against 1.0.0. The SBOM and SHA-256 manifests are the integrity evidence for + this release; check them before installing from anywhere other than the official + registry. + +### Added + +- **A sound lower bound on the objective, in every engine.** Computed from the request + alone before a search begins, and identical across the Python, PHP, Rust and JavaScript + implementations on 381 corpus cases. It is not a result field: reporting an optimality + gap would widen the contract this release exists to freeze. +- **A declared numeric ceiling shared by all four engines.** A sum too large to stay exact + returns a structured refusal instead of a number that one language would round and + another would not. The limit is stated by the library rather than inherited from each + language, so the four agree about which requests are answerable. +- **A published coverage frontier for optimality claims.** Where the bound is actually + attained is measured and committed, so `optimal` is bounded by evidence rather than used + as a label. + +### Fixed + +- **A bound could be returned that JavaScript cannot represent exactly.** Values above + `2^53-1` are now refused rather than silently rounded in one engine and exact in the + others. +- **The prebuilt Node addon had no quality floor.** It is now held to a per-fixture budget + like the other independent engines. + +### Not claimed + +- **Identical placements across engines.** Different engines may return different, equally + valid arrangements. This is measured and budgeted, not accidental — and it includes the + JavaScript fallback and the prebuilt native addon, which differ on 18 of 397 corpus + fixtures. +- **Optimal packings for arbitrary requests.** 3D packing remains NP-hard; the bound says + what is provable, not that every answer is optimal. +- **Fastest engine.** Measured against other libraries on identical hardware, that claim is + false on latency, and no Packvium surface makes it. On the report's separate two-axis + time-and-peak-memory frontier, `packvium-rust` is Pareto-optimal in 6 of 9 profiles and + the only engine of ten never dominated. The latency result and the trade-off result are + reported together; neither is a general speed-leadership claim. ## [0.1.3] diff --git a/README.md b/README.md index 2bcd108..105f624 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ Deterministic 3D cartonization for Node.js. It uses the optional native engine when available and automatically falls back to the bundled JavaScript implementation. +Full documentation, the constraint reference and benchmarks live at +[packvium.com](https://packvium.com). + ## Install ```bash @@ -100,6 +103,8 @@ and execute without a project around it. | File | What it shows | | --- | --- | | [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. | +| [`objectives.mjs`](examples/objectives.mjs) | All six objectives on scenes where they genuinely disagree — the same scores the Python, PHP and Rust engines print for the same request. | +| [`shapes.mjs`](examples/shapes.mjs) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, and a cushion that compresses under load until the crush limit refuses it. | | [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. | ```bash @@ -133,7 +138,7 @@ Documentation, the constraint reference and the benchmarks are at | --- | --- | --- | | Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) | | PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) | -| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "0.1"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | +| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.0"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | | Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) | | Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) | | PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) | diff --git a/SECURITY.md b/SECURITY.md index 658f11c..dff5f2f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ ## Supported versions -Only the latest `0.1.x` release receives fixes. This is an early release; there is no -long-term support branch yet. +Only the latest `1.x` release receives fixes. The `0.1.x` line is superseded by `1.0.0` +and receives none. There is no long-term support branch for older majors. ## Reporting a vulnerability diff --git a/contact-graph.js b/contact-graph.js index a4a498a..16b10e3 100644 --- a/contact-graph.js +++ b/contact-graph.js @@ -6,45 +6,106 @@ * representation can avoid. The exact overlap function remains authoritative. * This module is package-internal: package.json exports only the root entry point. */ -export function buildContactGraph(boxes, overlapXY) { - const supporters = boxes.map(() => []); - const children = boxes.map(() => []); - if (boxes.length === 0) return { supporters, children, candidateChecks: 0 }; - - const cell = Math.max(1, ...boxes.map(box => Math.max(box.d[0], box.d[1]))); - const byTop = new Map(); - const levels = new Map(); - const cells = box => { - const x1 = Math.floor(box.x / cell); - const x2 = Math.floor((box.x + box.d[0] - 1) / cell); - const y1 = Math.floor(box.y / cell); - const y2 = Math.floor((box.y + box.d[1] - 1) / cell); - return [...new Set([`${x1}:${y1}`, `${x2}:${y1}`, `${x1}:${y2}`, `${x2}:${y2}`])]; - }; +/** + * The at-most-four cells `box` occupies. + * + * `cell` must be at least as large as the largest footprint dimension of every box + * hashed into the index or queried against it -- not just the ones being indexed. Only + * then is a box guaranteed to span no more than a 2x2 block, which is what makes two + * overlapping boxes always share a cell. Sizing it from the indexed boxes alone would be + * exactly wrong: a larger querying box could step over cells in the middle of its own + * footprint and silently miss a real overlap. + */ +function cellsOf(box, cell) { + const x1 = Math.floor(box.x / cell); + const x2 = Math.floor((box.x + box.d[0] - 1) / cell); + const y1 = Math.floor(box.y / cell); + const y2 = Math.floor((box.y + box.d[1] - 1) / cell); + return [...new Set([`${x1}:${y1}`, `${x2}:${y1}`, `${x1}:${y2}`, `${x2}:${y2}`])]; +} + +function bucketsByPlane(boxes, plane) { + const byPlane = new Map(); boxes.forEach((box, index) => { - const top = box.z + box.d[2]; - if (!byTop.has(top)) byTop.set(top, []); - byTop.get(top).push(index); + const key = plane(box); + if (!byPlane.has(key)) byPlane.set(key, []); + byPlane.get(key).push(index); }); + return byPlane; +} + +const topOf = box => box.z + box.d[2]; +const bottomOf = box => box.z; +function levelIndex(boxes, indices, cell) { + const level = new Map(); + for (const index of indices) { + for (const key of cellsOf(boxes[index], cell)) { + if (!level.has(key)) level.set(key, []); + level.get(key).push(index); + } + } + return level; +} + +/** + * Every box in `buckets` on `plane` that really overlaps `box`, ascending by index. + * + * Ascending order is contract, not presentation: `topLoads` splits a conserved integer + * across the supporter list and hands the rounding remainder to whichever edge is last. + */ +function overlapsOnPlane(graph, buckets, cache, plane, box, overlapXY) { + const indices = buckets.get(plane); + if (!indices) return []; + let level = cache.get(plane); + if (level == null) { + level = levelIndex(graph.boxes, indices, graph.cell); + cache.set(plane, level); + } + const nearby = new Set(); + for (const key of cellsOf(box, graph.cell)) { + for (const index of level.get(key) ?? []) nearby.add(index); + } + const found = []; + for (const other of [...nearby].sort((left, right) => left - right)) { + const area = overlapXY(graph.boxes[other], box); + if (area > 0) found.push([other, area]); + } + return found; +} + +/** + * `cellHint` is an upper bound on the footprint of any box that may later be appended + * with `appendContactBox`. + * + * Without it the cell is sized from the boxes present now, and appending anything wider + * has to fall back to a full rebuild -- correct, but it defeats the point, because in a + * search the base is what is already placed and the candidate is a *new* item that may + * well be the widest in the request. A caller that knows the item set passes its widest + * footprint once and the delta path then always applies. Too large a hint only makes + * each bucket coarser; too small a one cannot give a wrong answer, because the fallback + * covers it. + */ +export function buildContactGraph(boxes, overlapXY, cellHint = 1) { + const supporters = boxes.map(() => []); + const children = boxes.map(() => []); + const cell = Math.max(1, cellHint, ...boxes.map(box => Math.max(box.d[0], box.d[1]))); + const byTop = bucketsByPlane(boxes, topOf); + const byBottom = bucketsByPlane(boxes, bottomOf); + const topLevels = new Map(); let candidateChecks = 0; + boxes.forEach((upper, upperIndex) => { const candidates = byTop.get(upper.z); if (!candidates) return; - let level = levels.get(upper.z); + let level = topLevels.get(upper.z); if (level == null) { - level = new Map(); - for (const index of candidates) { - for (const key of cells(boxes[index])) { - if (!level.has(key)) level.set(key, []); - level.get(key).push(index); - } - } - levels.set(upper.z, level); + level = levelIndex(boxes, candidates, cell); + topLevels.set(upper.z, level); } const nearby = new Set(); - for (const key of cells(upper)) { + for (const key of cellsOf(upper, cell)) { for (const index of level.get(key) ?? []) nearby.add(index); } for (const lowerIndex of [...nearby].sort((left, right) => left - right)) { @@ -57,5 +118,58 @@ export function buildContactGraph(boxes, overlapXY) { } } }); - return { supporters, children, candidateChecks }; + // The downward-facing indexes stay empty here: only an append queries them, and a graph + // built once and read once would otherwise pay for an index nothing looks at. + return { supporters, children, candidateChecks, boxes, cell, byTop, byBottom, topLevels, + bottomLevels: new Map() }; +} + +/** + * `graph` plus one more box, appended at the next index. + * + * Adding a box cannot create or destroy contact between two boxes already in the graph: + * contact is a pairwise geometric predicate over two boxes and nothing else. That is the + * whole reason a delta is sound, and it is why only the new box's own two planes are + * queried instead of every box being re-examined. + * + * The result is required to be identical to `buildContactGraph([...boxes, box])`, not + * merely equivalent -- see `overlapsOnPlane` on why edge order is contract. The new box + * takes the highest index, so appending it to an existing list keeps that list ascending. + * + * `graph` is not modified: the returned graph shares every edge list the append did not + * touch, and copies the two or three it did. + */ +export function appendContactBox(graph, box, overlapXY) { + const index = graph.boxes.length; + const footprint = Math.max(box.d[0], box.d[1]); + if (footprint > graph.cell) { + // The broad phase is only correct while its cell covers every box hashed into it or + // queried against it, so this is a correctness fallback, not an optimisation choice. + return buildContactGraph([...graph.boxes, box], overlapXY, footprint); + } + + const below = overlapsOnPlane(graph, graph.byTop, graph.topLevels, box.z, box, overlapXY); + const above = overlapsOnPlane(graph, graph.byBottom, graph.bottomLevels, topOf(box), box, overlapXY); + + const supporters = graph.supporters.slice(); + const children = graph.children.slice(); + supporters.push(below.map(([lower, area]) => [lower, area])); + children.push(above.map(([upper]) => upper)); + for (const [lower] of below) children[lower] = [...children[lower], index]; + for (const [upper, area] of above) supporters[upper] = [...supporters[upper], [index, area]]; + + // One box joins exactly two planes, so only those two buckets change, and only the two + // level indexes describing them are invalidated. A level index is never mutated after + // it is built, so every other one is shared with the base rather than rebuilt. + const byTop = new Map(graph.byTop); + byTop.set(topOf(box), [...(byTop.get(topOf(box)) ?? []), index]); + const byBottom = new Map(graph.byBottom); + byBottom.set(box.z, [...(byBottom.get(box.z) ?? []), index]); + const topLevels = new Map(graph.topLevels); + topLevels.delete(topOf(box)); + const bottomLevels = new Map(graph.bottomLevels); + bottomLevels.delete(box.z); + + return { supporters, children, candidateChecks: graph.candidateChecks, + boxes: [...graph.boxes, box], cell: graph.cell, byTop, byBottom, topLevels, bottomLevels }; } diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index af5efb3..3d2f188 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -48,9 +48,16 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `0.1.3` is an early release. The public API is not yet frozen: field names, -status codes and the objective vector may change before `1.0.0`. Pin an exact version. - -The algorithm complexities documented in `ALGORITHMS-AND-COMPLEXITY.md` are design -intent that has not yet been confirmed by profiling. Treat them as guidance for choosing -a solver profile, not as a performance contract. +Version `1.0.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. + +What the freeze does not cover: which of several equally valid packings a solver +returns. That is bounded by the objective vector, not by the placement list, and a minor +release may return a different arrangement with the same or a better score. + +The algorithm complexities documented in `ALGORITHMS-AND-COMPLEXITY.md` are asymptotic +design bounds held by review, not per-release measurements. They are not a wall-clock +performance contract either: constants, input shape and host all move the real number. +Use them to choose a solver profile, not to predict a duration. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index 2089bb4..f403f3f 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -3,6 +3,32 @@ ## 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. +- **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 + omits `shape_type` is unaffected**, byte for byte: the default is `rigid_cuboid`, the whole + existing golden corpus is unchanged, and writing `"shape_type": "rigid_cuboid"` explicitly + is served rather than refused. + + The two other values are rolling out one engine at a time, which is what the fields were + reserved before the freeze for, and the rollout is finished: **all four engines implement + both**, and are held to byte-identical results on the shared fixtures. The one + recorded difference is the JavaScript fallback on a scene whose answer depends on item + ordering -- it makes a single ordering pass where the other three run a portfolio, so it can + pack validly but less densely; that is pinned in `conformance/native-quality-budget.json` + rather than left to drift. + + Where they are implemented: a `convex_hull` item's collisions are decided by an exact + integer separating-axis test rather than by its box, and its occupied volume is the hull's + own — two complementary wedges share a crate that fits one of their bounding boxes. A + `compressible` item loses height linearly with the pressure resting on it, rounded up, and + a load above `max_compression_pressure_kpa` is refused as `crush_violation` rather than + packed. Three situations deliberately fall back to the bounding box, always over-reserving + space: a configured `clearance`, an item on a route (`stop_index`), and the uniform-lattice + fast path. Hull coordinates are non-negative offsets from the corner of the item's own + bounding box and are capped at 10^8 length ticks -- 6.25 m, beyond ordinary parcel sizes, and the + bound that keeps the exact geometry inside a 64-bit integer everywhere it can be. + IRREGULAR-ITEMS.md is the model and states why. - `Container`: id, inner/outer dimensions, tare/payload, cost, inventory quantity, obstacles (each a union of one or more exact boxes — `additional_boxes` approximates a non-rectangular zone such as a wheel arch or tapered roof without diff --git a/docs/UNITS-AND-NUMERICS.md b/docs/UNITS-AND-NUMERICS.md index a56d23e..9b1aa73 100644 --- a/docs/UNITS-AND-NUMERICS.md +++ b/docs/UNITS-AND-NUMERICS.md @@ -42,6 +42,33 @@ The JavaScript fallback also performs load distribution in `BigInt`: every when the intermediate product exceeds `Number` precision. The final tick count is converted back to a number only at the existing JSON boundary. +## The bound path's declared ceiling + +`docs/OPTIMALITY-CERTIFICATES.md` defines lower bounds on the objective. Their arithmetic is +integer-only, and the limit at which they refuse is **declared rather than inherited from the +language**: every sum in that path must stay below `10^30`, and exceeding it is a structured +refusal in all four engines rather than a number. + +The reason is the same one this document already gives for coordinates, taken one step +further. Python's integers are unbounded, PHP's silently become doubles on overflow, +JavaScript's `Number` stops being exact past `2^53`, and Rust's `i128` wraps. Four engines +refusing at four native limits would disagree about which requests are answerable at all -- +a caller would get a number from one and a refusal from another for the same input. So each +carries the guarded sums in a representation that holds `10^30` exactly: Python's `int`, +PHP's `BigInt` decimal strings, JavaScript's `BigInt`, Rust's `i128`. + +That intermediate ceiling is deliberately not the result ceiling. The five bound keys can +cross JSON and are returned as JavaScript `Number` values, so every engine also refuses a +final key above **`2^53 - 1` (`9,007,199,254,740,991`)**. This is the largest integer all four +bindings can return without changing its value. In particular, an unlimited inventory does +not make an expensive container count as one during validation: the selected opening costs +are summed exactly, checked against `10^30`, then checked against the portable result ceiling +before PHP or JavaScript converts them to a native integer. + +The value comes from the widest intermediate the formulas form -- a summed volume times +`10^6` -- which puts the largest product at `10^36`, about 170-fold inside an `i128`. It never +binds on a real request: `10^30` cubic ticks is 244 million cubic metres. + ## Decimal rendering `value` fields (e.g. `Length.decimal()`, `Weight.decimal()`, PHP's `RationalParser::decimalString()`) render an exact `ticks / divisor` rational to a fixed number of digits (8 by default). All four engines round the truncated remainder **ties-to-even**, matching Python's `Decimal.quantize` under its default context — the last kept digit rounds up when the discarded remainder is more than half the divisor, stays put when it's less, and on an exact half rounds to whichever choice makes that digit even. diff --git a/examples/shapes.mjs b/examples/shapes.mjs new file mode 100644 index 0000000..9037d20 --- /dev/null +++ b/examples/shapes.mjs @@ -0,0 +1,155 @@ +/** + * Shapes: when an item is not its box. + * + * Run it: + * + * node examples/shapes.mjs + * + * Every other example treats an item as the box it declares. That is the default and it + * is right for almost everything, because a carton *is* a cuboid. Two kinds of goods are + * not: a moulded or tapered part that leaves a usable void beside it, and a soft one that + * gives way under whatever is stacked on it. + * + * `shape_type` narrows the box in one direction each -- `convex_hull` in space, + * `compressible` in height under load -- and neither is ever inferred. An engine that + * quietly packed a hull as its bounding box would return a plan that validates and does + * not physically fit, so the value must be asked for. + * + * These fields are part of the shared request contract, so the same document runs + * unchanged against the Python, PHP and Rust engines. It does not follow that all four + * print the same numbers -- see the note on the compressible section below, which is the + * more useful half of the lesson. + */ + +import { pack } from '../index.js'; + +const MM = { units: { length: 'mm' } }; +const crate = (length, width, height) => [ + { id: 'crate', inner_dimensions: { length, width, height } }, +]; + +/** Run one request and print only what the shape changed: containers and refusals. */ +const summarise = (label, request) => { + const result = pack({ ...MM, ...request }); + const placed = result.containers.reduce((n, c) => n + c.placements.length, 0); + console.log( + ` ${label.padEnd(22)} ${result.status.padEnd(10)} ` + + `${result.containers.length} container(s), ${placed} placed, ` + + `${result.unpacked_items.length} refused`, + ); +}; + +// ------------------------------------------------------------------ convex_hull +// +// Two triangular prisms, each cut from the same 100 mm cube along the diagonal. Their +// bounding boxes are identical and fill the crate on their own, so as cuboids the second +// one has nowhere to go. As hulls they are complementary halves and share the crate +// exactly -- the collision test is an exact integer separating-axis test on the vertices, +// not a box overlap. +// +// The hull is given in the item's own coordinates, in the request's length unit, and must +// fit inside the declared dimensions. It is not a replacement for them: the box still +// bounds the item, the hull only says how much of that box is solid. + +const LOWER_WEDGE = [ + { x: '0', y: '0', z: '0' }, { x: '100', y: '0', z: '0' }, + { x: '0', y: '100', z: '0' }, { x: '0', y: '0', z: '100' }, + { x: '100', y: '0', z: '100' }, { x: '0', y: '100', z: '100' }, +]; +const UPPER_WEDGE = [ + { x: '100', y: '100', z: '0' }, { x: '100', y: '0', z: '0' }, + { x: '0', y: '100', z: '0' }, { x: '100', y: '100', z: '100' }, + { x: '100', y: '0', z: '100' }, { x: '0', y: '100', z: '100' }, +]; + +const wedge = (id, vertices) => ({ + id, + quantity: 1, + dimensions: { length: '100', width: '100', height: '100' }, + weight: { value: '1', unit: 'kg' }, + ...(vertices ? { shape_type: 'convex_hull', hull_vertices: vertices } : {}), +}); + +console.log('convex_hull -- two complementary wedges cut from one cube'); +summarise('as cuboids', { + items: [wedge('wedge-lower', null), wedge('wedge-upper', null)], + containers: crate('100', '100', '100'), +}); +summarise('as hulls', { + items: [wedge('wedge-lower', LOWER_WEDGE), wedge('wedge-upper', UPPER_WEDGE)], + containers: crate('100', '100', '100'), +}); + +// One crate instead of two, for the same goods and the same crate. Nothing about the +// request changed except the claim that the items are wedges rather than blocks. + +// ----------------------------------------------------------------- compressible +// +// `compression_ratio` is the fraction of its own height an item may lose when something +// rests on it -- 0.25 means it can give up a quarter. The mass above it is what decides +// how much it actually gives, so the occupied height of a compressible item is not a +// property of the item alone; it depends on what the solver put on top. +// +// `max_compression_pressure_kpa` is the other half of the same field. Past that pressure +// the item is not compressed further, it is crushed, and the load is refused instead. +// +// Note `must_be_on_floor` on the cushion. Without it the solver is free to put the brick +// underneath, nothing bears on the cushion, and the feature never engages -- which is the +// honest reason the rule is here and not an incidental detail of the example. + +const cushion = (crushKpa) => ({ + id: 'cushion', + quantity: 1, + dimensions: { length: '100', width: '100', height: '100' }, + weight: { value: '2', unit: 'kg' }, + must_be_on_floor: true, + shape_type: 'compressible', + compression_ratio: 0.25, + max_compression_pressure_kpa: crushKpa, +}); + +const brick = (kilograms) => ({ + id: 'brick', + quantity: 1, + dimensions: { length: '100', width: '100', height: '100' }, + weight: { value: String(kilograms), unit: 'kg' }, +}); + +/** One crate, one cushion, one brick -- only the brick's mass changes. */ +const load = (label, kilograms) => { + const result = pack({ + ...MM, + items: [cushion(100), brick(kilograms)], + containers: crate('100', '100', '200'), + }); + console.log( + ` ${label.padEnd(22)} ${result.containers.length} container(s), ` + + `unused volume ${result.score[3]} ppm`, + ); +}; + +// The crate is 100x100x200 and the two items are 100 mm cubes, so rigidly they fill it +// exactly and nothing is unused. At 102 kg the brick crosses 100 kPa over the cushion's +// 0.01 m^2 face: the stack is refused, the brick opens a second crate, and half of each +// crate is empty. +// +// At 101 kg this engine also opens two crates -- and the Python, PHP and Rust engines +// return one, with the cushion compressed. Both answers are valid: every item is placed, +// no rule is broken, and an independent validator accepts each. This one is simply worse, +// and it is recorded as such in the suite's quality budget rather than left to be +// discovered here. +// +// That is the guarantee, stated exactly. What the shared contract fixes is the request +// shape, the validity rules and the objective vector -- not which of several valid +// arrangements a given engine finds. An engine may return a worse-scoring valid packing; +// none may return an invalid one. If you need the best answer these fields can give, +// solve on the Rust or Python engine and treat the JavaScript fallback as the portable +// one. +console.log('\ncompressible -- a cushion that yields to the load above it'); +load('brick 101 kg', 101); +load('brick 102 kg', 102); + +// Both shapes are refused rather than approximated wherever an engine cannot honour them +// exactly -- a hull on a route, a hull under a configured clearance, a compressible item +// with `nesting_height`. A wrong answer that validates is worse than a refusal that does +// not, which is the whole reason these are opt-in. diff --git a/fallback.js b/fallback.js index 198ebfc..a967b0c 100644 --- a/fallback.js +++ b/fallback.js @@ -1,4 +1,4 @@ -import { buildContactGraph } from './contact-graph.js'; +import { appendContactBox, buildContactGraph } from './contact-graph.js'; import { parsePolicy, policyRejection, provesUnplaceable, tagOccurrences } from './policy.js'; const LEN={mm:16000,cm:160000,m:16000000,in:406400,inch:406400,inches:406400,ft:4876800,tick:1,ticks:1}; @@ -20,9 +20,20 @@ const UNSUPPORTED={ // container, which none of the per-entry loops below would ever see. request:[], configuration:[], + // `hull_vertices`, `compression_ratio` and `max_compression_pressure_kpa` left this list + // in , the last engine to gain both the solver behaviour and the independent + // validation the staged rollout requires. item:[], container:[], obstacle:[], + // `item.shape_type` values this engine does not implement. Presence is the + // wrong test for this one field: `rigid_cuboid` is the default and is implemented, so a + // caller that spells the default out must be served, not refused. What is unimplemented + // is a *value*, and the refusal names it -- packing a `convex_hull` item as its bounding + // box would return a plan that looks valid and does not physically fit. + // Empty since : this engine implements every value the schema defines. The guard + // stays because the next reserved value will need it. + shapeType:[], }; // The admission boundary for staged public-field rollouts, exported so a test can assert // that what the lists name is exactly what the guard refuses -- the counterpart of @@ -91,6 +102,8 @@ function rejectUnsupported(req){const fields=[]; for(const key of UNSUPPORTED.container)if(hasOwn(raw,key))fields.push(`container.${key}`); for(const obstacle of raw.obstacles??[])for(const key of UNSUPPORTED.obstacle)if(hasOwn(obstacle,key))fields.push(`obstacle.${key}`); } + for(const raw of req.items??[]){const shape=raw?.shape_type; + if(typeof shape==='string'&&UNSUPPORTED.shapeType.includes(shape))fields.push(`item.shape_type=${shape}`)} if(fields.length)throw new UnsupportedFeatureError([...new Set(fields)].sort()); } function rat(s){s=String(s).trim();if(s.includes(' ')){const [w,f]=s.split(/\s+/,2),[n,d]=f.split('/').map(BigInt),wb=BigInt(w),sg=s.startsWith('-')?-1n:1n,mag=(wb<0n?-wb:wb)*d+n;return [sg*mag,d]}if(s.includes('/')){const[n,d]=s.split('/').map(BigInt);return[n,d]}if(s.includes('.')){const neg=s.startsWith('-'),[a,b]=s.replace(/^[-+]/,'').split('.');const d=10n**BigInt(b.length),n=BigInt(a)*d+BigInt(b);return[neg?-n:n,d]}return[BigInt(s),1n]} @@ -100,10 +113,558 @@ function dims(v,u){return [scalar(v.length,u,LEN),scalar(v.width,u,LEN),scalar(v function rotate(d,r){return ROT[r].map(i=>d[i])} 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} + +// ---------------------------------------------------------------- irregular geometry +// +// The rule is fixed by docs/IRREGULAR-ITEMS.md. Every product here is a `BigInt`, and not for +// tidiness: a separating axis is a cross product of two edge vectors, so its components grow +// as the square of a coordinate and a projection grows as the cube. At the shared coordinate +// cap a cross product reaches 8e16 and a projection 2.4e25, while a JavaScript number is exact +// only to 2^53 ~ 9e15. Both would silently lose precision, and a collision predicate that +// rounds returns a plan that validates and does not fit. Rust carries the same arithmetic in +// `i128`; PHP needs a decimal-string fallback; here `BigInt` is already the house answer, used +// for load distribution since the first port. + +/** Largest vertex coordinate a hull may carry, in ticks -- 6.25 m. Shared with every engine: + * they must refuse the same hulls or they disagree about which requests are legal. */ +const MAX_HULL_COORDINATE=100000000; +const UNIT_AXES=[[1n,0n,0n],[0n,1n,0n],[0n,0n,1n]]; +const sub3=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]]; +const cross3=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]]; +const dot3=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2]; +const big3=v=>[BigInt(v[0]),BigInt(v[1]),BigInt(v[2])]; +function bigGcd(a,b){a=a<0n?-a:a;b=b<0n?-b:b;while(b){const t=a%b;a=b;b=t}return a} +/** Divide out the gcd and fix the sign, so parallel axes collapse to one entry. `null` for the + * zero vector: a cross product of parallel directions names no axis, an ordinary outcome. */ +function primitiveAxis(v){ + const g=bigGcd(bigGcd(v[0],v[1]),v[2]); + if(g===0n)return null; + const r=[v[0]/g,v[1]/g,v[2]/g]; + const lead=r.find(x=>x!==0n); + return lead>0n?r:[-r[0],-r[1],-r[2]]; +} +const axisKey=v=>`${v[0]},${v[1]},${v[2]}`; +/** Lexicographic order on the vertex vector itself. `axisKey` is for identity, never order. */ +const compareVertices=(l,r)=>{ + for(let i=0;i<3;i++)if(l[i]!==r[i])return l[i][Number(v[0]),Number(v[1]),Number(v[2])]); + if(points.length<4)throw new RangeError(`a convex hull needs at least 4 vertices, got ${points.length}`); + if(new Set(points.map(axisKey)).size!==points.length)throw new RangeError('convex hull vertices must be unique'); + if(points.some(v=>v.some(c=>Math.abs(c)>MAX_HULL_COORDINATE))) + throw new RangeError(`convex hull coordinates must stay within ${MAX_HULL_COORDINATE} ticks`); + const b=points.map(big3); + for(let i=0;i0n)above=true;else if(side<0n)below=true; + if(above&&below)return false} + return true; +} +/** Corners of one planar convex face, in cyclic order seen from outside. + * + * The vertices sharing a supporting plane are not all corners of the polygon they lie on: one + * can sit inside the face or part-way along an edge, and fanning over the raw set triangulates + * the wrong region -- the surface then fails to close and the volume is wrong. Gift-wrapping + * keeps only the corners, resolving collinear candidates to the farthest so an edge-interior + * vertex is walked past rather than doubled back through. */ +function windFace(face,outward){ + // By the vertex vector, never its decimal encoding: the walk is only correct because it + // starts from a corner, which it earns by starting from the smallest vertex under a genuine + // linear order. String order is not one -- "10,0,0" sorts before "9,0,0" -- so it can name a + // vertex lying inside the face and make the walk emit a segment that is not a hull edge. + const sorted=[...face].sort(compareVertices); + const ordered=[sorted[0]];let current=sorted[0]; + for(let step=0;stepheld))next=candidate; + } + if(next===null||axisKey(next)===axisKey(sorted[0]))break; + ordered.push(next);current=next; + } + return ordered; +} +/** Every face of the hull, each as its own corners in outward cyclic order. + * + * One walk, because the faces answer two questions at once: the volume needs them wound + * consistently, and the hull's edges are the consecutive corner pairs of the same walk. A + * plane carrying fewer than three vertices is an edge or a corner of the hull, not a face, + * and carries no edge its two adjoining faces do not already carry. */ +function woundFaces(points,faceAxes){ + const faces=[]; + for(const axis of faceAxes)for(const outward of [axis,[-axis[0],-axis[1],-axis[2]]]){ + let extreme=null; + for(const v of points){const value=dot3(v,outward);if(extreme===null||value>extreme)extreme=value} + const face=points.filter(v=>dot3(v,outward)===extreme); + if(face.length<3)continue; + faces.push(windFace(face,outward)); + } + return faces; +} +/** Exact volume in cubic ticks, by the divergence theorem over the hull's own faces. */ +function hullVolume(faces){ + let six=0n; + for(const ordered of faces){ + const apex=ordered[0]; + for(let i=1;i+1axes.map(axis=>axis.map(value=>value.toString())); + return {volume:shape.volume.toString(),faceAxes:copy(shape.faces),edgeDirections:copy(shape.edges)}; +} +/** How many rotated hulls stay resident, before the memo is dropped and refilled. A request is + * bounded by its distinct hull items times the six orientations, so this holds far more than + * any request the solver is sized for -- and bounded, rather than growing for the life of the + * process. */ +const SHAPE_CACHE_ENTRIES=1024; +const shapeCache=new Map(); +/** The rotated hull of one item in one orientation, built at most once. + * + * A hull depends on the item and the orientation and on nothing about where a candidate sits, + * but the collision predicate was rebuilding it on every call -- `O(v^4)` work inside an + * `O(n^2)` loop. Measured on the two-wedge fixture: 78 builds for two items, where twelve are + * the floor. + * + * Memoisation is safe here in the way it is not in general: the shape is never mutated after + * it is built, the key is the whole of what determines the value, and callers only project + * through it. Determinism is untouched -- this changes how often the answer is computed, + * never what it is. */ +function shapeFor(vertices,rotation){ + const key=rotation+'|'+vertices.map(v=>v.join(',')).join(';'); + const found=shapeCache.get(key); + if(found!==undefined)return found; + const shape=hullShape(hullRotate(vertices,rotation)); + if(shapeCache.size>=SHAPE_CACHE_ENTRIES)shapeCache.clear(); + shapeCache.set(key,shape); + return shape; +} +/** A cuboid, built without searching for its own faces: both sets are the three unit axes. */ +/** Lower bounds on the objective vector. + * + * The mathematics is fixed by docs/OPTIMALITY-CERTIFICATES.md. This is an independent + * implementation written from that document, and `conformance/scene/objective-bounds.json` + * holds it to the same vectors Python computes on 380 cases from the golden corpus. + * + * asks only for soundness -- the bound must never exceed the achieved objective -- + * because this engine is not held to placement equality. That freedom does not extend to a + * bound: it is a function of the *request*, so there is no room for a legitimately different + * answer, and this port is held to equality because equality is achievable and stronger. + * + * `BigInt` throughout for volumes. A one-metre cube is 4.1e21 cubic ticks, past what a + * double represents exactly, and the widest intermediate multiplies a summed volume by 1e6. + * Counts, weights, costs and the parts-per-million keys come back to `Number` only once the + * arithmetic has reduced them to that scale. + * + * `O(n log n + c log c)` for `n` instances and `c` container types: one sort of the volumes, + * one of the weights, one of the per-unit costs. No geometry is touched. */ +const BOUND_PPM=1000000n; +/** Every sum in the bound path must stay below this. + * + * Declared rather than inherited. This engine's `Number` stops being exact past 2^53, PHP's + * integers silently become doubles on overflow, Python's are unbounded and Rust's `i128` + * wraps -- so if each refused at its own limit the four would disagree about which requests + * are answerable. Keys 3 and 4 multiply a summed volume by `PPM`, so `10^30 * 10^6` sits + * about 170-fold inside an `i128`. Everything guarded here is `BigInt`, because a ceiling a + * representation cannot hold is a ceiling it cannot enforce. */ +const MAX_BOUND_SUM=10n**30n; +// Results cross the JSON/Number boundary. Intermediates may use the wider ceiling above, +// but every returned key must fit exactly in every binding before it becomes a Number. +const MAX_BOUND_VALUE=2n**53n-1n; +/** A sum in the bound path exceeded the declared ceiling. Structured rather than a number: + * a bound that is quietly wrong is worse than none, because it will be believed. */ +export class BoundOverflowError extends Error{ + constructor(quantity,ceiling=MAX_BOUND_SUM,subject='sum'){ + super(`${quantity} ${subject} is past the ${ceiling} ceiling the bound path declares`); + this.name='BoundOverflowError'; + } +} +function boundGuard(total,quantity){ + if(total>MAX_BOUND_SUM)throw new BoundOverflowError(quantity); + return total; +} +function boundOutput(value,quantity){ + if(value>MAX_BOUND_VALUE){ + throw new BoundOverflowError(quantity,MAX_BOUND_VALUE,'bound'); + } + return Number(value); +} +/** Can this item take up less room than its declared dimensions? + * + * Three ways, and each breaks the same argument -- that nominal volumes sum to something a + * solution must carry. A nested item sinks into the one below it; a `convex_hull` occupies + * its hull and leaves the rest of its bounding box free; a `compressible` item gives up + * height under load. The design document named only the first until a soundness test over + * the corpus found the omission. */ +function occupiesLessThanItsBox(item){ + if(item.nestingHeight!=null)return true; + return item.shapeType==='convex_hull'||item.shapeType==='compressible'; +} +const boundCeilDiv=(a,b)=>(a+b-1n)/b; +/** The largest n such that the n smallest values sum to at most the capacity. Smallest first + * is the whole soundness argument: the cheapest units maximise how many fit, so this + * over-estimates what any real packing achieves and the bound under-estimates. */ +function boundFit(ascending,capacity){ + if(capacity===null)return ascending.length; + let used=0n; + for(let taken=0;takencapacity)return taken; + } + return ascending.length; +} +/** Sum of limit*quantity, or null when any limit or inventory is undeclared. `zeroIsHarmless` + * is the volume rule: a container with no usable volume adds nothing however many there + * are, so an unlimited quantity only unbounds the total when the type holds something. */ +function boundCapacity(values,quantities,zeroIsHarmless){ + let total=0n; + for(let i=0;ibest?value:best; + } + return best; +} +/** Every bound, from the numbers the formulas consume -- the shape the shared scene records, + * so this port is checked without reimplementing a request parser. `shrinks` is taken as + * given; whether this engine decides it correctly is asserted separately. */ +function objectiveBounds(instances,containers){ + const volumes=instances.map(i=>i.volume).sort((a,b)=>ab?1:0); + const weights=instances.map(i=>i.weight).sort((a,b)=>ab?1:0); + const shrinks=instances.some(i=>i.shrinks); + const count=instances.length; + const usable=containers.map(c=>c.usable),inner=containers.map(c=>c.inner); + const quantities=containers.map(c=>c.quantity); + + // The a-priori check, once, on the way in. Every later product is bounded by these totals + // times PPM, so guarding them here is what makes the rest safe by derivation. + boundGuard(volumes.reduce((a,b)=>a+b,0n),'instance volume'); + boundGuard(weights.reduce((a,b)=>a+b,0n),'instance weight'); + for(const container of containers){ + boundGuard(container.usable,'container capacity'); + boundGuard(container.costMinor,'opening cost'); + } + + let placeable=count; + if(!shrinks)placeable=Math.min(placeable,boundFit(volumes,boundCapacity(usable,quantities,true))); + placeable=Math.min(placeable,boundFit(weights,boundCapacity(containers.map(c=>c.payload),quantities,false))); + const slotCapacity=boundCapacity(containers.map(c=>c.maxItems),quantities,false); + if(slotCapacity!==null)placeable=Math.min(placeable,Number(slotCapacity)); + const unpacked=count-placeable,placed=placeable; + + let opened=0; + if(placed>0&&containers.length){ + opened=1; + if(!shrinks){ + const largest=usable.reduce((a,b)=>b>a?b:a,0n); + if(largest>0n)opened=Math.max(opened,Number(boundCeilDiv(volumes.slice(0,placed).reduce((a,b)=>a+b,0n),largest))); + } + const payload=boundFiniteMax(containers.map(c=>c.payload)); + if(payload!==null&&payload>0n)opened=Math.max(opened,Number(boundCeilDiv(weights.slice(0,placed).reduce((a,b)=>a+b,0n),payload))); + const slots=boundFiniteMax(containers.map(c=>c.maxItems)); + if(slots!==null&&slots>0n)opened=Math.max(opened,Number(boundCeilDiv(BigInt(placed),slots))); + } + + let cost=0n; + if(opened>0){ + const available=[]; + for(const c of containers){ + const repeat=c.quantity===null?opened:Math.min(Number(c.quantity),opened); + for(let taken=0;takenab?1:0); + cost=available.slice(0,opened).reduce((a,b)=>a+b,0n); + } + + let unused=0n; + if(!shrinks&&opened>0&&containers.length){ + const smallest=inner.reduce((a,b)=>b0n){ + const largestPlaced=placed>0?volumes.slice(volumes.length-placed).reduce((a,b)=>a+b,0n):0n; + // In BigInt until it is clamped: `largestPlaced * PPM` can reach 10^36, which a + // `Number` would round rather than carry. + const filled=boundCeilDiv(largestPlaced*BOUND_PPM,smallest); + const raw=BigInt(opened)*BOUND_PPM-filled-BigInt(opened-1); + unused=raw>0n?raw:0n; + } + } + + let height=0n; + if(!shrinks&&opened>0&&containers.length){ + const widest=containers.map(c=>c.baseArea).reduce((a,b)=>b>a?b:a,0n); + const tallest=containers.map(c=>c.height).reduce((a,b)=>b>a?b:a,0n); + if(widest>0n&&tallest>0n){ + const required=placed>0?boundCeilDiv(volumes.slice(0,placed).reduce((a,b)=>a+b,0n),widest):0n; + const raw=required*BOUND_PPM/tallest-BigInt(opened-1); + height=raw>0n?raw:0n; + } + } + return [ + boundOutput(BigInt(unpacked),'unpacked count'), + boundOutput(BigInt(opened),'container count'), + boundOutput(boundGuard(cost,'opening cost'),'opening cost'), + boundOutput(unused,'unused volume'), + boundOutput(height,'stack height'), + ]; +} +/** Exposed for the cross-language scene test only, like `__inspectHullShapeForTests`: the + * bounds are internal until a contract freeze decides whether a caller ever sees a gap. */ +export function __objectiveBoundsForTests(instances,containers){return objectiveBounds(instances,containers);} +export function __occupiesLessThanItsBoxForTests(item){return occupiesLessThanItsBox(item);} +function boxShape(dx,dy,dz){ + const v=[]; + for(const x of [0n,BigInt(dx)])for(const y of [0n,BigInt(dy)])for(const z of [0n,BigInt(dz)])v.push([x,y,z]); + return {v,faces:UNIT_AXES,edges:UNIT_AXES,volume:BigInt(dx)*BigInt(dy)*BigInt(dz)}; +} +/** Reorient a hull the way a rotation reorients its box, never mirroring it. + * + * Three of the six rotations are odd permutations of the coordinate axes. On a cuboid that is + * invisible; on a hull a bare permutation returns the item's mirror image, a shape the caller + * does not own. One axis therefore changes sign when the permutation is odd. */ +function hullRotate(vertices,code){ + const axes=ROT[code]; + let inversions=0; + for(let i=0;i<3;i++)for(let j=i+1;j<3;j++)if(axes[i]>axes[j])inversions++; + const sign=inversions%2?-1:1; + const turned=vertices.map(v=>[sign*v[axes[0]],v[axes[1]],v[axes[2]]]); + const low=[0,1,2].map(a=>Math.min(...turned.map(v=>v[a]))); + return turned.map(v=>[v[0]-low[0],v[1]-low[1],v[2]-low[2]]); +} +function separatingAxes(left,right){ + const axes=new Map(); + for(const axis of [...left.faces,...right.faces])axes.set(axisKey(axis),axis); + for(const l of left.edges)for(const r of right.edges){ + const axis=primitiveAxis(cross3(l,r)); + if(axis)axes.set(axisKey(axis),axis); + } + return [...axes.values()]; +} +/** Do two placed hulls overlap with positive volume? Touching is contact, not collision: the + * comparison is `<=`, matching the half-open convention cuboids already use. */ +function hullsCollide(left,leftOrigin,right,rightOrigin){ + const lo=big3(leftOrigin),ro=big3(rightOrigin); + for(const axis of separatingAxes(left,right)){ + const project=shape=>{let low=null,high=null; + for(const v of shape.v){const value=dot3(v,axis); + if(low===null||valuehigh)high=value} + return [low,high]}; + const [ll,lh]=project(left),[rl,rh]=project(right); + const ls=dot3(lo,axis),rs=dot3(ro,axis); + if(lh+ls<=rl+rs||rh+rs<=ll+ls)return false; + } + return true; +} + +// ---------------------------------------------------------------- compression + +const COMPRESSION_PPM=1000000n; +const GRAVITY_NUMERATOR=980665n,GRAVITY_DENOMINATOR=100000n,PASCALS_PER_KPA=1000n; +/** Exact pressure in kPa from the cumulative mass above an item, over its footprint. Reduced, + * so the divisor in the height formula stays small and two engines agreeing on the value + * cannot disagree on the representation. */ +function appliedPressure(loadTicks,footprintTicks){ + const metre=BigInt(LEN.mm)*1000n; + const n=BigInt(loadTicks)*GRAVITY_NUMERATOR*metre*metre; + const d=BigInt(WT.kg)*GRAVITY_DENOMINATOR*PASCALS_PER_KPA*BigInt(footprintTicks); + const g=bigGcd(n,d)||1n; + return {n:n/g,d:d/g}; +} +/** Cross multiplication, so the inclusive boundary is decided without ever dividing. */ +const pressureExceeds=(pressure,limitKpa)=>pressure.n>BigInt(limitKpa)*pressure.d; +/** Occupied height under load, rounded up, never below one tick. Rounding up keeps a discrete + * packer honest; the one-tick floor stops a fully compressible item reaching zero height, + * where it would slip past collision and support invariants entirely. */ +function effectiveHeight(heightTicks,ratioPpm,limitKpa,pressure){ + if(limitKpa===0)return heightTicks; + const divisor=BigInt(limitKpa)*COMPRESSION_PPM*pressure.d; + const retained=divisor-BigInt(ratioPpm)*pressure.n; + const rounded=(BigInt(heightTicks)*retained+divisor-1n)/divisor; + return Number(rounded>1n?rounded:1n); +} +/** The published ratio rule, `floor(ratio * 1000000 + 0.5)`, applied once at the boundary so + * the float a caller supplied never reaches the geometry. */ +function ratioToPpm(ratio){ + if(!(ratio>=0&&ratio<=1))throw new RangeError('compression_ratio must be between zero and one'); + return Math.floor(ratio*1000000+0.5); +} function validNesting(a,b){if(a.item.raw.id!==b.item.raw.id||a.item.nesting==null||b.item.nesting==null||a.item.nesting!==b.item.nesting)return false; if(a.x!==b.x||a.y!==b.y||a.x+a.ed[0]!==b.x+b.ed[0]||a.y+a.ed[1]!==b.y+b.ed[1])return false; const [low,high]=a.z<=b.z?[a,b]:[b,a];return low.z!==high.z&&low.z+low.ed[2]-high.z===a.item.nesting} -function usedVolume(placements){let total=placements.reduce((s,p)=>s+volume(p.pd),0n),overlap=0n; +/** This placement's rotated hull, or `null` when its box is the honest answer. + * + * `null` for every `rigid_cuboid` and for three cases that fall back to the box, always + * over-reserving space: a clearance has inflated the envelope past the physical box and a + * margin around a hull is not a hull; the item is on a route, where the sequence replay + * reasons with box sweeps only and packing tighter than it can verify would produce + * arrangements the engine then calls unloadable. */ +function placedHull(placement){ + const item=placement.item; + if(item.shapeType!=='convex_hull'||item.stopIndex!=null)return null; + if(placement.ed[0]!==placement.pd[0]||placement.ed[1]!==placement.pd[1]||placement.ed[2]!==placement.pd[2])return null; + return shapeFor(item.hullVertices,placement.r); +} +/** 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. */ +function solidsOverlap(leftShape,leftBox,rightShape,rightBox){ + if(leftShape===null&&rightShape===null)return true; + return hullsCollide( + leftShape??boxShape(leftBox.d[0],leftBox.d[1],leftBox.d[2]),[leftBox.x,leftBox.y,leftBox.z], + rightShape??boxShape(rightBox.d[0],rightBox.d[1],rightBox.d[2]),[rightBox.x,rightBox.y,rightBox.z]); +} +/** Space one placement actually takes, which is its box only if it is one. + * + * A `convex_hull` item occupies its hull: counting the bounding box is not a conservative + * approximation of utilisation but a wrong number, putting two interlocking wedges at 200% of + * a crate. A `compressible` item occupies the height left after the load it reports. */ +function occupiedVolume(placement,loadTicks=0){ + const item=placement.item; + // Route and clearance can make collision conservatively use the envelope; neither changes + // the physical solid used for utilisation and void-fill reserve accounting. + if(item.shapeType==='convex_hull')return shapeFor(item.hullVertices,placement.r).volume; + if(item.maxCompressionKpa==null)return volume(placement.pd); + const footprint=placement.pd[0]*placement.pd[1]; + // The load is passed in rather than read off the placement: this engine computes top loads + // at reporting time and never stores them, so a placement field would have been silently + // zero and nothing would ever have compressed. + const pressure=appliedPressure(loadTicks,footprint); + // A crushed item has no meaningful occupied volume, and the arrangement is already invalid + // -- the crush check refuses it and the validator reports it. + if(pressureExceeds(pressure,item.maxCompressionKpa))return volume(placement.pd); + return BigInt(footprint)*BigInt(effectiveHeight(placement.pd[2],item.compressionPpm,item.maxCompressionKpa,pressure)); +} +/** 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 + * one question in two currencies -- a mass the box below must bear, against a pressure the + * item itself must survive. An item can pass one and fail the other, so both are asked. */ +function crushed(boxes,loads=null){ + if(boxes.every(b=>b.maxCompressionKpa==null))return false; + if(loads==null)loads=topLoads(boxes); + return boxes.some((b,i)=>{ + if(b.maxCompressionKpa==null)return false; + const footprint=b.d[0]*b.d[1]; + return pressureExceeds(appliedPressure(Number(loads[i]),footprint),b.maxCompressionKpa); + }); +} +/** Parse and admit an item's shape, or refuse it with the reason. + * + * Coordinates go through the length scale, which refuses a negative value, so a hull crossing + * the wire is authored as non-negative offsets from the corner of its own bounding box. The + * admission rule spans four fields at once -- which are required, which are forbidden, and + * what the survivors must agree with -- and mirrors the other three engines exactly. */ +function parseShape(raw,d,unit,nesting){ + const shapeType=raw.shape_type??'rigid_cuboid'; + if(!['rigid_cuboid','convex_hull','compressible'].includes(shapeType)) + throw new RangeError(`item.shape_type ${shapeType} is not a known shape`); + const hullVertices=raw.hull_vertices==null?null:raw.hull_vertices.map(v=> + [scalar(v.x,unit,LEN),scalar(v.y,unit,LEN),scalar(v.z,unit,LEN)]); + const compressionPpm=raw.compression_ratio==null?null:ratioToPpm(raw.compression_ratio); + const maxCompressionKpa=raw.max_compression_pressure_kpa==null?null:Number(raw.max_compression_pressure_kpa); + const foreign=shapeType==='convex_hull' + ?[['compression_ratio',compressionPpm],['max_compression_pressure_kpa',maxCompressionKpa]] + :shapeType==='compressible'?[['hull_vertices',hullVertices]] + :[['hull_vertices',hullVertices],['compression_ratio',compressionPpm],['max_compression_pressure_kpa',maxCompressionKpa]]; + for(const [name,value] of foreign) + if(value!=null)throw new RangeError(`${name} is not part of a ${shapeType} item`); + // Both rewrite occupied height. Choosing an order silently would give four engines four + // contracts, so the interaction is refused until a task defines it. + if(nesting!=null&&shapeType!=='rigid_cuboid') + throw new RangeError(`nesting_height with shape_type ${shapeType} is not supported yet`); + if(shapeType==='convex_hull'){ + if(hullVertices===null)throw new RangeError('a convex_hull item requires hull_vertices'); + const points=hullValidate(hullVertices); + for(let axis=0;axis<3;axis++){ + const span=Math.max(...points.map(v=>v[axis]))-Math.min(...points.map(v=>v[axis])); + // `dimensions` stays the broad phase and the candidate-generation envelope, so a hull + // poking out of it would be collision-tested against space never reserved. + if(span>d[axis])throw new RangeError('hull_vertices span does not fit inside dimensions'); + } + } + if(shapeType==='compressible'){ + if(compressionPpm===null||maxCompressionKpa===null) + throw new RangeError('a compressible item requires both compression_ratio and max_compression_pressure_kpa'); + if(maxCompressionKpa<0)throw new RangeError('max_compression_pressure_kpa cannot be negative'); + } + return {shapeType,hullVertices,compressionPpm,maxCompressionKpa}; +} +function usedVolume(placements){ + // Compression needs the cumulative mass above each item, which is the same propagation the + // reported `top_load` uses -- one traversal, read twice. + 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;is+volume(p.pd // what is already there -- O(n) where `usedVolume` is O(n^2). The search calls this once // per candidate orientation, which is what made the whole solve super-linear in item // count before. -function usedVolumeDelta(placements,tentative){let delta=volume(tentative.pd); +function usedVolumeDelta(placements,tentative){let delta=occupiedVolume(tentative); for(const placed of placements)if(validNesting(placed,tentative)) delta-=BigInt(placed.item.nesting)*BigInt(placed.ed[0])*BigInt(placed.ed[1]); return delta} @@ -162,6 +723,21 @@ function insertPoint(points,point){let low=0,high=points.length; // candidate list bounded instead of letting it grow with every placement, which is what // left the fallback evaluating two orders of magnitude more points per item than Python //. The half-open test matches `intersects`. +/** Retire the points a placement covers -- unless it is a hull. + * + * Retiring a point because it falls inside a solid's box assumes the box *is* the solid. For a + * hull it is not: a placement origin is a corner of a bounding box, and a hull leaves most of + * that box -- including, for a wedge, the origin itself -- available to the next item. Pruning + * them first would mean the engine could describe an interlocking pack it could never propose, + * and the exact collision test would be correct and never consulted. + * + * One wrapper rather than a guard at each call site: the Rust port found a *second* place that + * treated a box as the solid, and a single entry point is what makes a third impossible to + * forget. */ +function retirePointsForPlacement(points,placement){ + if(placedHull(placement)!==null)return; + retirePointsInside(points,{x:placement.x,y:placement.y,z:placement.z,d:placement.ed}); +} function retirePointsInside(points,box){const x2=box.x+box.d[0],y2=box.y+box.d[1],z2=box.z+box.d[2]; let write=0; for(let read=0;read0n); @@ -369,7 +949,101 @@ function routeContactAllowed(candidate,placed,supports){ * physical rule the schema accepts is enforced here instead: a result that claims to * honour a rule it ignored is worse than no result at all. */ -function allowed(candidate,placed,container,globalSupportPpm,metrics){ +// `loadBase` is a thunk, not a graph: the caller knows the placed boxes cannot move for +// this item's whole candidate sweep, but most candidates never reach the load rules at +// all, and building a base none of them asks for would be pure cost. It yields null +// whenever the delta does not apply -- see `candidatesFor`. +// Dimensions reach this rule in two shapes and both are legitimate: the solver carries them +// as `[length, width, height]`, while a caller holding a request or a fixture carries the +// named object. `sweptVolume` reads the named form, and an array silently answers `3` for +// `.length` -- so normalising here is not tidiness. Before 's review this predicate +// returned the opposite verdict for the same scene depending on which shape it was handed, +// and nothing caught it because no request path supplies a direction list yet. +const namedDimensions=value=>Array.isArray(value) + ?{length:value[0],width:value[1],height:value[2]}:value; +const innerDimensions=container=>namedDimensions(container.d!==undefined?container.d:container); +// Only position and envelope size matter to a corridor, so the box is built here rather than +// through `constraintBox`, which also carries load, nesting and item type -- none of which +// this rule reads, and all of which an embedder would have to supply to call it. +const corridorBox=p=>({x:p.x,y:p.y,z:p.z,d:namedDimensions(p.ed)}); + +/** + * The corridors open in one immutable placement state. + * + * Built once per candidate sweep and reused by every candidate: the placed boxes cannot move + * while one item is being placed, so the placed-versus-placed intersections give the same + * answer every time. Construction is `O(m^2 * |D|)` and each candidate then costs + * `O(m * |D|)`, matching what the Rust core does with the same state. Rebuilding per + * candidate would make switching the doors on cost `O(m^2 * |D|)` for every candidate -- the + * hoist exists so that wiring the field later does not also have to repair a hot loop. + * + * The base is keyed to one candidate stop, so it is valid for exactly one item's sweep. + */ +export function stopAccessibilityBase(candidateStop,placed,container,directions){ + const stop=candidateStop??Infinity,stops=placed.map(p=>p.item.stopIndex??Infinity); + // No doors is the default on every request path, and one distinct stop means nothing is + // due before anything else. Either way no corridor can be wrongly blocked. Checked over + // the candidate too, or the first placement into an empty container would skip a check it + // should make. + if(!directions||directions.length===0||stops.every(each=>each===stop)) + return {inert:true,stop,stops,directions:[],inner:null,boxes:[],clear:[]}; + const inner=innerDimensions(container),boxes=placed.map(corridorBox); + const clear=boxes.map((box,index)=>stops[index]===Infinity + // Never unloaded, so it needs no door of its own -- it only ever blocks. + ?[] + :directions.map(direction=>sweptVolume(box,inner,direction)) + .filter(sweep=>!boxes.some((other,position)=>position!==index + &&stops[position]>stops[index]&&sweptHits(sweep,other)))); + return {inert:false,stop,stops,directions:[...directions],inner,boxes,clear}; +} + +function accessibleAgainst(base,candidateBox){ + if(base.inert)return true; + // Every already-placed item due before the candidate must keep a door the candidate does + // not take. + for(let index=0;index!sweptHits(sweep,candidateBox)))return false; + } + // An item riding the whole route is never unloaded, so it needs no door of its own. + if(base.stop===Infinity)return true; + return base.directions.some(direction=>{ + const sweep=sweptVolume(candidateBox,base.inner,direction); + return !base.boxes.some((other,index)=>base.stops[index]>base.stop&&sweptHits(sweep,other)); + }); +} + +// The horizontal half of route order: nothing due later may stand between an earlier item +// and a door. `routeContactAllowed` above enforces the vertical half -- nothing +// due later may rest *above* something due earlier. Both are necessary and neither implies +// the other; docs/STOP-ACCESSIBILITY.md derives the rule and the post-validator's +// whole-scene replay stays the sufficient check. +// +// Inert unless the caller supplies exit directions. The request schema has no field for +// them, and assuming all six walls open would enforce a rule true of no real vehicle and +// nearly vacuous besides -- a box is almost always free through *some* face. This engine +// has no programmatic config path, so the request path always passes the empty list and an +// embedder reaches the rule by calling this function directly, which is as close as +// JavaScript gets to the config field Python, PHP and Rust carry. +// +// The blocker set is `{q : s(q) > s(p)}` -- strictly later. Same-stop items are excluded +// because the order within a stop is free: whichever is in the way comes off first. +// +// One implementation, not two: this builds the base and asks it, so the exported predicate +// and the solver's hot path cannot drift apart. +export function stopAccessible(candidate,placed,container,directions){ + return accessibleAgainst( + stopAccessibilityBase(candidate.item.stopIndex,placed,container,directions), + corridorBox(candidate)); +} + +// Half-open on every axis, matching the box intersection test, so a box flush against +// another's exit face is not standing in its way. +function sweptHits([sx1,sy1,sz1,sx2,sy2,sz2],box){ + return sx1p.item.maxTop!=null); + const needsLoads=container.maxStackDensity!=null||candidate.item.maxTop!=null||placed.some(p=>p.item.maxTop!=null) + ||candidate.item.maxCompressionKpa!=null||placed.some(p=>p.item.maxCompressionKpa!=null); const needsGraph=needsLoads||candidate.item.maxStacked!=null||placed.some(p=>p.item.maxStacked!=null); - if(!needsGraph)return groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports); - const boxes=[...placed.map(constraintBox),constraintBox(candidate)]; - const graph=contactGraph(boxes),loads=needsLoads?topLoads(boxes,graph):null; - return !overloaded(boxes,loads)&&!stackLimitsExceeded(boxes,graph)&&!stackDensityExceeded(boxes,container.maxStackDensity,loads) - &&groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports); + if(!needsGraph)return groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports) + &&(accessBase===null||accessibleAgainst(accessBase,corridorBox(candidate))); + // With a base for this sweep, both the box list and the graph come from it by + // appending one box, rather than each candidate rebuilding both from every placement. + // The two paths are required to agree exactly, which is what `contact-graph`'s append + // property test holds them to. + const candidateBox=constraintBox(candidate),base=loadBase===null?null:loadBase(); + const boxes=base===null?[...placed.map(constraintBox),candidateBox]:[...base.boxes,candidateBox]; + const graph=base===null?contactGraph(boxes):appendContactBox(base,candidateBox,overlapXY); + const loads=needsLoads?topLoads(boxes,graph):null; + return !overloaded(boxes,loads)&&!crushed(boxes,loads)&&!stackLimitsExceeded(boxes,graph)&&!stackDensityExceeded(boxes,container.maxStackDensity,loads) + &&groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports) + &&(accessBase===null||accessibleAgainst(accessBase,corridorBox(candidate))); } function supportRatioOf(placement,placed){ @@ -469,6 +1152,10 @@ function admitItem(raw,u){ if(raw.stop_index!=null&&(!Number.isSafeInteger(raw.stop_index)||raw.stop_index<0))throw new RangeError('stop_index must be a non-negative safe integer'); if(raw.value!=null&&(!Number.isSafeInteger(raw.value)||raw.value<0))throw new RangeError('value must be a non-negative safe integer'); if(raw.ground_contact_rule!=null&&!['free','covered','single','multiple'].includes(raw.ground_contact_rule))throw new RangeError('ground_contact_rule must be free, covered, single or multiple'); + // The shape rules belong here for the reason this function exists: the compact lattice path + // never builds an `items` entry, so an admission living only in the general path's item loop + // would let the two disagree about which requests are legal. + parseShape(raw,d,u,nesting); if(raw.eligible_container_tags!=null&&(!Array.isArray(raw.eligible_container_tags)||raw.eligible_container_tags.some(tag=>typeof tag!=='string')))throw new TypeError('eligible_container_tags must be an array of strings'); } @@ -514,6 +1201,12 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver const raw=req.items[0],quantity=raw.quantity??1; if(!Number.isSafeInteger(quantity)||quantity<1||raw.group!=null||(raw.tags??[]).length||(raw.incompatible_tags??[]).length ||(raw.eligible_container_tags??[]).length||raw.max_stacked_items!=null||raw.nesting_height!=null + // The lattice is closed-form over boxes: it counts cells from envelope extents and reports + // volume from its own summary. It can see neither a hull -- it would tile bounding boxes + // and call the result exact -- nor pressure, so a compressible column would be sized + // without ever asking whether its base survives, and reported uncompressed. The general + // search checks both per candidate. + ||(raw.shape_type!=null&&raw.shape_type!=='rigid_cuboid') ||!['free',null,undefined].includes(raw.ground_contact_rule))return null; const itemDimensions=dims(raw.dimensions,u),weight=scalar(raw.weight??0,'g',WT); const rotations=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)); @@ -920,7 +1613,8 @@ const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scal 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})} + stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],value:raw.value??0, + ...parseShape(raw,d,u,nesting)})} // 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. @@ -994,6 +1688,34 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ // the check belongs here beside the eligibility and tag-limit gates rather than inside // the point loop, and costs O(m + r) per (template, item) instead of per candidate. if(policyRules.length&&policyRejection(policyRules,item.tags,tmpl.tags??[],tagOccurrences(state.placements))!==null)return []; + // The placed boxes do not move for the whole of this item's candidate sweep, so + // their contact graph is built once here -- on first demand, since most candidates never + // reach a load rule -- and every candidate appends to it instead of rebuilding. + // + // 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); + let loadBaseGraph; + const loadBase=nestingPresent?null:()=>{ + if(loadBaseGraph===undefined){ + // The cell must cover every box hashed into the broad phase or queried against it, + // and the candidate is a new item that may be wider than anything placed -- so the + // hint comes from this item's own rotations, which are known here. + const widest=Math.max(1,...item.rots.flatMap(r=>{const pd=rotate(item.d,r); + return [pd[0]+2*clear,pd[1]+2*clear]})); + loadBaseGraph=buildContactGraph(state.placements.map(constraintBox),overlapXY,widest); + } + return loadBaseGraph; + }; + // The same argument, for the other rule that reads the whole placed scene. The + // doors are empty on every request path today, so this base is inert and costs one pass + // over the stops -- it is built here rather than inside `allowed` so that wiring the + // field through later does not silently turn an O(m*|D|) check into O(m^2*|D|) per + // candidate. + const accessBase=stopAccessibilityBase(item.stopIndex,state.placements,tmpl,[]); + const compressionSensitive=item.shapeType==='compressible' + ||state.placements.some(placement=>placement.item.shapeType==='compressible'); const found=[]; const candidates=points.length>maxCandidatePoints?points.slice(0,maxCandidatePoints):points; candidatePoints:for(const pt of candidates){if(candidateEffortExceeded())break;metrics.candidate_points_considered++;for(const r of item.rots){if(candidateEffortExceeded())break candidatePoints;metrics.orientations_considered++;if(deadline.expired()){timeLimitReached=true;break candidatePoints}const pd=rotate(item.d,r),ed=pd.map(x=>x+2*clear),box={x:pt[0],y:pt[1],z:pt[2],d:ed}; @@ -1001,9 +1723,12 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ if(tmpl.max!=null&&state.payload+item.w>tmpl.max)continue; if(tmpl.max_items!=null&&state.placements.length>=tmpl.max_items)continue; const tentative={x:pt[0],y:pt[1],z:pt[2],pd,ed,r,item}; - if(used+usedVolumeDelta(state.placements,tentative)+tmpl.reserve>tmpl.innerVolume)continue; + if(!compressionSensitive&&used+usedVolumeDelta(state.placements,tentative)+tmpl.reserve>tmpl.innerVolume)continue; let collision=false; - for(const obstacle of tmpl.obs){metrics.collision_checks++;if(intersects(box,obstacle)){collision=true;break}} + const candidateShape=item.shapeType==='convex_hull'&&item.stopIndex==null&&clear===0 + ?shapeFor(item.hullVertices,r):null; + for(const obstacle of tmpl.obs){metrics.collision_checks++; + if(intersects(box,obstacle)&&solidsOverlap(candidateShape,box,null,obstacle)){collision=true;break}} // Broad phase: visit only the placements sharing a cell with `box`, stamping each // so a placement spanning several cells is narrow-phase-checked once. A generation // counter does that without allocating a set per candidate orientation. @@ -1012,11 +1737,23 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ const bucket=index.cells.get(cellKey(ix,iy,iz));if(!bucket)continue; for(const position of bucket){if(index.seen[position]===stamp)continue;index.seen[position]=stamp; const placed=state.placements[position];metrics.collision_checks++; - if(intersects(box,{x:placed.x,y:placed.y,z:placed.z,d:placed.ed})&&!validNesting(tentativeBox,placed)){collision=true;break scan}}}} + const placedBox={x:placed.x,y:placed.y,z:placed.z,d:placed.ed}; + if(intersects(box,placedBox)&&!validNesting(tentativeBox,placed) + // The axis-aligned test is the broad phase and stays mandatory. Only when a hull + // is one of the two solids does the exact test get to overrule it, so a request of + // ordinary boxes never reaches the hull path at all. + &&solidsOverlap(candidateShape,box,placedHull(placed),placedBox)){collision=true;break scan}}}} if(collision)continue; const candidate={x:pt[0],y:pt[1],z:pt[2],pd,ed,r,item}; if(axleOverloaded(tmpl,state.placements,candidate))continue; - if(!allowed(candidate,state.placements,tmpl,globalSupportPpm,metrics))continue; + if(!allowed(candidate,state.placements,tmpl,globalSupportPpm,metrics,loadBase,accessBase))continue; + // With zero load the candidate is at its largest, and appending it can only shrink + // existing compressible supports. If that upper bound fits, the exact support-graph + // refresh cannot reject it; only a candidate near the reserve boundary pays the + // non-local calculation. Ordinary requests retain the incremental O(1) path above. + if(compressionSensitive){const upperBound=used+occupiedVolume(tentative); + if(upperBound+tmpl.reserve>tmpl.innerVolume + &&usedVolume([...state.placements,tentative])+tmpl.reserve>tmpl.innerVolume)continue} metrics.feasible_candidates++; const score=solverAlias==='grid' ?pt[2]*1e12+pt[1]*1e6+pt[0] @@ -1047,9 +1784,13 @@ const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const state={tmpl,placements:[ metrics.search_nodes_expanded++; const best=candidatesFor(tmpl,item,state,points,index,used,1)[0]; if(!best){ok=false;break} - state.payload+=item.w;used+=usedVolumeDelta(state.placements,best);state.placements.push(best); + state.payload+=item.w; + const compressionSensitive=item.shapeType==='compressible' + ||state.placements.some(placement=>placement.item.shapeType==='compressible'); + used=compressionSensitive?usedVolume([...state.placements,best]):used+usedVolumeDelta(state.placements,best); + state.placements.push(best); indexAdd(index,state.placements.length-1,{x:best.x,y:best.y,z:best.z,d:best.ed}); - retirePointsInside(points,{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(snapshotPoints)points.splice(0,points.length,...snapshotPoints); @@ -1063,9 +1804,12 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ index:makeIndex(tmpl.d),unplaced:[]}); const clone=node=>({state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used, points:node.points.slice(),index:copyIndex(node.index),unplaced:node.unplaced.slice()}); - const place=(node,candidate)=>{node.state.payload+=candidate.item.w;node.used+=usedVolumeDelta(node.state.placements,candidate); + const place=(node,candidate)=>{node.state.payload+=candidate.item.w; + const compressionSensitive=candidate.item.shapeType==='compressible' + ||node.state.placements.some(placement=>placement.item.shapeType==='compressible'); + node.used=compressionSensitive?usedVolume([...node.state.placements,candidate]):node.used+usedVolumeDelta(node.state.placements,candidate); node.state.placements.push(candidate);indexAdd(node.index,node.state.placements.length-1,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed}); - retirePointsInside(node.points,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed});for(const point of pointsFrom(candidate))insertPoint(node.points,point)}; + retirePointsForPlacement(node.points,candidate);for(const point of pointsFrom(candidate))insertPoint(node.points,point)}; const sortCosts=costs=>costs.sort((a,b)=>ab?1:0); const maxCount=(sortedCosts,capacity)=>{let used=0n,count=0;for(const cost of sortedCosts){if(used+cost>capacity)break;used+=cost;count++}return count}; // `future` is the same array for every comparison inside one `expansions.sort(...)` @@ -1124,7 +1868,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ w.state.payload+=candidate.item.w;w.used+=usedVolumeDelta(w.state.placements,candidate); w.state.placements.push(candidate); indexAdd(w.index,w.state.placements.length-1,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed}); - retirePointsInside(w.points,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed}); + retirePointsForPlacement(w.points,candidate); for(const point of pointsFrom(candidate))insertPoint(w.points,point)}; // One child per feasible candidate for a lone item; a group is all-or-nothing, so it // contributes at most one child placed greedily member by member. @@ -1454,6 +2198,8 @@ 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??[], + ...parseShape(raw,dims(raw.dimensions,unit),unit, + raw.nesting_height==null?null:scalar(raw.nesting_height,unit,LEN)), }) } const templates=new Map((req.containers??[]).map(raw=>{ diff --git a/index.js b/index.js index a4cee21..ad13695 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,7 @@ export function rebalanceWeight(request,result,{maxMoves=64}={}){ } return rebalanceFallback(request,result,{maxMoves}); } -export const version=()=>native?.version?.()??'0.1.3-js-fallback'; +export const version=()=>native?.version?.()??'1.0.0-js-fallback'; /** * The exported commercial and control-plane API: a quote, a policy decision and catalog diff --git a/package.json b/package.json index e7344fe..0de4175 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name":"@packvium/engine", - "version":"0.1.3", + "version":"1.0.0", "description":"Native-first 3D cartonization with deterministic JS fallback", "keywords":["3d-bin-packing","bin-packing","cartonization","packing","container-loading","logistics","shipping","deterministic"], "homepage":"https://packvium.com", @@ -12,10 +12,10 @@ "exports":{".":{"types":"./index.d.ts","import":"./index.js"}}, "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","examples","index.d.ts","README.md","SECURITY.md"], "engines":{"node":">=16"}, - "optionalDependencies":{"@packvium/native":"0.1.3"}, + "optionalDependencies":{"@packvium/native":"1.0.0"}, "scripts":{ - "test":"node --test \"test/*.test.mjs\"", - "test:legacy":"node test/legacy-conformance.mjs" + "test":"node test/run-tests.mjs", + "test:legacy":"node test/legacy-smoke.mjs" }, "license":"MIT" } diff --git a/test/contact-graph.test.mjs b/test/contact-graph.test.mjs index 6cb2beb..c512ee5 100644 --- a/test/contact-graph.test.mjs +++ b/test/contact-graph.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { buildContactGraph } from '../contact-graph.js'; +import { appendContactBox, buildContactGraph } from '../contact-graph.js'; const overlapXY = (left, right) => { const x = Math.max(0, Math.min(left.x + left.d[0], right.x + right.d[0]) - Math.max(left.x, right.x)); @@ -36,3 +36,138 @@ test('the broad phase produces the exact same edges as a pairwise scan', () => { })); assert.deepEqual(graph.supporters, expected); }); + +// ----------------------------------------------------- incremental append + +/** + * A scene whose boxes actually touch each other, from a replayable generator. + * + * What is under test is that a delta reproduces edges, so a corpus of scenes that mostly + * have no edges at all would pass with the delta returning nothing. Snapping every + * coordinate and extent to one coarse lattice makes shared planes the norm. + */ +const lcg = seed => { + let state = BigInt(seed); + return bound => { + state = (state * 6364136223846793005n + 1n) & 0xffffffffffffffffn; + return Number((state >> 33n) % BigInt(bound)); + }; +}; + +const touchingScene = (next, count) => { + const extents = [10, 20, 30]; + return Array.from({ length: count }, () => ({ + x: next(6) * 10, y: next(6) * 10, z: next(4) * 10, + d: [extents[next(3)], extents[next(3)], extents[next(3)]], + })); +}; + +const widestFootprint = boxes => Math.max(1, ...boxes.map(box => Math.max(box.d[0], box.d[1]))); +const edges = graph => [graph.supporters, graph.children]; + +test('appending a box matches building the whole scene at once', () => { + // The base carries the widest footprint in the scene as its hint, which is what a + // solver knows before it starts placing: the candidate about to be appended may be + // larger than anything already placed, and sizing the broad phase from the placed boxes + // alone would send every append into the fallback. + for (let seed = 0; seed < 40; seed++) { + const next = lcg(2000 + seed); + const boxes = touchingScene(next, 2 + next(13)); + const split = Math.max(1, Math.floor(boxes.length / 2)); + const hint = widestFootprint(boxes); + const base = buildContactGraph(boxes.slice(0, split), overlapXY, hint); + let graph = base; + for (const box of boxes.slice(split)) graph = appendContactBox(graph, box, overlapXY); + // A full rebuild recounts its broad-phase probes; the delta path carries the base's + // count forward untouched. So an unchanged count is what says the delta ran -- without + // it, an assertion that the two graphs match is equally satisfied by an append that + // quietly rebuilds everything, which is none of the point. + assert.equal(graph.candidateChecks, base.candidateChecks, `seed ${seed} fell back`); + assert.deepEqual(edges(graph), edges(buildContactGraph(boxes, overlapXY)), `seed ${seed}`); + } +}); + +test('a box wider than the hint rebuilds and is still correct', () => { + // The hint is an optimisation; being wrong about it may cost time, never an answer. + const small = [ + { x: 0, y: 0, z: 0, d: [10, 10, 10] }, + { x: 10, y: 0, z: 0, d: [10, 10, 10] }, + ]; + const wide = { x: 0, y: 0, z: 10, d: [40, 10, 10] }; + const base = buildContactGraph(small, overlapXY); + const graph = appendContactBox(base, wide, overlapXY); + assert.notEqual(graph.candidateChecks, base.candidateChecks); + assert.deepEqual(graph.supporters[2].map(([index]) => index), [0, 1]); + assert.deepEqual(edges(graph), edges(buildContactGraph([...small, wide], overlapXY))); +}); + +test('an appended box lands last in the lists it joins', () => { + // The new box always takes the highest index, so appending it to an existing supporter + // list keeps that list ascending -- but only because it is appended and not inserted, + // which a from-scratch comparison on random scenes can miss when no scene happens to + // produce the collision. + const scene = [ + { x: 0, y: 0, z: 0, d: [10, 10, 10] }, + { x: 0, y: 0, z: 10, d: [20, 10, 10] }, + { x: 10, y: 0, z: 0, d: [10, 10, 10] }, + ]; + const graph = appendContactBox( + buildContactGraph(scene, overlapXY, 20), { x: 0, y: 0, z: 20, d: [10, 10, 10] }, overlapXY); + assert.deepEqual(graph.supporters[1].map(([index]) => index), [0, 2]); + assert.deepEqual(graph.children[1], [3]); +}); + +test('appending leaves the base graph untouched', () => { + // The base is shared across every candidate evaluated against one search state, so an + // append that wrote through to it would corrupt each candidate for the next. + const base = buildContactGraph([{ x: 0, y: 0, z: 0, d: [10, 10, 10] }], overlapXY, 10); + appendContactBox(base, { x: 0, y: 0, z: 10, d: [10, 10, 10] }, overlapXY); + assert.equal(base.boxes.length, 1); + assert.deepEqual(base.children[0], []); +}); + +test('the delta matches a rebuild across scene shapes', () => { + // The property test above asserts the base's candidateChecks is carried forward, which + // proves the delta ran rather than quietly rebuilding -- and therefore never exercises a + // run where the fallback and the delta interleave. A cell hint of one produces exactly + // that, several times per scene. + // + // Three axes vary independently. Tight coordinates make shared planes and zero-area edge + // contacts the norm; coordinates at 1e9 push the broad phase's cell arithmetic somewhere + // a lattice never goes; a huge hint collapses every box into one cell, which is the + // degenerate case the hash exists to avoid and therefore the one most likely to be wrong. + const tight = [0, 1, 2, 5, 10]; + const wide = [0, 10, 100, 1e9]; + const tiny = [1, 2, 3]; + const mixed = [1, 5, 10, 40]; + const shapes = [ + ['tight/tiny/exact', tight, tiny, 'exact'], + ['tight/tiny/one', tight, tiny, 'one'], + ['tight/mixed/one', tight, mixed, 'one'], + ['tight/mixed/huge', tight, mixed, 'huge'], + ['wide/mixed/exact', wide, mixed, 'exact'], + ['wide/mixed/one', wide, mixed, 'one'], + ['wide/tiny/huge', wide, tiny, 'huge'], + ]; + + shapes.forEach(([name, coordinates, extents, hintMode], shapeIndex) => { + const next = lcg(7000 + shapeIndex); + for (let trial = 0; trial < 60; trial++) { + const count = 1 + next(10); + const boxes = Array.from({ length: count }, () => ({ + x: coordinates[next(coordinates.length)], + y: coordinates[next(coordinates.length)], + z: coordinates[next(coordinates.length)], + d: [extents[next(extents.length)], extents[next(extents.length)], extents[next(extents.length)]], + })); + const widest = widestFootprint(boxes); + const hint = hintMode === 'exact' ? widest : hintMode === 'one' ? 1 : widest * 100; + const split = Math.max(1, Math.floor(count / 2)); + let graph = buildContactGraph(boxes.slice(0, split), overlapXY, hint); + for (const box of boxes.slice(split)) graph = appendContactBox(graph, box, overlapXY); + assert.deepEqual(edges(graph), edges(buildContactGraph(boxes, overlapXY)), + `${name} trial ${trial}`); + } + }); +}); + diff --git a/test/fallback.test.mjs b/test/fallback.test.mjs index 7aec916..0a2e67c 100644 --- a/test/fallback.test.mjs +++ b/test/fallback.test.mjs @@ -3,7 +3,9 @@ import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; import { - Deadline, SequenceReplayError, UNSUPPORTED_FIELDS, UnsupportedFeatureError, aggregateTermination, + Deadline, SequenceReplayError, UNSUPPORTED_FIELDS, UnsupportedFeatureError, + BoundOverflowError, __inspectHullShapeForTests, __objectiveBoundsForTests, + __occupiesLessThanItsBoxForTests, aggregateTermination, explainUnpackedItem, explanationForUnpackedItem, packFallback, rebalanceWeight, verifyLoadingPrefixBusinessRules, } from '../fallback.js'; @@ -51,6 +53,10 @@ const nestedTopLoadFixtureUrl = new URL( const sharedNestedTopLoadRequest = existsSync(nestedTopLoadFixtureUrl) ? JSON.parse(readFileSync(nestedTopLoadFixtureUrl)) : null; +const hullInternalsUrl = new URL('../../../../conformance/scene/hull-internals.json', import.meta.url); +const sharedHullInternals = existsSync(hullInternalsUrl) + ? JSON.parse(readFileSync(hullInternalsUrl)) + : null; test('explanations are deterministic and localization-ready', () => { const item = { @@ -1546,16 +1552,43 @@ test('a rebalance move that would leave the destination unpriceable is vetoed', // ---------------------------------------------------- staged rollout -test('the guard refuses exactly the fields the unsupported lists name', () => { +test('the guard refuses exactly the fields the unsupported lists name', (t) => { // A field this engine has not implemented is rejected rather than ignored: an engine // that reads a request it does not fully understand and answers confidently is - // indistinguishable, from the outside, from one that honoured every field. The lists - // are empty while this engine is current with the shared schema, and this test is what - // holds a future staging to the same guard rather than to a hand-written throw. + // indistinguishable, from the outside, from one that honoured every field. + // + // This used to assert the lists are all empty, which was the same thing while they + // were -- and stopped being the same thing the moment populated two. What the + // guard is actually for is that `public-field-matrix.json` records each refusal, so the + // shared corpus *asserts* it instead of merely tolerating it. So read the matrix and + // compare both directions. assert.deepEqual(Object.keys(UNSUPPORTED_FIELDS).sort(), - ['configuration', 'container', 'item', 'obstacle', 'request']); - const named = Object.values(UNSUPPORTED_FIELDS).flat(); - assert.deepEqual(named, [], `staged fields still unimplemented: ${named.join(', ')}`); + ['configuration', 'container', 'item', 'obstacle', 'request', 'shapeType']); + const matrixUrl = new URL( + '../../conformance/shared/public-field-matrix.json', import.meta.url); + if (!existsSync(matrixUrl)) { + t.skip('the shared public-field matrix is not part of this package'); + return; + } + const matrix = JSON.parse(readFileSync(matrixUrl, 'utf8')); + const rejectedByMatrix = Object.entries(matrix.fields) + .filter(([, row]) => matrix.support_sets[row.support].javascript === 'rejected:unsupported_feature') + .map(([field]) => field).sort(); + const declared = new Set([ + ...UNSUPPORTED_FIELDS.request, + ...UNSUPPORTED_FIELDS.configuration.map((name) => `configuration.${name}`), + ...UNSUPPORTED_FIELDS.item.map((name) => `items.*.${name}`), + ...UNSUPPORTED_FIELDS.container.map((name) => `containers.*.${name}`), + ]); + // A value-keyed refusal is one matrix row for the field itself. `hull_vertices` is an + // array of points, so the schema's leaves -- and therefore its rows -- are the three + // coordinates, not the array. + if (UNSUPPORTED_FIELDS.shapeType.length) declared.add('items.*.shape_type'); + if (declared.delete('items.*.hull_vertices')) { + for (const axis of 'xyz') declared.add(`items.*.hull_vertices.*.${axis}`); + } + assert.deepEqual([...declared].sort(), rejectedByMatrix, + 'the engine and the matrix disagree about what JavaScript refuses'); const carrying = { request: (field) => ({ [field]: {} }), configuration: (field) => ({ configuration: { [field]: {} } }), @@ -1574,6 +1607,244 @@ test('the guard refuses exactly the fields the unsupported lists name', () => { } }); +test('the default shape type is served rather than refused', () => { + // `rigid_cuboid` is implemented, so spelling the default out must not be a rejection. + // This is why `shape_type` is not in the presence-keyed table: that table means "this + // engine does not implement the field at all", and a value-keyed refusal is a different + // claim. A caller who writes the default explicitly is asking for what they already get. + const result = packFallback(request( + [{ ...cube('a'), shape_type: 'rigid_cuboid' }], [box('c')])); + assert.equal(result.status, 'feasible'); +}); + +test('a convex hull is packed by its hull rather than its box', () => { + // closed the staged rollout that began with this engine refusing both shapes. Two + // complementary halves of one cube share a crate that fits one of their bounding boxes -- + // the outcome an engine deciding collisions from boxes cannot produce, and the reason the + // refusal existed rather than packing a hull as its envelope. + const side = 100; + const lower = [[0, 0, 0], [side, 0, 0], [0, side, 0], [0, 0, side], [side, 0, side], [0, side, side]]; + const upper = [[side, side, 0], [side, 0, 0], [0, side, 0], [side, side, side], [side, 0, side], [0, side, side]]; + const hull = (id, vertices) => ({ + id, quantity: 1, dimensions: { length: '100', width: '100', height: '100' }, + shape_type: 'convex_hull', + hull_vertices: vertices.map(([x, y, z]) => ({ x: String(x), y: String(y), z: String(z) })), + }); + const result = packFallback(request( + [hull('lower', lower), hull('upper', upper)], + [{ id: 'crate', inner_dimensions: { length: '100', width: '100', height: '100' } }])); + assert.equal(result.status, 'feasible'); + assert.deepEqual( + result.containers.map((container) => container.placements.map((p) => p.item_id).sort()), + [['lower#1', 'upper#1']]); + // Two bounding boxes would fill the crate twice over; two hulls fill it exactly once. + assert.equal(result.containers[0].used_volume_ticks3, String((100 * 16000) ** 3)); +}); + +test('a routed hull keeps its physical volume when collision falls back to its box', () => { + const side = 100; + const lower = [[0,0,0],[side,0,0],[0,side,0],[0,0,side],[side,0,side],[0,side,side]]; + const result = packSound(request([{ + id: 'routed', dimensions: mm(side, side, side), shape_type: 'convex_hull', stop_index: 0, + hull_vertices: lower.map(([x,y,z]) => ({ x: String(x), y: String(y), z: String(z) })), + }], [box('crate', side, side, side)])); + assert.equal(result.containers[0].used_volume_ticks3, String(BigInt(side * MM) ** 3n / 2n)); +}); + +test('the shape memo answers a repeated pack exactly as the first one', () => { + // put a process-lifetime memo in front of hull construction, which is where a + // determinism regression would hide: a wrong cached entry is invisible on the first call + // and only shows on the second. Packing the same request twice in one process is what + // distinguishes a memo from a mutation -- the second pack reads every shape from the cache + // and must not be able to tell. + const side = 100; + const lower = [[0, 0, 0], [side, 0, 0], [0, side, 0], [0, 0, side], [side, 0, side], [0, side, side]]; + const upper = [[side, side, 0], [side, 0, 0], [0, side, 0], [side, side, side], [side, 0, side], [0, side, side]]; + const hull = (id, vertices) => ({ + id, quantity: 1, dimensions: { length: '100', width: '100', height: '100' }, + shape_type: 'convex_hull', + hull_vertices: vertices.map(([x, y, z]) => ({ x: String(x), y: String(y), z: String(z) })), + }); + const build = () => packFallback(request( + [hull('lower', lower), hull('upper', upper)], + [{ id: 'crate', inner_dimensions: { length: '100', width: '100', height: '100' } }])); + const first = build(); + const second = build(); + assert.deepEqual(second, first); + // Named separately from the deep compare: the volume is the number the hull geometry + // decides, so a memo handing back a stale or foreign shape shows here first. + assert.equal(second.containers[0].used_volume_ticks3, String((100 * 16000) ** 3)); +}); + +test('the face walk and real-edge set match the numeric cross-language order', (t) => { + // These are the three hulls where the former decimal-string minimum selected a non-corner + // on one face. A packing-output test cannot catch that: the extra axes are safe and only + // make the predicate slower. Pin the actual internals, including order, from the independent + // Python port just as PHP and Rust do in their own suites. + if (sharedHullInternals === null) { + t.skip('the shared cross-language scene fixture is not part of this package'); + return; + } + assert.equal(sharedHullInternals.format, 'packvium-hull-internals/v1'); + const axesAsStrings = axes => axes.map(axis => axis.map(String)); + for (const one of sharedHullInternals.cases) { + const expected = { + volume: one.volume, + faceAxes: axesAsStrings(one.face_axes), + edgeDirections: axesAsStrings(one.edge_directions), + }; + const { vertices } = one; + assert.deepEqual(__inspectHullShapeForTests(vertices), expected); + } +}); + +test('a rotated hull is packed by the hull that rotation actually produces', () => { + // The memo is keyed on the item's vertices *and* the orientation. Keying on vertices alone + // would pass every test that packs one orientation, and quietly hand a wedge its neighbour's + // shape the moment a second rotation was tried -- so the six are asked for by name here. + const side = 100; + const wedge = [[0, 0, 0], [side, 0, 0], [0, side, 0], [0, 0, side], [side, 0, side], [0, side, side]]; + const attempt = (rotations) => packFallback(request( + [{ + id: 'w', quantity: 2, dimensions: { length: '100', width: '100', height: '100' }, + shape_type: 'convex_hull', allowed_rotations: rotations, + hull_vertices: wedge.map(([x, y, z]) => ({ x: String(x), y: String(y), z: String(z) })), + }], + [{ id: 'crate', inner_dimensions: { length: '200', width: '100', height: '100' } }])); + const everyOrientation = attempt(['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']); + assert.equal(everyOrientation.status, 'feasible'); + // Two wedges are half a cube each, whichever way they are turned. The figure is a property + // of the shapes, not of the orientation search, so it may not move when the search widens. + assert.equal( + everyOrientation.containers[0].used_volume_ticks3, + String((100 * 16000) ** 3)); + assert.equal(attempt(['LWH']).containers[0].used_volume_ticks3, + everyOrientation.containers[0].used_volume_ticks3); +}); + +test('the objective lower bound matches Python on every corpus case', (t) => { + // asks only that this engine's bound never exceed the achieved objective, because + // this engine is not held to placement equality. That freedom does not extend to a bound: + // it is a function of the request, so a disagreement with Python would be a defect in one + // of the two rather than the permitted difference in how they place items. Equality is + // achievable here, so equality is what is asserted. + const sceneUrl = new URL('../../../../conformance/scene/objective-bounds.json', import.meta.url); + if (!existsSync(sceneUrl)) { + t.skip('the shared bounds scene is not part of this package'); + return; + } + const scene = JSON.parse(readFileSync(sceneUrl)); + assert.equal(scene.format, 'packvium-objective-bounds/v1'); + assert.ok(scene.cases.length > 300, + 'a scene that quietly emptied itself would make every case below vacuous'); + for (const one of scene.cases) { + const instances = one.instances.map((raw) => ({ + volume: BigInt(raw.volume), weight: BigInt(raw.weight), shrinks: raw.shrinks, + })); + const containers = one.containers.map((raw) => ({ + usable: BigInt(raw.usable), inner: BigInt(raw.inner), baseArea: BigInt(raw.base_area), + height: BigInt(raw.height), + payload: raw.payload === null ? null : BigInt(raw.payload), + maxItems: raw.max_items === null ? null : BigInt(raw.max_items), + quantity: raw.quantity === null ? null : BigInt(raw.quantity), + costMinor: BigInt(raw.cost_minor), + })); + assert.deepEqual(__objectiveBoundsForTests(instances, containers), one.bounds, + `bounds diverge from Python on ${one.fixture}`); + } +}); + +test('a sum past the declared ceiling is refused rather than answered', () => { + // , at the same two inputs Python, PHP and Rust assert. The ceiling is declared + // rather than inherited: this engine's `Number` stops being exact past 2^53, PHP's integers + // silently become doubles, Python's are unbounded and Rust's i128 wraps. If each refused at + // its own limit, a caller would get a number from one engine and a refusal from another for + // the same request. + const ceiling = 10n ** 30n; + const containers = [{ usable: 1000n, inner: 1000n, baseArea: 100n, height: 10n, + payload: null, maxItems: null, quantity: 1n, costMinor: 0n }]; + const three = (volume) => Array.from({ length: 3 }, + () => ({ volume, weight: 1n, shrinks: false })); + + assert.deepEqual(__objectiveBoundsForTests(three(ceiling / 4n), containers), [3, 0, 0, 0, 0]); + assert.throws(() => __objectiveBoundsForTests(three(ceiling / 2n), containers), + BoundOverflowError); +}); + +test('a bound that cannot cross every binding exactly is refused', () => { + // Unlimited inventory used to make the a-priori cost check count one container while L2 + // opened two. JavaScript then rounded the resulting Number. The result ceiling is lower + // than the wide intermediate ceiling precisely because all four ports must return it. + const exactPortable = 2n ** 53n - 1n; + const instances = [ + { volume: 1n, weight: 1n, shrinks: false }, + { volume: 1n, weight: 1n, shrinks: false }, + ]; + const containers = [{ usable: 1n, inner: 1n, baseArea: 1n, height: 1n, + payload: null, maxItems: 1n, quantity: null, costMinor: exactPortable }]; + + assert.throws(() => __objectiveBoundsForTests(instances, containers), BoundOverflowError); +}); + +test('each shape that occupies less than its box is recognised', () => { + // The scene supplies this flag ready-made so its equality check is about arithmetic alone. + // That leaves exactly one thing it cannot catch, and it is the omission found in + // Python: a port checking only `nestingHeight` is unsound for the two irregular shapes, + // both of which occupy less than their bounding box for the same reason. + assert.equal(__occupiesLessThanItsBoxForTests({ shapeType: 'rigid_cuboid' }), false); + assert.equal(__occupiesLessThanItsBoxForTests({ shapeType: 'convex_hull' }), true); + assert.equal(__occupiesLessThanItsBoxForTests({ shapeType: 'compressible' }), true); + assert.equal( + __occupiesLessThanItsBoxForTests({ shapeType: 'rigid_cuboid', nestingHeight: 1 }), true); +}); + +test('a shape refuses data belonging to another shape', () => { + // Refused rather than ignored: a `compression_ratio` silently dropped on a `convex_hull` + // reads back as an item packed to limits it never had. + assert.throws( + () => packFallback(request([{ ...cube('a'), shape_type: 'convex_hull' }], [box('c')])), + /a convex_hull item requires hull_vertices/); + assert.throws( + () => packFallback(request([{ ...cube('a'), compression_ratio: 0.25 }], [box('c')])), + /compression_ratio is not part of a rigid_cuboid item/); + assert.throws( + () => packFallback(request( + [{ ...cube('a'), shape_type: 'compressible', compression_ratio: 0.25 }], [box('c')])), + /a compressible item requires both/); +}); + +test('a compressible column compresses its own base', () => { + // Ordering cannot vary here -- two identical items in a one-footprint crate -- so this is + // the scene that makes `compression_ratio` observable in this engine as well as the other + // three. The base keeps 87.7% of its height under 50 kg over 0.01 square metres. + const cushion = { + id: 'cushion', quantity: 2, dimensions: { length: '100', width: '100', height: '100' }, + weight: { value: '50', unit: 'kg' }, shape_type: 'compressible', + compression_ratio: 0.25, max_compression_pressure_kpa: 100, + }; + const result = packFallback(request([cushion], + [{ id: 'crate', inner_dimensions: { length: '100', width: '100', height: '200' } }])); + assert.equal(result.containers.length, 1); + const uncompressed = BigInt((100 * 16000) ** 3) * 2n; + assert.ok(BigInt(result.containers[0].used_volume_ticks3) < uncompressed, + 'the base must give up height under the load above it'); +}); + +test('a load past the crush limit is refused rather than packed', () => { + // A hard boundary, not a worse score: 0.01 square metres under 100 kPa puts it between + // 101 kg and 102 kg, close enough to state and far enough from a round number that a + // floating-point shortcut would land on the wrong side of it. + const scene = (kilograms) => request([ + { id: 'cushion', quantity: 1, dimensions: { length: '100', width: '100', height: '100' }, + weight: { value: '2', unit: 'kg' }, must_be_on_floor: true, shape_type: 'compressible', + compression_ratio: 0.25, max_compression_pressure_kpa: 100 }, + { id: 'brick', quantity: 1, dimensions: { length: '100', width: '100', height: '100' }, + weight: { value: String(kilograms), unit: 'kg' } }, + ], [{ id: 'crate', inner_dimensions: { length: '100', width: '100', height: '200' } }]); + const stacked = (result) => result.containers.some((c) => c.placements.length === 2); + assert.equal(stacked(packFallback(scene(102))), false, '102 kg crushes the cushion'); +}); + test('a policy rule set is honoured rather than refused', () => { // The staged rollout that began with this engine refusing the block is closed: // segregation opens a second container here, and a request that packed both items diff --git a/test/legacy-smoke.mjs b/test/legacy-smoke.mjs new file mode 100644 index 0000000..78136b7 --- /dev/null +++ b/test/legacy-smoke.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; + +import { backend, pack, version } from '../index.js'; + +const result = pack({ + items: [ + { id: 'a', quantity: 2, dimensions: { length: '10', width: '10', height: '10' } }, + ], + containers: [ + { id: 'c', inner_dimensions: { length: '20', width: '10', height: '10' } }, + ], +}); + +assert.ok(['rust', 'javascript'].includes(backend())); +assert.equal(typeof version(), 'string'); +assert.equal(result.status, 'feasible'); +assert.equal(result.complete, true); +assert.equal(result.summary.packed_item_count, 2); +assert.equal(result.summary.unpacked_item_count, 0); +assert.equal(result.containers.length, 1); + +process.stdout.write('legacy Node smoke: PASS\n'); diff --git a/test/run-tests.mjs b/test/run-tests.mjs new file mode 100644 index 0000000..ccf2fd6 --- /dev/null +++ b/test/run-tests.mjs @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const tests = fs.readdirSync(testDirectory) + .filter((name) => name.endsWith('.test.mjs')) + .sort() + .map((name) => path.join(testDirectory, name)); + +if (tests.length === 0) { + process.stderr.write(`no test files found in ${testDirectory}\n`); + process.exit(1); +} + +const completed = spawnSync(process.execPath, ['--test', ...tests], { + stdio: 'inherit', +}); + +if (completed.error) throw completed.error; +process.exit(completed.status ?? 1); diff --git a/test/stop-accessibility.test.mjs b/test/stop-accessibility.test.mjs new file mode 100644 index 0000000..07422b4 --- /dev/null +++ b/test/stop-accessibility.test.mjs @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync,readFileSync} from 'node:fs'; +import {stopAccessible,stopAccessibilityBase} from '../fallback.js'; + +// The worked examples in docs/STOP-ACCESSIBILITY.md, read from the corpus the Python, PHP +// and Rust suites read. They were four separate transcriptions until that file existed, so a +// verdict could drift in one engine and stay green in the other three; one table makes that +// impossible rather than merely unlikely. +// +// Only `accessible` is asserted here. The corpus also carries `code`, which needs a +// constraint that answers with a reason, and `route_order_allowed`, which needs a +// route-order check -- this engine's is deliberately not exported, and widening its public +// surface for a test would be the wrong trade. Both columns are asserted by the engines +// that can reach them, and the corpus records which those are. + +const fixture=new URL('../../../../conformance/scene/stop-accessibility-fixtures.json',import.meta.url); +const scenes=existsSync(fixture)?JSON.parse(readFileSync(fixture,'utf8')).scenes:null; + +// Position and envelope size are all a corridor reads, which is why `stopAccessible` takes +// this shape rather than a full placement. +const boxOf=(raw)=>({ + x:raw.origin.x,y:raw.origin.y,z:raw.origin.z, + ed:raw.dimensions, + item:{id:raw.id,stopIndex:raw.stop_index,w:0}, + w:0, +}); + +test('the shared four-language scene corpus is present and non-empty',(t)=>{ + if(scenes===null){t.skip('the shared cross-language scene corpus is not part of this package');return;} + assert.ok(scenes.length>0,'an empty corpus would let every scene below pass without asserting anything'); +}); + +for(const scene of scenes??[]){ + test(`stop accessibility: ${scene.id}`,()=>{ + assert.equal( + stopAccessible(boxOf(scene.candidate),scene.placements.map(boxOf),scene.container,scene.directions), + scene.accessible, + scene.why, + ); + }); +} + +// The solver carries dimensions as `[length, width, height]` and this corpus carries the +// named object. Both are legitimate inputs and an array answers `3` for `.length`, so the +// same scene used to get opposite verdicts depending on which shape reached the rule -- +// invisibly, because no request path supplies a direction list. Running the whole corpus in +// the solver's own shape as well is what makes that class of defect impossible to reach +// again, rather than merely fixed once. +const solverShaped=(raw)=>({ + x:raw.origin.x,y:raw.origin.y,z:raw.origin.z, + ed:[raw.dimensions.length,raw.dimensions.width,raw.dimensions.height], + item:{id:raw.id,stopIndex:raw.stop_index,w:0}, + w:0, +}); + +for(const scene of scenes??[]){ + test(`stop accessibility in the solver's data shape: ${scene.id}`,()=>{ + const container={d:[scene.container.length,scene.container.width,scene.container.height]}; + assert.equal( + stopAccessible(solverShaped(scene.candidate),scene.placements.map(solverShaped), + container,scene.directions), + scene.accessible, + scene.why, + ); + }); +} + +// The solver does not call the exported predicate: it builds the base once per candidate +// sweep and asks that. The two must agree on every scene, or the engine and the corpus are +// testing different rules. +for(const scene of scenes??[]){ + test(`the hoisted base agrees with the predicate: ${scene.id}`,()=>{ + const placed=scene.placements.map(boxOf); + const base=stopAccessibilityBase(scene.candidate.stop_index,placed,scene.container, + scene.directions); + assert.equal(base.inert&&!scene.accessible,false, + 'an inert base can only ever allow, so a refused scene must not produce one'); + assert.equal( + stopAccessible(boxOf(scene.candidate),placed,scene.container,scene.directions), + scene.accessible,scene.id); + }); +} diff --git a/test/validate.mjs b/test/validate.mjs index 8ac163e..c464e29 100644 --- a/test/validate.mjs +++ b/test/validate.mjs @@ -25,6 +25,13 @@ function requestLengthTicks(measure, defaultUnit) { return Math.round(Number(raw) * LENGTH_TICKS[unit]); } +/** A request weight in ticks, defaulting to grams the way the schema does. */ +function requestWeightTicks(measure) { + const raw = measure && typeof measure === 'object' ? measure.value : measure; + const unit = measure && typeof measure === 'object' ? (measure.unit ?? 'g') : 'g'; + return Math.round(Number(raw ?? 0) * WEIGHT_TICKS[unit]); +} + function overlaps(one, other) { return one.at.every((start, axis) => start < other.at[axis] + other.size[axis] && start + one.size[axis] > other.at[axis]); @@ -48,6 +55,7 @@ function boxesOf(container, latticeSequences = new Map()) { type: placement.item_type, at: corner(placement.position), size: size(placement.dimensions), + orientation: placement.orientation, })); const summary = container.lattice_summary; if (summary == null) return boxes; @@ -63,6 +71,7 @@ function boxesOf(container, latticeSequences = new Map()) { boxes.push({ id: `${summary.item_type}#${sequence}`, type: summary.item_type, + orientation: summary.orientation, at: [ x * envelope[0] + clearance[0], y * envelope[1] + clearance[1], @@ -78,6 +87,144 @@ function boxesOf(container, latticeSequences = new Map()) { /** * @returns {string[]} one code per violation, empty when the result is sound. */ + +// ---------------------------------------------------------------- irregular geometry +// +// The engine's counterpart of this file must not be imported: an independent recompute is the +// whole point, so the separating-axis rule and the pressure model are written here from +// docs/IRREGULAR-ITEMS.md. Every product is a `BigInt` -- a projection reaches 2.4e25 while a +// JavaScript number is exact only to 2^53. +const vsub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const vcross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +const vdot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const vkey = (v) => `${v[0]},${v[1]},${v[2]}`; +const vcompare = (left, right) => { + for (let axis = 0; axis < 3; axis++) { + if (left[axis] < right[axis]) return -1; + if (left[axis] > right[axis]) return 1; + } + return 0; +}; +function vgcd(a, b) { a = a < 0n ? -a : a; b = b < 0n ? -b : b; while (b) { const t = a % b; a = b; b = t; } return a; } +function vprimitive(v) { + const g = vgcd(vgcd(v[0], v[1]), v[2]); + if (g === 0n) return null; + const r = [v[0] / g, v[1] / g, v[2] / g]; + const lead = r.find((x) => x !== 0n); + return lead > 0n ? r : [-r[0], -r[1], -r[2]]; +} +/** The rotation applied to a hull, as a proper rotation: three of the six orientations are odd + * permutations, and a bare permutation would return the item's mirror image. */ +function rotateHull(vertices, orientation) { + const axes = { LWH: [0, 1, 2], LHW: [0, 2, 1], WLH: [1, 0, 2], WHL: [1, 2, 0], HLW: [2, 0, 1], HWL: [2, 1, 0] }[orientation]; + let inversions = 0; + for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) if (axes[i] > axes[j]) inversions++; + const sign = inversions % 2 ? -1n : 1n; + const turned = vertices.map((v) => [sign * v[axes[0]], v[axes[1]], v[axes[2]]]); + const low = [0, 1, 2].map((a) => turned.reduce((m, v) => (v[a] < m ? v[a] : m), turned[0][a])); + return turned.map((v) => [v[0] - low[0], v[1] - low[1], v[2] - low[2]]); +} +/** The solid a placement occupies, in container coordinates: its hull, or its eight box + * corners. Collision replay can deliberately request the route-safe box fallback; physical + * volume never does, because a route changes reachability rather than the authored solid. */ +function solidOf(box, item, lengthUnit, collisionReplay = true) { + const corners = []; + for (const x of [0n, BigInt(box.size[0])]) for (const y of [0n, BigInt(box.size[1])]) for (const z of [0n, BigInt(box.size[2])]) { + corners.push([BigInt(box.at[0]) + x, BigInt(box.at[1]) + y, BigInt(box.at[2]) + z]); + } + if (item?.shape_type !== 'convex_hull' + || (collisionReplay && item.stop_index != null) + || item.hull_vertices == null) return corners; + const local = item.hull_vertices.map((v) => [ + BigInt(requestLengthTicks(v.x, lengthUnit)), + BigInt(requestLengthTicks(v.y, lengthUnit)), + BigInt(requestLengthTicks(v.z, lengthUnit)), + ]); + return rotateHull(local, box.orientation).map((v) => + [v[0] + BigInt(box.at[0]), v[1] + BigInt(box.at[1]), v[2] + BigInt(box.at[2])]); +} +/** Whether two convex solids share interior volume. Touching is contact, not collision. */ +function solidsOverlap(left, right) { + const axes = new Map(); + for (const hull of [left, right]) { + for (let i = 0; i < hull.length; i++) for (let j = i + 1; j < hull.length; j++) for (let k = j + 1; k < hull.length; k++) { + const axis = vprimitive(vcross(vsub(hull[j], hull[i]), vsub(hull[k], hull[i]))); + if (axis) axes.set(vkey(axis), axis); + } + } + const directions = (hull) => { + const out = []; + for (let i = 0; i < hull.length; i++) for (let j = i + 1; j < hull.length; j++) out.push(vsub(hull[j], hull[i])); + return out; + }; + for (const a of directions(left)) for (const b of directions(right)) { + const axis = vprimitive(vcross(a, b)); + if (axis) axes.set(vkey(axis), axis); + } + for (const axis of axes.values()) { + const span = (hull) => hull.reduce((acc, v) => { + const value = vdot(v, axis); + return [value < acc[0] ? value : acc[0], value > acc[1] ? value : acc[1]]; + }, [vdot(hull[0], axis), vdot(hull[0], axis)]); + const [ll, lh] = span(left); const [rl, rh] = span(right); + if (lh <= rl || rh <= ll) return false; + } + return true; +} +/** Occupied volume: the hull's own, or the compressed box, or the box. */ +function occupiedVolumeOf(box, item, lengthUnit, loadTicks) { + if (item?.shape_type === 'convex_hull' && item.hull_vertices != null) { + const solid = solidOf(box, item, lengthUnit, false); + let six = 0n; + const axes = new Map(); + for (let i = 0; i < solid.length; i++) for (let j = i + 1; j < solid.length; j++) for (let k = j + 1; k < solid.length; k++) { + const axis = vprimitive(vcross(vsub(solid[j], solid[i]), vsub(solid[k], solid[i]))); + if (!axis) continue; + const offset = vdot(solid[i], axis); + const sides = solid.map((v) => vdot(v, axis) - offset); + if (sides.every((x) => x <= 0n) || sides.every((x) => x >= 0n)) axes.set(vkey(axis), axis); + } + for (const axis of axes.values()) for (const outward of [axis, [-axis[0], -axis[1], -axis[2]]]) { + const extreme = solid.reduce((m, v) => { const value = vdot(v, outward); return value > m ? value : m; }, vdot(solid[0], outward)); + const face = solid.filter((v) => vdot(v, outward) === extreme); + if (face.length < 3) continue; + const sorted = [...face].sort(vcompare); + const ordered = [sorted[0]]; let current = sorted[0]; + for (let step = 0; step < sorted.length; step++) { + let next = null; + for (const candidate of sorted) { + if (vkey(candidate) === vkey(current)) continue; + if (next === null) { next = candidate; continue; } + const turn = vdot(vcross(vsub(next, current), vsub(candidate, current)), outward); + const reach = vdot(vsub(candidate, current), vsub(candidate, current)); + const held = vdot(vsub(next, current), vsub(next, current)); + if (turn < 0n || (turn === 0n && reach > held)) next = candidate; + } + if (next === null || vkey(next) === vkey(sorted[0])) break; + ordered.push(next); current = next; + } + for (let i = 1; i + 1 < ordered.length; i++) six += vdot(ordered[0], vcross(ordered[i], ordered[i + 1])); + } + const magnitude = six < 0n ? -six : six; + return magnitude / 6n; + } + if (item?.max_compression_pressure_kpa == null) return volume(box.size); + const footprint = BigInt(box.size[0]) * BigInt(box.size[1]); + const metre = 16000n * 1000n; + const numerator = BigInt(loadTicks) * 980665n * metre * metre; + const denominator = 8000000000n * 100000n * 1000n * footprint; + const g = vgcd(numerator, denominator) || 1n; + const pressure = { n: numerator / g, d: denominator / g }; + const limit = BigInt(item.max_compression_pressure_kpa); + if (pressure.n > limit * pressure.d) return volume(box.size); + if (limit === 0n) return volume(box.size); + const ratioPpm = BigInt(Math.floor(item.compression_ratio * 1000000 + 0.5)); + const divisor = limit * 1000000n * pressure.d; + const retained = divisor - ratioPpm * pressure.n; + const height = (BigInt(box.size[2]) * retained + divisor - 1n) / divisor; + return footprint * (height > 1n ? height : 1n); +} + export function validate(request, result) { const issues = []; const itemsById = new Map(request.items.map((item) => [item.id, item])); @@ -120,7 +267,12 @@ export function validate(request, result) { } for (const other of boxes.slice(index + 1)) { - if (overlaps(box, other) && !validNesting(box, other, itemsById, lengthUnit)) { + // The axis-aligned test is the broad phase; the exact test refines it only when a hull + // is one of the two solids, so an ordinary box pair reaches the same verdict it always + // did. + if (overlaps(box, other) && !validNesting(box, other, itemsById, lengthUnit) + && solidsOverlap(solidOf(box, item, lengthUnit), + solidOf(other, itemsById.get(other.type), lengthUnit))) { issues.push('collision'); } } @@ -236,7 +388,24 @@ export function objective(request, result) { const inner = size(container.inner_dimensions); const total = volume(inner); const boxes = boxesOf(container, latticeSequences); - let used = boxes.reduce((sum, box) => sum + volume(box.size), 0n); + // Occupied volume follows the shape: a hull's own volume, a compressible item's height + // after the load above it, otherwise the box. Counting boxes would put two interlocking + // wedges at 200% of a crate and would never show a compressible item giving up height. + const loadsFor = boxes.some((box) => itemsById.get(box.type)?.max_compression_pressure_kpa != null) + ? boxes.map((box) => { + let carried = 0n; + for (const other of boxes) { + if (other === box) continue; + const restsAbove = other.at[2] === box.at[2] + box.size[2] + && other.at[0] < box.at[0] + box.size[0] && box.at[0] < other.at[0] + other.size[0] + && other.at[1] < box.at[1] + box.size[1] && box.at[1] < other.at[1] + other.size[1]; + if (restsAbove) carried += BigInt(requestWeightTicks(itemsById.get(other.type)?.weight ?? 0)); + } + return carried; + }) + : boxes.map(() => 0n); + let used = boxes.reduce((sum, box, index) => + sum + occupiedVolumeOf(box, itemsById.get(box.type), lengthUnit, loadsFor[index]), 0n); boxes.forEach((box, index) => { for (const other of boxes.slice(index + 1)) { if (validNesting(box, other, itemsById, lengthUnit)) {