diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c7a00..9387ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,59 @@ What changed in `@packvium/engine` on npm, release by release. The format follow [Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.0] + +A validator and a scene converter join the package, `quality` looks ahead in its block solver, +and the JavaScript `quality` beam does the same search for about a third of the work. No request +or result field changes. + +### Added + +- **`validate(request, result)`, `MalformedResult` and `ValidatorUnavailableError`.** The + reference validator: a list of issues (`code`, `detail`), empty when the result is valid, and + `MalformedResult` for a result of the wrong shape. It needs the compiled addon; without it, + `validate` throws `ValidatorUnavailableError` (`code: 'validator_unavailable'`) until the + JavaScript engine's own port lands. +- **`@packvium/engine/scene.js`**: `resultToScenes(request, result)` returns one + `packvium-visualization-scene/v1` per packed container; `artifactToScene(artifact, index)` and + `sceneItemId` build one from an operational artifact. TypeScript types included. + +### Changed + +- **Every schema enum value is refused as `not_allowed`.** A value outside its list in + `allowed_rotations`, the output and dimensional-weight units, `shape_type`, + `ground_contact_rule` or `configuration.solvers` throws `InvalidRequestError` with reason + `not_allowed` at its JSON Pointer, where the JavaScript engine threw a `TypeError`. **`ticks` + as an output unit is now refused**; the schema never listed it. +- **`quality` looks ahead in the block solver.** Each round completes its leading + blocks greedily and commits the best, so the answer is never worse than the greedy pass. On + our benchmark 87 of 89 answers improved and none got worse. Under a tight effort budget the + block start can now report `truncated` / `effort_limit`. `fast` and `balanced` are unchanged. +- **`quality` does the same search for less.** On our 11 pinned instances it takes 37% + of 1.6.0's CPU, `balanced` 95% and `fast` 99%, with byte-identical output on every + conformance fixture. +- **Candidates are ranked by a lexicographic key**, far corner z, y, x, then origin and + rotation; on hull fixtures 10 equal-score layouts changed. +- **A start that repeats another's item-type order runs a neighbouring order instead**, + kept only on a strict win; its record id ends in `+swap-N`. No measured answer changed. +- **Axle-balanced candidate points are offered as a last resort.** On the conformance + and benchmark requests one answer improved and none got worse; on a synthetic axle corpus of + 120 requests 27 improved and 4 got worse. +- **`no_eligible_container` is reported `proven`, and `insufficient_support` is reported** + (`observed`) where the support rule is the only blocker. +- **A `nesting_height` not below the item's height is refused as `invalid_value`**, and padded + unit codes such as `' in '` are accepted, as the native addon accepts them. + +### Fixed + +- **Native-addon errors carry `code`, `reason` and `field`** and are thrown as the same classes + the JavaScript engine throws; they were a plain `Error`. +- **`rebalanceWeight`'s `maxMoves` outside `0..=4294967295` is refused** on both backends, not + wrapped into range. +- **An addon of another release is ignored** and the JavaScript engine answers. +- **`volume_utilization` and `support_ratio` round half-even exactly**, the support ratio is + converted one way everywhere, and the default clock is monotonic. + ## [1.6.0] Boxes cut to measure, flat bags, stacking groups and a volume-first objective in the request. diff --git a/CITATION.cff b/CITATION.cff index 1392839..29c2ea3 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - name: "Packvium contributors" website: "https://packvium.com" -version: "1.6.0" +version: "1.7.0" license: MIT url: "https://packvium.com" repository-code: "https://github.com/toxakara/packvium-node" diff --git a/README.md b/README.md index f1168c0..3060ee8 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,31 @@ A request that is valid but does not fit completely is not an error: the result left out, and why, in `unpacked_items`. [`examples/errors.mjs`](examples/errors.mjs) walks every case above. +## Checking a result + +*New in 1.7.0.* + +`validate(request, result)` checks a result against the request it answers, from the JSON alone: +geometry, support and load, accounting, the result's facts and its score. It is the same +reference validator every Packvium package exports, so it returns the same list. + +The validator runs only in the compiled native addon, and the published package does not ship +one yet, so after a plain `npm install` `validate` throws `ValidatorUnavailableError` (code +`validator_unavailable`) instead of answering. The JavaScript fallback's validator is planned +for 1.8. Check `backend()` first: + +```js +import { MalformedResult, backend, pack, validate } from '@packvium/engine'; + +if (backend() === 'rust') { + const issues = validate(request, pack(request)); // [] -- valid + // [{ code: 'collision', detail: 'cube#1 with cube#2 in box#1' }] for two boxes in one place +} +``` + +It judges any engine's result, not only this package's. A result without the documented shape +throws `MalformedResult`, whose `kind` is `contract` or `shape`. + ## Quotes, policy and catalog versions `commerce` has three functions, all deterministic and all over one document you supply — @@ -207,7 +232,7 @@ and execute without a project around it, and each prints what it teaches. | File | What it shows | | --- | --- | | [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. | -| [`objectives.mjs`](examples/objectives.mjs) | All six objectives on scenes where they genuinely disagree about which container to open. | +| [`objectives.mjs`](examples/objectives.mjs) | Six of the seven objectives (all but `maximum_volume`) on scenes where they genuinely disagree about which container to open. | | [`constraints.mjs`](examples/constraints.mjs) | Rotations and `keep_upright`, support ratio, top load, stacking caps, incompatible tags and atomic groups — each with and without the rule — and `explainUnpackedItem` turning a refusal into a sentence. | | [`limits.mjs`](examples/limits.mjs) | Wheel arches as `obstacles` with `additional_boxes`, `max_items` and `tag_limits` per container, and what happens when container stock or `max_containers` runs out. | | [`trucking.mjs`](examples/trucking.mjs) | A multi-drop van: `stop_index` and a rear door in `access_directions`, two `axles` with load limits, and a safe loading order fed into `buildExecutionPlan`. | @@ -229,7 +254,7 @@ node examples/basic.mjs ## Subpath modules -The main entry point has packing, sequencing, explanations, rebalancing and `commerce`. Four +The main entry point has packing, sequencing, explanations, rebalancing and `commerce`. Five more modules are separate imports, so an application that only packs never loads them: ```js @@ -237,12 +262,19 @@ import { buildExecutionPlan, canonicalPlanJson } from '@packvium/engine/executio import { buildOperationalArtifact, canonicalArtifactJson } from '@packvium/engine/artifacts.js'; import { exportCsv, exportJson, exportWorkOrderHtml } from '@packvium/engine/artifact-exports.js'; import { deriveRevision, rootRevision, verifyRevisionChain } from '@packvium/engine/revisions.js'; +import { artifactToScene, resultToScenes, sceneItemId } from '@packvium/engine/scene.js'; ``` `execution.js` turns a result into numbered work-order steps. It never computes a loading order itself: pass one from `safeLoadingOrder()` as `loadingOrders`, or the plan says its order is `unavailable` rather than guessing. `artifacts.js` and `artifact-exports.js` package a result for a system with no engine; `revisions.js` records changes against an approved plan. +`scene.js` turns a result into one drawable scene per container +(`packvium-visualization-scene/v1`): every length an exact tick string beside a display-only `mm` +value, items already rotated, each id spelled from the placement's type, position and orientation. `resultToScenes(request, result)` +returns them all; `artifactToScene(artifact, index)` reads one from an operational artifact, with a +loading sequence when the artifact's plan carries one. `sceneItemId(reference)` spells a plan +step's placement the way its scene item is named, to match one to the other. ## Features @@ -281,7 +313,7 @@ Documentation, the constraint reference and the benchmarks are at | --- | --- | --- | | Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) | | PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) | -| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.0"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | +| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.7"` | [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) | @@ -312,7 +344,7 @@ CodeMeta form. @software{packvium_node, author = {{Packvium contributors}}, title = {Packvium for Node.js}, - version = {1.6.0}, + version = {1.7.0}, license = {MIT}, url = {https://packvium.com} } diff --git a/artifacts.js b/artifacts.js index d4eb07a..acc1bf0 100644 --- a/artifacts.js +++ b/artifacts.js @@ -22,7 +22,7 @@ export const FORMAT = 'packvium-operational-artifact/v1'; * The engine's own name is deliberately not recorded: four correct builders naming themselves * would emit four different documents. */ -export const SUITE_VERSION = '1.6.0'; +export const SUITE_VERSION = '1.7.0'; /** The deterministic part of `result.algorithm`; `duration_ms` is wall-clock time. */ const SOLVER_FIELDS = ['profile', 'solver', 'seed', 'time_limit_reached', 'effort_limit_reached']; diff --git a/codemeta.json b/codemeta.json index 26ec135..943a863 100644 --- a/codemeta.json +++ b/codemeta.json @@ -3,7 +3,7 @@ "@type": "SoftwareSourceCode", "name": "Packvium for Node.js", "description": "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.", - "version": "1.6.0", + "version": "1.7.0", "license": "https://spdx.org/licenses/MIT", "author": [ { diff --git a/docs/COMMERCE-API.md b/docs/COMMERCE-API.md index 6237164..7fcc53d 100644 --- a/docs/COMMERCE-API.md +++ b/docs/COMMERCE-API.md @@ -160,9 +160,11 @@ may carry only `tariffs`. Field-level rules: than leaving it to be inferred from which fields the payload happens to carry. - An omitted optional field and an explicit JSON `null` mean the same thing: absent. Writing `"minimum_charge_minor": null` is exactly writing nothing. -- Every integer is exact and, except where a model explicitly allows zero, positive. - The per-field bounds are the ones the models already enforce — see - CATALOG-VERSIONING.md and POLICY-RULES.md. +- Every integer is exact. Dimensions, an item's `weight_g`, `max_payload_g`, + `max_stack_height_mm` and `dimensional_weight_divisor` must be positive; `effective_at`, + `published_at`, `cost_minor`, `minimum_charge_minor`, `fuel_surcharge_permille`, + `flat_charge_minor`, `permille_of_base` and each zone rate in + `cost_per_dimensional_kg_minor` may be zero but not negative. ### Input errors versus rejections @@ -322,13 +324,12 @@ original paths and re-export the relocated definitions, so every workspace test, against the exact same objects. There is one definition of `rate_tariff` in the Python tree, and the installed wheel contains it. `commerce/rating/objective.py` — the solver adapter — is untouched and stays a workspace module: it registers in-process scorer and -selector objects, which EXTENDING.md explains are deliberately not -cross-language features. +selector objects, which are deliberately not cross-language features: an in-process +object cannot cross the JSON boundary every other engine is driven through. ## Conformance standard -Held to the standard every other capability in this project is held to, per -TESTING-AND-RELEASE.md: +Held to the standard every other capability in this project is held to: - **Python and PHP: byte-identical.** Both are ports of one contract; canonical JSON (sorted keys, `,`/`:` separators, no trailing whitespace) of the result document must @@ -364,15 +365,15 @@ TESTING-AND-RELEASE.md: | `catalog_version_info` | `O(h + e log e)` | `O(e)` | The `e log e` term is sorting the three id lists; every other bound is a linear scan of -the relevant history. These match the bounds already published for the underlying -models in ALGORITHMS-AND-COMPLEXITY.md — the wrapper -adds parsing and serialization, both linear in the payload, and nothing else. +the relevant history. These are the bounds of the underlying models; the wrapper adds +parsing and serialization, both linear in the payload, and nothing else. ## Limitations - The document is supplied by the caller. Nothing here fetches, scrapes or embeds any - real carrier's published rates; live rate-card ingestion is still out of scope and - still tracked in LIMITATIONS-AND-ROADMAP.md. + real carrier's published rates. Live rate-card ingestion exists, but in the carrier + connectors, not here: CARRIER-RATE-INGESTION.md builds a + table from a feed, and this API is handed the result like any other document. - `catalog_version_info` returns metadata and id lists, not the resolved master-data records. Exporting the full snapshot is a larger surface with its own wire-format question and is not part of this epic. diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 1ee0c28..e0949d9 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -52,7 +52,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.6.0` freezes the public API. Field names, status codes, the objective +Version `1.7.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. @@ -61,7 +61,7 @@ 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 +The algorithm complexities Packvium states, in these docs and in the source, 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 df8fd8c..a784f42 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -15,8 +15,7 @@ false` dominates. The engines expose the same two fields on their item type: Python `Item.stacking_group` / `Item.accepts_stacking_groups`, PHP trailing constructor parameters `$stackingGroup` / `$acceptsStackingGroups`, Rust `Item::stacking_group` / - `Item::accepts_stacking_groups`, and the TypeScript request type. See - STACKING-GROUPS.md. + `Item::accepts_stacking_groups`, and the TypeScript request type. - **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 @@ -26,11 +25,12 @@ 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. + both**. On the shared fixtures Python and PHP are held to byte-identical results, + Rust and JavaScript to valid results no worse than the native quality budget. The + JavaScript fallback runs its own, smaller portfolio (eight item orderings, one under `fast`, + and four solvers under `quality`), so on a scene whose answer depends on item ordering it can + pack validly but less densely; any such difference 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 @@ -42,7 +42,6 @@ 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 @@ -75,7 +74,7 @@ parameter `$deformableBoundary` with `PackedContainer::achievedDimensions()` / `billedDimensions()`, Rust `Container::deformable_boundary: Option` with `PackedContainer::achieved_dimensions()` / `billed_dimensions()`, and the TypeScript request - type. See DEFORMABLE-CONTAINERS.md. + type. - **Made-to-measure containers.** `made_to_measure` (absent or `null`: a stock container) makes the container an on-demand box machine whose largest box is `inner_dimensions`: `minimum` and `increment` per axis (required; `minimum` equal to @@ -93,8 +92,7 @@ `invalid_value`. The engines expose it as Python `Container.made_to_measure` (a `packvium.MadeToMeasure`, last field), PHP the trailing constructor parameter `?MadeToMeasure $madeToMeasure` (`Packvium\Domain\MadeToMeasure`), Rust - `Container::made_to_measure: Option`, and the TypeScript request type. See - MADE-TO-MEASURE-CONTAINERS.md. + `Container::made_to_measure: Option`, and the TypeScript request type. - `PackingConfig`: solver profile, deadline, alternatives, deterministic seed, clearance, support threshold (`minimum_support_ratio`, default 0: support is not required, and an item may rest on nothing above the floor; set it above 0 to require that share of the @@ -104,7 +102,7 @@ that bounds all four stable implementations by counted work instead of wall-clock time. In code it is Python `packvium.EffortBudget`, PHP `Packvium\Config\EffortBudget` and Rust `packvium_core::EffortBudget`. `solvers` is an ordered list: every named strategy is run, stable ties prefer - the earlier name, and an unknown name is a structured error. The optional + the earlier name, and an unknown name is a structured error. The stable solver names are `grid`, `extreme_points`, `homogeneous_blocks`, `layer`, `maximal_spaces` and `exact_small`. `homogeneous_blocks` is the unrestricted solid-lattice quality strategy and safely delegates to `extreme_points` when a scene @@ -114,8 +112,7 @@ example, for multiple declared item types or a custom hard constraint); it does not silently return an empty lattice result. The optional `shipping_cost` objective accepts exact dimensional-weight divisor/unit inputs and ranks billable weight from the shipped container's outer dimensions. These fields - have identical semantics in all four implementations — see - ALGORITHMS-AND-COMPLEXITY.md. + have identical semantics in all four implementations. `require_placement_coordinates` (Python/PHP/Rust/JavaScript, default `true`) lets a caller trade per-item coordinates for speed and memory on a bulk single-item-type order: when `false` and `GridSolver`'s regular-lattice preconditions already hold (see the @@ -138,18 +135,18 @@ search with `invalid_fixed_placement`. Python `Packer.pack(items, containers, fixed_placements)` and PHP `Packer::pack($items, $containers, $fixedPlacements)` take `FixedPlacement` values; Rust takes them in `PackingRequest.fixed_placements`, and every - JSON entry point reads the field. PLAN-REVISIONS.md is the contract; - all four engines implement it. + JSON entry point reads the field; all four engines implement it. - `catalog_versions_used`: optional immutable catalog references resolved before packing. Each reference has exactly `catalog_id`, positive `version`, non-negative `published_at_epoch_ms`, and `content_digest`; catalog IDs are unique. Production packing uses a monotonic system clock. Test harnesses and deterministic simulations may inject a monotonic clock into Python `Packer(..., clock=...)`, PHP -`Packer(..., clock: ...)`, Rust `Deadline::with_clock(...)` with -`SolverRegistry::solve_with_deadline(...)`, or the JavaScript fallback's optional clock -argument. The callable returns an implementation-native monotonic value; it must never -move backwards. This is a testing seam, not a serialized request field. +`Packer(..., clock: ...)`, or Rust `Deadline::with_clock(...)` with +`SolverRegistry::solve_with_deadline(...)`. `@packvium/engine` exposes no clock: its +`pack(request)` takes the request alone. The callable returns an implementation-native +monotonic value; it must never move backwards. This is a testing seam, not a serialized +request field. ## Core output @@ -161,8 +158,7 @@ move backwards. This is a testing seam, not a serialized request field. alongside it. The pre-1.1.0 extension point remains open: closing the object would make a previously valid producer fail a 1.1.0 validator, which is a breaking change and therefore not legal in this minor release. No engine emits the reserved fields - yet, so `code` is still the only key any Packvium result carries. See - OPTIMALITY-CERTIFICATES.md; + yet, so `code` is still the only key any Packvium result carries; - legacy `status`: `optimal`, `feasible`, `best_found`, `time_limit`, `infeasible`, or `invalid_result`; - packed containers with exact coordinates, dimensions, rotations, and a `centre_of_mass_offset_ppm` — the weighted centre of mass's exact-integer Chebyshev @@ -174,7 +170,7 @@ move backwards. This is a testing seam, not a serialized request field. share the positive `denominator`; signed numerators intentionally expose a centre of mass outside the axle span instead of clipping it; - unpacked instances with reason codes, proof levels and rejection observations; -- objective score — a five-key lexicographic vector of exact integers, identical across every implementation (OBJECTIVE.md) — and solver statistics; +- objective score — a five-key lexicographic vector of exact integers, lower is better, computed by the same formula in every implementation — and solver statistics. Under the default objective the keys are `unpacked_count`, `container_count`, `total_cost_minor`, `unused_volume_ppm` (per container `floor((V - used) x 10^6 / V)` of inner volume `V`) and `stack_height_ppm` (per container `floor(top x 10^6 / H)` of inner height `H`); `configuration.objective` selects one of seven orderings (`default`, `lowest_cost`, `shipping_cost`, `lowest_landed_cost`, `open_dimension_height`, `maximum_value`, `maximum_volume`), and the result echoes it in `objective`; - the exact `catalog_versions_used` lineage from the request (an empty array when no catalog was involved), preserved by Python, PHP, Rust and JavaScript; - warnings and top-K alternatives. With `minimum_support_ratio` at its default 0, a result @@ -285,8 +281,7 @@ own `code` property): {"code": "invalid_direction", "direction": "sideways"} ``` -`conformance/scene/sequence-fixtures.json` pins this shape across all four languages; -see `docs/SERIALIZATION.md`. +A shared fixture pins this shape across all four languages. Sequence diagnostics use a separate canonical `SequenceWarning` DTO instead of putting caller-facing prose into an untyped string. Its four fields are stable in @@ -409,6 +404,12 @@ without turning a minor release into a parsing break. `status` remains for one compatibility cycle and is checked against the three facts. The alpha release emits no `proven_optimal` claim without a future proof/certificate. +`status: infeasible`, with `feasibility: infeasible` and `optimality: proven_infeasible`, is +emitted only when the portfolio ranks no result at all. A request whose every item is proven +unpackable is not that case: every engine answers `status: best_found`, `feasibility: +unknown` and `optimality: best_found`, with each item and its `proven` reason in +`unpacked_items`. Read `unpacked_items`, not `status`, to learn that nothing fits. + ## Portfolio termination `termination` describes the whole solver portfolio instead of copying one ambiguous @@ -457,8 +458,9 @@ The aggregate fields are derived only from `starts`. Exactly one record is selec clock, and `effort_limit` when the selected start stopped at a counted-work boundary. A truncated loser alone leaves the selected answer's code `complete`, while still making `all_required_starts_completed` false. The two algorithm limit flags preserve -the exact cause. -`winning_start_truncated || global_deadline_reached`. +the exact cause: one of `algorithm.time_limit_reached` and `algorithm.effort_limit_reached` +is true exactly when `winning_start_truncated || global_deadline_reached`, and +`global_deadline_reached` requires `time_limit_reached`. ## Rejection proof @@ -489,7 +491,7 @@ The four levels have deliberately different meanings: | Level | Meaning | | --- | --- | | `proven` | A request-level bound proves rejection without relying on search completeness. Currently `no_compatible_container_dimensions`, `payload_exceeded`, `rotation_restricted` and `no_eligible_container` — each a pure geometry or tag check across every container, none dependent on how far the search got. | -| `observed` | The engine exhausted or rejected the candidates it actually examined; this is not a global impossibility proof. | +| `observed` | The engine exhausted or rejected the candidates it actually examined; this is not a global impossibility proof. Currently `no_feasible_placement`, `search_exhausted`, `exact_search_incomplete` and `insufficient_support` — the last when replaying the finished layout finds a place the item fits once the support rules are set aside, and none with them, in every engine. | | `inferred` | The reason follows from solver state or a compound rule but has no complete certificate. | | `unknown_due_to_limit` | A deadline stopped the search, so no negative conclusion is claimed. | @@ -510,6 +512,28 @@ load can only refuse an item a fresh container would take, so they all end there relieve the front one -- and both rank after the structural proofs, the deadline, the effort budget and `group_cannot_fit_together`. +## Result validation + +Every package can check a result against the request it answers with the same reference +validator, and gets the same issue list back: geometry, support and load, accounting, the +result's facts and its score, each issue a `code` and a `detail`, an empty list meaning +valid. A result without the documented shape is refused as `MalformedResult`, whose `kind` is +`contract` or `shape`. + +- Python: `packvium.validate(request, result)`, `packvium.MalformedResult`. +- Rust: `packvium_core::validate_json(request, result)` over JSON text, returning + `Vec` or `MalformedResult { kind, message }`; `validate_json_outcome` returns the + same as one JSON document for a binding. +- PyO3 (`packvium_native_rust`): `validate_json(request, result)` over JSON text, a list of + `{"code", "detail"}` dicts, `MalformedResult` (a `ValueError`) with `kind`. +- `@packvium/engine`: `validate(request, result)`, `MalformedResult`, and + `ValidatorUnavailableError` (`code: 'validator_unavailable'`) when the compiled addon is + absent, until the JavaScript fallback's port. +- `@packvium/browser`: `validate(request, result)`, a promise, `MalformedResult`. + +- PHP: `Packvium\Validation\validate(array $request, array $result)`, a `list` + (`code`, `detail`), `MalformedResult` with `kind`. + ## Commercial and control-plane API Three deterministic functions over one canonical JSON document -- a carrier quote, an @@ -546,8 +570,7 @@ request returns a result with a status rather than raising. A document derived from an already validated packing result: what to do first, why this carton, and what was not packed. It is a view, not a decision — it calls no solver and no -validator, and a test in each language asserts that. The contract is -EXECUTION-PLAN.md. +validator, and a test in each language asserts that. | Language | Entry point | | --- | --- | @@ -609,8 +632,7 @@ One portable document built from a validated result. It carries: of the solver record, and an honest replay level. It is exported as JSON, RFC 4180 CSV, or a self-contained printable HTML work order. Like the -plan it is a view, not a decision: no solver, validator, renderer or clock is called. The -contract is OPERATIONAL-ARTIFACTS.md. +plan it is a view, not a decision: no solver, validator, renderer or clock is called. | Language | Builder | Exports | | --- | --- | --- | @@ -645,8 +667,7 @@ An append-only, hash-chained record of what differed from an approved plan: a mi a substituted carton, an operator lock or a verified placement. Each revision derives the request the next plan solves; a replan is an ordinary solve of it, with locks and verified placements carried as `fixed_placements`. A revision is a pure function of its parent, the -approved operational artifact and its events, and calls no solver or clock. The contract is -PLAN-REVISIONS.md. +approved operational artifact and its events, and calls no solver or clock. | Language | Surface | | --- | --- | @@ -663,9 +684,8 @@ stopping at the first. Refusals use `invalid_revision`, `invalid_event`, `event_ ## Scenario and recommendation API Scenario what-if comparison and catalog/rule recommendation publication, exported as -`packvium.simulation` and `packvium.recommendations`. The full contract is -INTELLIGENCE-API.md; what belongs here is the surface and its two -limits, both of which are part of the API rather than notes about it. +`packvium.simulation` and `packvium.recommendations`. This section is the surface and its +two limits, both of which are part of the API rather than notes about it. | Language | Entry point | | --- | --- | @@ -677,7 +697,7 @@ document is proved by driving four engines as subprocesses over JSON. `run_scena an `OrderEvaluator` *callback* as its central argument, and a callback does not cross a pipe -- so the harness that proves the packing and commerce contracts cannot express a scenario at all. Whether the data-in/data-out half ever crosses runtimes is an open -question, not an omission; INTELLIGENCE-API.md scopes it. +question, not an omission. **Historical replay ships with the ledger it reads.** `evaluate_on_history` scores a recommendation against decisions held out by an explicit `split_at`, and could not be @@ -700,10 +720,10 @@ importing the submodule, which puts them outside the frozen API snapshot the pac commerce surfaces are held to. They work, they are tested, and they will not be withdrawn inside 1.x — but a parameter may be renamed or a returned value gain a field in a minor release, announced in the changelog rather than blocked by a gate. Pin the version if you -depend on the exact shape. COMPATIBILITY.md states why this one surface -is held that way and what would change it. +depend on the exact shape. ## JSON API Python, PHP, Rust and the JavaScript fallback accept the same top-level keys: `units`, -`configuration`, `items`, `containers`, `catalog_versions_used`, and `output`. +`configuration`, `items`, `containers`, `catalog_versions_used`, `policy`, `output` and +`fixed_placements`. diff --git a/docs/UNITS-AND-NUMERICS.md b/docs/UNITS-AND-NUMERICS.md index 272c4ee..fd1e6e6 100644 --- a/docs/UNITS-AND-NUMERICS.md +++ b/docs/UNITS-AND-NUMERICS.md @@ -20,7 +20,7 @@ Conversion supports floor, ceiling and ties-to-even nearest rounding. Applicatio ## Geometry versus scores -Feasibility checks are integer-only, including the support ratio: the requested fraction is converted once to parts per million and compared as `supported_area >= floor(base_area x ratio_ppm / 10^6)`, never as a float division against an epsilon. The independent validator rechecks boundaries and intersections using exact integers. +Feasibility checks are integer-only, including the support ratio: the requested fraction is converted once to parts per million, `ratio_ppm = floor(ratio x 10^6 + 0.5)` on the IEEE double the request carries (each step correctly rounded, so every language gets the same integer; on about one in a hundred seven-decimal halves it differs from rounding the exact decimal, `0.0001245` giving 124), and compared as `supported_area >= floor(base_area x ratio_ppm / 10^6)`, never as a float division against an epsilon. Every result validator applies this same rule rather than the exact rational. `void_fill_reserve_ratio` goes through the same conversion and reserves `floor(inner_volume x ratio_ppm / 10^6)` cubic ticks, which the validators recompute the same way. The independent validator rechecks boundaries and intersections using exact integers. The Rust solver and validator were the exception until 2026-09-02: both compared `area / base_area + 1e-12 < ratio` in `f64`. On a real-sized base (a square centimetre is `2.56 x 10^10` square ticks) an area one square tick short of the requirement sits well inside that epsilon, so Rust admitted a placement the shared validator refused. Both now go through `support_area_sufficient`, the rule above; the `f64` ratio survives only as the reported `support_ratio` on the placement record. @@ -33,7 +33,7 @@ Coordinates remain 64-bit integers. Values that exceed that range are handled wi | Quantity | Why it overflows | How it is handled | | --- | --- | --- | | Volume, for output and ordering | A one-metre cube is `4.096 x 10^21` cubic ticks | `BigInt::multiply`/`add`, compared as decimal integers; this includes exact-small's equal-count physical-volume tie-break | -| Objective keys 3 and 4 | Ratio of two volumes | `BigInt::subtract` / `BigInt::divide`, see OBJECTIVE.md | +| Objective keys 3 and 4 | `unused_volume_ppm`, per container `floor((V - used) x 10^6 / V)`, scales a volume by 10^6; `stack_height_ppm` is `floor(top x 10^6 / H)` | `BigInt::subtract` / `BigInt::divide` | | Load shares, `weight x area / total_area` | Eighth-microgram counts times squared ticks | `Arithmetic::mulDiv`, exact and overflow-free | | Serialized decimals | Ticks beyond `2^53` do not survive a `double` | long division on digits, rounded ties-to-even | @@ -46,7 +46,7 @@ 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 +The engines compute lower bounds on the objective during search. 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. diff --git a/fallback.js b/fallback.js index 9d335c1..d69fcb0 100644 --- a/fallback.js +++ b/fallback.js @@ -11,6 +11,14 @@ const LEN={mm:16000,millimeter:16000,millimeters:16000,cm:160000,m:16000000,in:4 const WT={g:8000000,kg:8000000000,mg:8000,lb:3628738960,lbs:3628738960,oz:226796185,tick:1,ticks:1}; const ROT={LWH:[0,1,2],LHW:[0,2,1],WLH:[1,0,2],WHL:[1,2,0],HLW:[2,0,1],HWL:[2,1,0]}; const SUPPORT_SCALE=1000000; +/** A ratio in parts per million by the published rule, `floor(ratio * 1000000 + 0.5)`, Python's + * `scaled_ratio`. */ +const scaledRatio=(ratio)=>Math.floor(ratio*SUPPORT_SCALE+0.5); +/** A support ratio in parts per million, the one conversion the global, per-item, rebalance and + * validator paths share (S11). A positive ratio below half a ppm still demands contact: Python + * refuses an unsupported item there through its stability check, which this engine reads as + * the smallest ratio it can state. */ +const supportPpm=(ratio)=>ratio>0?Math.max(1,scaledRatio(ratio)):0; const MIN_CANDIDATE_POINTS=16; const hasOwn=(value,key)=>Object.prototype.hasOwnProperty.call(value,key); const isObject=value=>value!==null&&typeof value==='object'&&!Array.isArray(value); @@ -179,7 +187,7 @@ function requireFixedEntryShape(entry,where,field,u){ if(missing.length)throw malformed(`${where} needs ${jsonSpelling(missing)}`,field); for(const name of ['item_type','container_type']) if(typeof entry[name]!=='string'||!entry[name])throw malformed(`${where}.${name} is a non-empty string`,`${field}/${name}`); - if(!Object.keys(ROT).includes(entry.orientation))throw malformed(`${where}.orientation is one of the six codes`,`${field}/orientation`); + if(!Object.keys(ROT).includes(entry.orientation))throw malformed(`${where}.orientation must be one of ${jsonSpelling(Object.keys(ROT))}`,`${field}/orientation`); const instance=fixedInstance(entry); if(instance===null||instance<1)throw malformed(`${where}.container_instance counts from 1`,`${field}/container_instance`); const position=hasOwn(entry,'position')?entry.position:{}; @@ -242,8 +250,8 @@ function admitFixedPlacements(req,{items,templates,u,clear}){ requireContiguousInstances(groups,req.configuration?.max_containers??null); // Support is measured against every other fixed item in the container, not only the ones // listed before it: the set is one arrangement, and listing order is not physics. - for(const group of groups)for(const placement of group.placements)placement.fixedSupport=placement.z===0?1 - :directSupporters(placement,group.placements).reduce((total,support)=>total+support.area,0)/(placement.ed[0]*placement.ed[1]); + for(const group of groups)for(const placement of group.placements)placement.fixedSupport=placement.z===0?[1,1] + :[directSupporters(placement,group.placements).reduce((total,support)=>total+support.area,0),placement.ed[0]*placement.ed[1]]; const issue=fixedSetIssue(groups,req,clear); if(issue!==null)throw new FixedPlacementError(issue); return groups @@ -267,7 +275,7 @@ const byTemplate=(groups,id)=>groups.find(group=>group.tmpl.id===id).tmpl; * (docs/VALIDATION-CONTRACT.md). */ function fixedSetIssue(groups,req,clear){ - const globalRatio=req.configuration?.minimum_support_ratio??0,globalPpm=Math.round(globalRatio*SUPPORT_SCALE),located=new Map(); + const globalRatio=req.configuration?.minimum_support_ratio??0,globalPpm=supportPpm(globalRatio),located=new Map(); for(const group of groups){const {tmpl,placements}=group,cid=`${tmpl.id}#${group.seq}`,boxOf=p=>({x:p.x,y:p.y,z:p.z,d:p.ed}); for(let i=0;itotal+support.area,0),base=p.ed[0]*p.ed[1]; - return aread?q+1n:q%2n===0n?q:q+1n;return sg*z} -function scalar(v,def,table){let raw=v,unit=def;if(v&&typeof v==='object'){raw=v.value;unit=v.unit??def}else if(typeof v==='string'){const m=v.trim().match(/^(.+?)\s*(millimeters|millimeter|inches|ticks|inch|lbs|tick|mm|cm|ft|in|mg|kg|oz|lb|g|m)$/i);if(m){raw=m[1];unit=m[2]}}const[n,d]=rat(raw),ticks=Number(round(n*BigInt(table[unit.toLowerCase()]),d));if(ticks<0)throw new RangeError(`${table===WT?'weight':'length'} cannot be negative`);return ticks} +function scalar(v,def,table){let raw=v,unit=def;if(v&&typeof v==='object'){raw=v.value;unit=v.unit??def}else if(typeof v==='string'){const m=v.trim().match(/^(.+?)\s*(millimeters|millimeter|inches|ticks|inch|lbs|tick|mm|cm|ft|in|mg|kg|oz|lb|g|m)$/i);if(m){raw=m[1];unit=m[2]}}const[n,d]=rat(raw),ticks=Number(round(n*BigInt(table[unit.trim().toLowerCase()]),d));if(ticks<0)throw new RangeError(`${table===WT?'weight':'length'} cannot be negative`);return ticks} +// With no `output.length_unit`, the request's own `units.length` spelt as the schema's +// output code ("Inches" is `in`); a unit no output code names, the internal tick, gives `mm`. +function outputLengthUnit(unit){const multiplier=LEN[unit.trim().toLowerCase()];return ['mm','cm','m','in','ft'].find(code=>LEN[code]===multiplier)??'mm'} function dims(v,u){return [scalar(v.length,u,LEN),scalar(v.width,u,LEN),scalar(v.height,u,LEN)]} // Three-element literals, not `Array.prototype.map`: an inlined `map` in optimised code builds // a holey array where the baseline tiers build a packed one, so every consumer of a box's @@ -810,7 +821,7 @@ function effectiveHeight(heightTicks,ratioPpm,limitKpa,pressure){ * 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); + return scaledRatio(ratio); } 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; @@ -1046,8 +1057,7 @@ function centreOfMassOffsetPpm(container,placements,clearance=0){let total=0n,wx const x=(wx-total*length)<0n?total*length-wx:wx-total*length,y=(wy-total*width)<0n?total*width-wy:wy-total*width; return Number((x*1000000n/(total*length))>(y*1000000n/(total*width))?x*1000000n/(total*length):y*1000000n/(total*width))} function axleReactions(container,placements,extra=null){if(container.axleSpec==null)return null; - const [front,rear]=container.axleSpec;let total=BigInt(container.tare),weighted=BigInt(container.tare)*BigInt(container.d[0]); - for(const p of placements){const w=BigInt(p.item.w);total+=w;weighted+=w*BigInt(2*p.x+p.ed[0])} + const [front,rear]=container.axleSpec;let {total,moment:weighted}=axleLoad(container,placements); if(extra){const w=BigInt(extra.item.w);total+=w;weighted+=w*BigInt(2*extra.x+extra.ed[0])} const denominator=2n*BigInt(rear.position-front.position); return {denominator,front:2n*total*BigInt(rear.position)-weighted,rear:weighted-2n*total*BigInt(front.position)}} @@ -1064,14 +1074,44 @@ function axleAdmitsAlone(container,weight,envelopeLength){ const last=container.d[0]-envelopeLength;if(last<0)return false; if(container.axleSpec==null)return true; if(weight<=0)return !axleOverloaded(container,[]); - const [front,rear]=container.axleSpec,w=BigInt(weight),l=BigInt(envelopeLength),tare=BigInt(container.tare); - const total=tare+w,denominator=2n*BigInt(rear.position-front.position),tareMoment=tare*BigInt(container.d[0]); - let lowest=0n,highest=BigInt(last); - if(front.max!=null){const required=2n*total*BigInt(rear.position)-BigInt(front.max)*denominator-tareMoment; - const origin=-floorDivBig(-(-floorDivBig(-required,w)-l),2n);if(origin>lowest)lowest=origin} - if(rear.max!=null){const allowed=BigInt(rear.max)*denominator+2n*total*BigInt(front.position)-tareMoment; - const origin=floorDivBig(floorDivBig(allowed,w)-l,2n);if(origin0n?front:0n,highest=rear!=null&&rearpoint[2]===0).map(point=>point[1]))].sort((a,b)=>a-b); + if(!floorYs.length)floorYs.push(0); + const load=axleLoad(container,placements),out=[]; + for(const r of item.rots){const length=rotate(item.d,r)[0]+2*clearance; + const {front,rear}=axleBalancedOrigins(container,load,item.w,length),last=BigInt(container.d[0]-length); + for(const origin of [front,rear])if(origin!=null&&origin>=0n&&origin<=last)for(const y of floorYs)out.push([Number(origin),y,0])} + return out} function fitsTemplateAlone(c,d){return d.every((edge,k)=>edge<=c.d[k])&&(!c.bag||(d[0]+d[2]<=c.d[0]&&d[1]+d[2]<=c.d[1]))} // The container limit that left an item out, for the search_exhausted // fallback, or null when none explains it. The same rule, in the same order, as every @@ -1157,6 +1197,23 @@ function decimalString(v,divisor,places=8){ const text=digits===''?whole.toString():`${whole}.${digits}`; return text==='0'?'0':(negative?'-':'')+text } +// S13: a ratio written with six places, rounded half to even from the exact fraction, as Python's +// and PHP's formatting does -- `toFixed` rounds an exact half up (1/128 -> 0.007813, not 0.007812). +// Both operands are non-negative integers (Number or BigInt) and the denominator is positive. O(1). +function sixPlaces(numerator,denominator){ + const scaled=BigInt(numerator)*1000000n,divisor=BigInt(denominator); + let quotient=scaled/divisor;const twice=(scaled%divisor)*2n; + if(twice>divisor||(twice===divisor&"ient%2n===1n))quotient+=1n; + return `${quotient/1000000n}.${(quotient%1000000n).toString().padStart(6,'0')}`; +} +// The exact value of a finite non-negative double as [numerator, denominator], so a ratio the +// caller sent as a float formats as Python's `f"{ratio:.6f}"` does: on the double's own value. +function doubleFraction(value){ + const view=new DataView(new ArrayBuffer(8));view.setFloat64(0,value); + const bits=view.getBigUint64(0),exponent=Number((bits>>52n)&0x7ffn),fraction=bits&0xfffffffffffffn; + const mantissa=exponent===0?fraction:fraction|(1n<<52n),shift=(exponent===0?1:exponent)-1075; + return shift>=0?[mantissa<start.selected);if(selected.length!==1)throw new Error('termination aggregation requires exactly one selected start');const anyStartTruncated=starts.some(start=>start.truncated),allRequiredStartsCompleted=starts.every(start=>start.completed),winningStartTruncated=selected[0].truncated,globalDeadlineReached=starts.some(start=>start.global_deadline_reached);return {code:error?'error':winningStartTruncated||globalDeadlineReached?'time_limit':'complete',any_start_truncated:anyStartTruncated,all_required_starts_completed:allRequiredStartsCompleted,winning_start_truncated:winningStartTruncated,global_deadline_reached:globalDeadlineReached,starts}} -export class Deadline{constructor(limitMs,clock=Date.now){this.clock=clock;this.started=clock();this.limitMs=Math.max(1,limitMs)}elapsedMs(){return this.clock()-this.started}remainingMs(){return this.limitMs-this.elapsedMs()}expired(){return this.remainingMs()<=0}} +// S15: a deadline measures elapsed time, so it reads a monotonic clock; a wall clock steps when +// the host's time is set. The clock stays injectable, so tests drive time by hand. +const monotonicNow=()=>performance.now(); +export class Deadline{constructor(limitMs,clock=monotonicNow){this.clock=clock;this.started=clock();this.limitMs=Math.max(1,limitMs)}elapsedMs(){return this.clock()-this.started}remainingMs(){return this.limitMs-this.elapsedMs()}expired(){return this.remainingMs()<=0}} // Exact floor(baseArea * ratio / 1) with the ratio held at parts per million, so support // is decided on integers rather than a float comparison against an epsilon. @@ -1264,22 +1324,35 @@ function constraintBox(placement){return {x:placement.x,y:placement.y,z:placemen maxCompressionKpa:placement.item.maxCompressionKpa,compressionPpm:placement.item.compressionPpm, stopIndex:placement.item.stopIndex}} +// Ties between equal scores fall to enumeration order unless a solver ranks them further. +const noTieBreak=()=>0; +// The extreme-point rank after the score, as Rust's `candidate_order_key`: the envelope's +// far corner z2, y2, x2, then its origin z, y, x and the rotation name. Compared field by +// field, never folded into one weighted number: on a long container x2 outweighs a +// sub-millimetre step in y2 under any fixed weights, and the sum leaves 2^53. +// A full tie between two cuboid rotations means the same box, which Rust sweeps once under +// the first allowed rotation; enumeration order is that choice, so only a hull, whose +// rotations Rust keeps apart, compares the names. +function compareFarCorner(a,b){const ae=a.ed,be=b.ed; + return (a.z+ae[2])-(b.z+be[2])||(a.y+ae[1])-(b.y+be[1])||(a.x+ae[0])-(b.x+be[0]) + ||a.z-b.z||a.y-b.y||a.x-b.x + ||(a.item.shapeType==='convex_hull'?(a.rb.r?1:0):0)} // Keep only the requested prefix: the root is the worst score, latest tie first. // Ordinals make heap eviction preserve the stable full-sort order exactly. -function compareRetained(a,b){return a.score-b.score||a.ordinal-b.ordinal} -function retainCandidate(heap,width,score,ordinal,candidate){ - if(heap.length===width&&score>=heap[0].score)return; +function compareRetained(a,b,tieBreak=noTieBreak){return a.score-b.score||tieBreak(a.candidate,b.candidate)||a.ordinal-b.ordinal} +function retainCandidate(heap,width,score,ordinal,candidate,tieBreak=noTieBreak){ + if(heap.length===width&&(score-heap[0].score||tieBreak(candidate,heap[0].candidate))>=0)return; const entry={score,ordinal,candidate}; if(heap.length0){const parent=Math.floor((at-1)/2);if(compareRetained(heap[parent],entry)>=0)break; + while(at>0){const parent=Math.floor((at-1)/2);if(compareRetained(heap[parent],entry,tieBreak)>=0)break; heap[at]=heap[parent];at=parent} heap[at]=entry;return; } let at=0; while(at*2+10)child++; - if(compareRetained(entry,heap[child])>=0)break; + if(child+10)child++; + if(compareRetained(entry,heap[child],tieBreak)>=0)break; heap[at]=heap[child];at=child} heap[at]=entry; } @@ -1583,17 +1656,31 @@ function allowed(candidate,placed,container,globalSupportPpm,metrics,loadBase=nu // `topPlane` is `placed` bucketed by top face, as a sweep holds it; a caller reading every // placement of one container builds it once, so the writer is O(p + supporters), not O(p^2). -function supportRatioOf(placement,placed,topPlane=null){ - if(placement.z===0)return 1; +/** The supported share of a placement's base as the exact [area, base area]; a floor placement is whole. */ +function supportFraction(placement,placed,topPlane=null){ + if(placement.z===0)return [1,1]; const dimensions=placementDimensions(placement),area=directSupporters(placement,placed,topPlane).reduce((total,support)=>total+support.area,0); - return area/(dimensions[0]*dimensions[1]); + return [area,dimensions[0]*dimensions[1]]; } +function supportRatioOf(placement,placed,topPlane=null){const [area,base]=supportFraction(placement,placed,topPlane);return area/base} // The leading `maximum_volume` key: the share of the requested nominal item volume left // unpacked, in parts per million. Rounded up so an incomplete answer never scores zero, // and a ratio rather than the raw volume because cubic ticks pass 2^53 for an ordinary // container -- which is also why both operands arrive as BigInt. function unpackedVolumePpm(unpackedVolume,requestedVolume){return requestedVolume>0n?Number((unpackedVolume*1000000n+requestedVolume-1n)/requestedVolume):0} +/** Two id lists in Python's tuple order: element by element in code points, a prefix first. + * Joining them with a separator is not that order, and lets two lists compare equal (S18). */ +function compareIdLists(left,right){ + for(let i=0;icompareCodePoints(left,right)); + return JSON.stringify([plan.remaining.map(i=>i.id),inventory]); +} function compareScore(left,right){for(let i=0;itotal+run.algorithm.candidates_evaluated,0), placements_attempted:runs.reduce((total,run)=>total+run.algorithm.placements_attempted,0)} } -function startRecordId(solverAlias,index){ +// What a start adds to its solver's name: nothing for the first ordering, its index for a +// later one, and the swapped position for a neighbour run in a start a repeat freed. +function startSuffix(index,swapAt=null){ + return (index===0?'':`:seeded_${index}`)+(swapAt===null?'':`+swap-${swapAt}`) +} +function startRecordId(solverAlias,index,swapAt=null){ const solver=solverAlias?`${solverAlias}:javascript_fallback`:'javascript_fallback'; - return index===0?solver:`${solver}:seeded_${index}` + return solver+startSuffix(index,swapAt) } -function unstartedRecord(solverAlias,index,globalDeadlineReached){return { - id:startRecordId(solverAlias,index),started:false,completed:false,truncated:false,selected:false, +function unstartedRecord(solverAlias,index,globalDeadlineReached,swapAt=null){return { + id:startRecordId(solverAlias,index,swapAt),started:false,completed:false,truncated:false,selected:false, global_deadline_reached:globalDeadlineReached, }} +/** The neighbour orders the starts a repeated item-type sequence freed run, as + * Rust's `distinct_start_orders`: one item swapped with the next where their types differ, + * the smallest change that gives a new type sequence, taken start by start and position by + * position, never one `seen` already holds. A neighbour keeps its order nearly sorted, so it + * costs about what the start it replaces did. Only positions whose two items share the lead + * keys are offered, so a neighbour, like a shuffle, never puts a low-priority item ahead of + * a high-priority one. At most `max(32, 16 * needed)` are examined; O(c * n) time and O(n) + * space per key for `c` examined. */ +function neighbourStarts(ownStarts,seen,freed,needed){ + const neighbours=[],limit=Math.max(32,16*needed);let examined=0; + for(const {index,sequence,swappable} of ownStarts)for(const at of swappable){ + if(neighbours.length>=freed||examined>=limit)return neighbours; + examined++; + const swapped=sequence.slice();[swapped[at],swapped[at+1]]=[swapped[at+1],swapped[at]]; + const key=swapped.join(','); + if(!seen.has(key)){seen.add(key);neighbours.push({index,swapAt:at})} + } + return neighbours +} // A portfolio's start records are its runs' own, in run order: only the winning run keeps // its selected start, and a deadline that passed during a later run is every run's. function portfolioStarts(runs,winnerIndex,globalDeadlineReached){ @@ -2030,6 +2141,10 @@ function portfolioStarts(runs,winnerIndex,globalDeadlineReached){ const BLOCK_START_NODE_LIMIT=100000; // Lattice nodes between two clock reads of the block enumeration. const BLOCK_CLOCK_PERIOD=64; +// Leading blocks the `quality` block lookahead completes in each round, and its +// lattice-node budget per mode as a multiple of `container_plan_node_limit`. +const BLOCK_LOOKAHEAD_WIDTH=4; +const BLOCK_LOOKAHEAD_NODE_FACTOR=64; // One (space, type, rotation) group of the block enumeration visits every (nx, ny) with // nx <= maximumNx and nx*ny <= capacity, ny <= maximumY, one node each, in nx-major order. // The three functions below replace that per-node walk without changing what it decides: @@ -2115,6 +2230,25 @@ function blockScanSpace(space,types,round){const tally=space.tally,volumeFirst=r const candidate={space,itemId:type.itemId,rotation:lattice.rotation,pd:lattice.pd,ed:lattice.ed,volume:lattice.volume,approximateUsed, nx:tally[offset+4],ny:tally[offset+5],nz:tally[offset+6],count,key:null}; if(best==null||blockLeads(candidate,best,volumeFirst))round.best=candidate}} +// One space's per-rotation blocks for a lookahead round, kept in `scan.leading`, best first +// and at most `scan.leaders` long. The tally already counted the space's nodes and found +// each type's leading rotation; a later rotation of the same type in the same space can +// still be among the leaders, so each group's own first largest count is ranked here. +function blockRankSpace(space,types,scan){const tally=space.tally,volumeFirst=scan.volumeFirst,leading=scan.leading; + for(let typeIndex=0;typeIndexspace.d[0]||ed[1]>space.d[1])continue; + const maximumNx=Math.min(Math.floor(space.d[0]/ed[0]),type.capacity),maximumY=Math.floor(space.d[1]/ed[1]),maximumZ=Math.floor(space.d[2]/ed[2]); + const upper=Math.min(type.capacity,maximumNx*maximumY*maximumZ);if(upper===0)continue; + const full=leading.length===scan.leaders; + if(full&&blockTrails(upper,upper*lattice.approximateVolume,leading[leading.length-1],volumeFirst))continue; + const group=blockGroupBest(maximumNx,maximumY,maximumZ,type.capacity,upper); + const candidate={space,itemId:type.itemId,rotation:lattice.rotation,pd:lattice.pd,ed:lattice.ed,volume:lattice.volume, + approximateUsed:group.count*lattice.approximateVolume,nx:group.nx,ny:group.ny,nz:group.nz,count:group.count,key:null}; + if(full&&!blockLeads(candidate,leading[leading.length-1],volumeFirst))continue; + let position=leading.length; + while(position>0&&blockLeads(candidate,leading[position-1],volumeFirst))position--; + leading.splice(position,0,candidate);if(leading.length>scan.leaders)leading.pop()}}} function containedByEarlier(spaces,index){const inner=spaces[index]; for(let other=0;otherscalar(value * defect is never dressed up as the caller's mistake. An error that already carries its own * `code` (unsupported feature, policy, fixed placement, direction) passes through unchanged. */ -export function packFallback(req,clock=Date.now,solverAlias=null,startIndex=null,sharedDeadline=null){ +export function packFallback(req,clock=monotonicNow,solverAlias=null,startIndex=null,sharedDeadline=null){ if(!isObject(req))throw new InvalidRequestError('wrong_type','','must be an object'); rejectUnsupported(req); checkRequest(req,MEASURES); @@ -2204,8 +2340,11 @@ function asRequestError(error){ // run. Every frame reads the request into its model before it searches; `phase` is shared by // all frames of one call and flips when the first search begins. `addedBlockStart` marks the // frame as the block start `fast` and `balanced` add to their own; such a frame answers -// null, before it searches, for a request the lattice cannot express in full. -function solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,phase,addedBlockStart=false){ +// null, before it searches, for a request the lattice cannot express in full. `startOrder` +// is a multi-start run's say over one start's ordering: with `seen`, the start answers null +// for an item-type sequence already in it and otherwise reports its own; with `swapAt`, the +// start runs its ordering with that item and the next swapped. +function solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,phase,addedBlockStart=false,startOrder=null){ // Admit the doors here, beside the other request-admission checks, and not // where they are canonicalised. The container template is built after the // uniform-lattice fast path has already returned, so validating there let a request @@ -2315,29 +2454,52 @@ if(solverAlias===null&&requestedSolvers.length){ // start now genuinely re-solves against its own deterministic ordering and the best // score wins, which is what `termination.starts` has always claimed to describe. if(startIndex===null&&multiStartOrders>1){ - const plannedStarts=Math.min(multiStartOrders,restartLimit),runs=[]; + const plannedStarts=Math.min(multiStartOrders,restartLimit),runs=[],labels=[],ownStarts=[],seen=new Set(); const stagedPlanSearch=profile==='quality'&&(req.configuration?.container_plan_beam_width??16)>1; - for(let index=0;index{ const left=effort==null?null:remainingEffort(effort,runs); - runs.push(solveFrame(withEffort(child,left),clock,solverAlias,index,deadline,phase)); + const run=solveFrame(withEffort(request,left),clock,solverAlias,index,deadline,phase,false,startOrder); + if(run!==null){runs.push(run);labels.push(label)} + return run!==null}; + // Instances of one item are interchangeable, so a start whose item-type sequence an earlier + // start already ran packs alike: it answers null before it searches and frees its start + //. The earlier start is the one that won the tie when the repeat still ran, so + // the profile's own starts answer exactly as before. + let reached=0; + for(;reachedindex({ + ...run.termination.starts[0],id:startRecordId(solverAlias,labels[index].index,labels[index].swapAt),selected:index===winnerIndex, + global_deadline_reached:run.termination.starts[0].global_deadline_reached||globalDeadlineReached, + })); + for(let index=reached;index0)winner.algorithm={...winner.algorithm,solver:`${winner.algorithm.solver}:seeded_${winnerIndex}`}; + const suffix=startSuffix(labels[winnerIndex].index,labels[winnerIndex].swapAt); + if(suffix)winner.algorithm={...winner.algorithm,solver:`${winner.algorithm.solver}${suffix}`}; return finalizeOutermost(winner); } -const u=req.units?.length??'mm',ou=req.output?.length_unit??u,ow=req.output?.weight_unit??'g',clear=scalar(req.configuration?.clearance??0,u,LEN); +const u=req.units?.length??'mm',ou=req.output?.length_unit??outputLengthUnit(u),ow=req.output?.weight_unit??'g',clear=scalar(req.configuration?.clearance??0,u,LEN); const objective=req.configuration?.objective??'default';if(!['default','lowest_cost','shipping_cost','lowest_landed_cost','open_dimension_height','maximum_value','maximum_volume'].includes(objective))throw new RangeError(`unknown objective ${JSON.stringify(objective)}`); // `maximum_volume` fills space before it counts pieces, so every comparison below that // leads with a piece count leads with placed volume instead when this is set. @@ -2362,7 +2524,7 @@ const dimensionalWeight=d=>Number(volume(d)*BigInt(WT[dimWeightUnit])/(BigInt(LE const billedDimensionalWeight=(tmpl,placements)=>!tmpl.bag?dimensionalWeight(tmpl.outerD) :placements.length?dimensionalWeight(bagExtent(placements)):0; const globalSupportRatio=req.configuration?.minimum_support_ratio??0; -const globalSupportPpm=globalSupportRatio>0?Math.max(1,Math.round(globalSupportRatio*SUPPORT_SCALE)):0; +const globalSupportPpm=supportPpm(globalSupportRatio); const maxCandidatePoints=req.configuration?.max_candidate_points??4096; const qualityProfile=profile==='quality'; const maxCandidatesPerItem=req.configuration?.max_candidates_per_item??(qualityProfile?16:1); @@ -2371,6 +2533,10 @@ const containerPlanNodeLimit=req.configuration?.container_plan_node_limit??(qual for(const [name,value] of Object.entries({max_candidates_per_item:maxCandidatesPerItem,container_plan_beam_width:containerPlanBeamWidth,container_plan_node_limit:containerPlanNodeLimit})) if(!Number.isSafeInteger(value)||value<1)throw new RangeError(`${name} must be a positive safe integer`); let timeLimitReached=false; +// Orientations tried by the candidate sweep, so the clock is read at every 64th one, as +// Python, PHP and Rust read it every 64th node: a counted limit is still checked at each +// one, and only where a wall-clock stop lands can move. +let candidateClockTick=0; const metrics={candidate_points_considered:0,orientations_considered:0,feasible_candidates:0,collision_checks:0,support_checks:0,space_partitions:0,search_nodes_expanded:0}; const candidateEffortExceeded=()=>effort!=null&&( (effort.max_candidates_evaluated!=null&&metrics.feasible_candidates>=effort.max_candidates_evaluated) @@ -2412,7 +2578,7 @@ const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scal const vol=volume(d),longest=Math.max(...d),base=BigInt(d[0])*BigInt(d[1]); for(let i=1;i<=(raw.quantity??1);i++)items.push({raw,d,w,rots,vol,longest,base,id:`${raw.id}#${i}`, stackable:raw.stackable!==false,maxTop:raw.max_top_load==null?null:scalar(raw.max_top_load,'g',WT), - supportPpm:Math.round((raw.minimum_support_ratio??0)*SUPPORT_SCALE),priority:raw.priority??0, + supportPpm:supportPpm(raw.minimum_support_ratio??0),priority:raw.priority??0, tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null, nesting,maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null, stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],value:raw.value??0, @@ -2469,12 +2635,22 @@ items.sort((a,b)=>{ return ascendingVolume(b,a)||(b.longest-a.longest)||(b.w-a.w)||compareId(a.id,b.id); }); // Start 0 is the ordering above, so a single-start request is byte-identical to what it -// produced before restarts existed; every later start re-solves a shuffle of it that keeps -// the lead keys, so a shuffle never puts a low-priority item ahead of a high-priority one. -if(startIndex!==null&&startIndex>0&&(!qualityProfile||startIndex>2))items.splice(0,items.length,...seededOrder(items,req.configuration?.seed??42,startIndex).sort(leadOrder)); +// produced before restarts existed; every later start re-solves a plain shuffle of it, as +// Python's, PHP's and Rust's seeded orders are. Priority, value and stop order lead every +// named ordering, but a shuffle is the portfolio's only way to try a low-priority item +// first, and a hard rule can need exactly that: a `must_be_on_floor` item behind a +// higher-priority one that took the only floor footprint costs a second container. +// Priority is a preference, so the score, not the ordering, decides between the starts. +if(startIndex!==null&&startIndex>0&&(!qualityProfile||startIndex>2))items.splice(0,items.length,...seededOrder(items,req.configuration?.seed??42,startIndex)); +if(startOrder?.swapAt!=null){const at=startOrder.swapAt;[items[at],items[at+1]]=[items[at+1],items[at]]} +// An item type is its request entry, so the sequence never depends on how ids collate. +if(startOrder?.seen!=null){const typeOf=new Map(req.items.map((raw,index)=>[raw,index])),sequence=items.map(item=>typeOf.get(item.raw)),key=sequence.join(','); + if(startOrder.seen.has(key))return null; + startOrder.seen.add(key);startOrder.sequence=sequence; + for(let at=0;at+1{const d=dims(c.inner_dimensions,u),axleSpec=c.axles==null?null:c.axles.map(a=>({position:scalar(a.position,u,LEN),max:a.max_load==null?null:scalar(a.max_load,'g',WT)})); if(axleSpec&&(axleSpec.length!==2||axleSpec[0].position>=axleSpec[1].position||axleSpec[0].position<0||axleSpec[1].position>d[0]))throw new RangeError('axles must be [front, rear] inside the container'); - const reservePpm=Math.round((c.void_fill_reserve_ratio??0)*SUPPORT_SCALE); + const reservePpm=scaledRatio(c.void_fill_reserve_ratio??0); if(c.tag_limits!=null&&(typeof c.tag_limits!=='object'||Array.isArray(c.tag_limits)||Object.values(c.tag_limits).some(limit=>!Number.isSafeInteger(limit)||limit<1)))throw new RangeError('tag_limits must map strings to positive safe integers'); const maxStackDensity=c.max_stack_density==null?null:scalar(c.max_stack_density,'g',WT);if(maxStackDensity!=null&&maxStackDensity<0)throw new RangeError('max_stack_density must be non-negative'); const outerD=c.outer_dimensions?dims(c.outer_dimensions,u):d; @@ -2522,12 +2698,18 @@ const maxContainers=req.configuration?.max_containers??Infinity; // Group members travel together: one container takes all of them or none. Batching them // here is what both the greedy pass and the exact search branch over. const batchesOf=groupBatches; -// Every feasible (point, rotation) for `item`, ordered by the solver's own candidate -// score, best first. The greedy path asks for one and takes it; the exact search asks for -// all of them and branches on each, which is the only difference between the two +// Every feasible (candidate point, rotation) for `item`, ordered by the solver's own +// candidate score, best first. The greedy path asks for one and takes it; the exact search +// asks for all of them and branches on each, which is the only difference between the two //. Ties keep insertion order, which is the sorted point order crossed with the // item's declared rotations -- deterministic without a tiebreak key. -const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ +// +// Kept as the one loop over `candidates` it was in 1.6.0, with eight plain parameters that +// every caller passes. The axle pass as a second iteration of an enclosing loop, and the +// support ratio as a default parameter, each cost about half of a 7% slower cold br7 `fast` +// process in the frontier's Node 22 image, where this sweep runs mostly before TurboFan has +// compiled it (docs/PERFORMANCE-PRACTICE.md, "The 1.7 candidate sweep on a cold process"). +const sweepCandidates=(tmpl,item,state,candidates,index,used,width,supportRatioPpm)=>{ if(item.eligibleTags.length&&!item.eligibleTags.some(tag=>(tmpl.tags??[]).includes(tag)))return []; if(item.tags.length&&item.tags.some(tag=>tmpl.tagLimits[tag]!=null&&state.placements.filter(p=>p.item.tags.includes(tag)).length>=tmpl.tagLimits[tag]))return []; // Every rule form is a statement about this item, this container and what the container @@ -2567,7 +2749,12 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ const accessBase=tmpl.doors.length?stopAccessibilityBase(item.stopIndex,state.placements,tmpl,tmpl.doors):null; const compressionSensitive=item.shapeType==='compressible'||anyCompressiblePlaced(state.placements); const found=[];let bestScore=0; - const candidates=points.length>maxCandidatePoints?points.slice(0,maxCandidatePoints):points; + // Fixed for the sweep: neither the doors nor any placed stop change inside it. + const routeSensitive=tmpl.doors.length>0&&(item.stopIndex!=null||state.placements.some(p=>p.item.stopIndex!=null)); + // JavaScript registers no candidate scorers, so the extreme-point score is Rust's empty + // scorer sum and the far-corner order decides alone. + const farCornerRanked=solverAlias!=='grid'&&solverAlias!=='layer'&&solverAlias!=='maximal_spaces'&&!routeSensitive; + const tieBreak=farCornerRanked?compareFarCorner:noTieBreak; // Everything that is a function of (item, rotation) alone -- the rotated envelope, the // volume it occupies, its hull -- is computed once per sweep here rather than once per // point, the class of waste docs/PERFORMANCE-PRACTICE.md ranks first. The volume gate is @@ -2594,7 +2781,7 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ // Read as scalars, not destructured, so a rigid container pays nothing for the rule. const bag=Boolean(tmpl.bag),bagExtents=bag?bagExtent(placements):null,bagX=bag?bagExtents[0]:0,bagY=bag?bagExtents[1]:0,bagZ=bag?bagExtents[2]:0; candidatePoints:for(let pointIndex=0;pointIndexinnerX||y2>innerY||z2>innerZ)continue; if(bag&&(Math.max(x2,bagX)+Math.max(z2,bagZ)>innerX||Math.max(y2,bagY)+Math.max(z2,bagZ)>innerY))continue; @@ -2637,7 +2824,7 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ if(collision)continue; if(candidate===null)candidate={x,y,z,pd,ed,r,item}; if(axleOverloaded(tmpl,placements,candidate))continue; - if(!allowed(candidate,placements,tmpl,globalSupportPpm,metrics,loadBase,accessBase,sweep))continue; + if(!allowed(candidate,placements,tmpl,supportRatioPpm,metrics,loadBase,accessBase,sweep))continue; // With zero load the candidate is at its largest, and appending it can only shrink // existing compressible supports. If that upper bound fits, the exact support-graph // refresh cannot reject it; only a candidate near the reserve boundary pays the @@ -2646,27 +2833,35 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ if(upperBound+tmpl.reserve>tmpl.innerVolume &&usedVolume([...placements,candidate])+tmpl.reserve>tmpl.innerVolume)continue} metrics.feasible_candidates++; - const routeSensitive=tmpl.doors.length>0&&(item.stopIndex!=null||state.placements.some(p=>p.item.stopIndex!=null)); - const score=solverAlias==='grid' + const score=farCornerRanked?0:solverAlias==='grid' ?z*1e12+y*1e6+x :solverAlias==='layer' ?z2*1e12+z*1e8+y*1e4+x :solverAlias==='maximal_spaces' ?x2+y2+z2*1e6 - :routeSensitive - ?x2*1e9+y2*1e4+z2 - :z2*1e9+y2*1e4+x2; - if(width===1){if(!found.length||scoreentry.candidate); - found.sort((a,b)=>a.score-b.score); + if(width!=null)return found.sort((a,b)=>compareRetained(a,b,tieBreak)).map(entry=>entry.candidate); + found.sort((a,b)=>a.score-b.score||tieBreak(a.candidate,b.candidate)); return found.map(entry=>entry.candidate) }; +// Two sweeps over the same checks: the contact points, then -- only when none of them is +// feasible and the search may go on -- the axle-balanced ones. Such a point floats the item +// away from every wall and box, so it is the last resort that keeps an item an axle limit +// would otherwise strand, never a rival to a contact point. A container without axles, and +// every item with a feasible contact point, sweeps once, exactly as before. The container +// test leads so that a sweep finding nothing reaches no code the optimised caller has not +// already run. +const candidatesFor=(tmpl,item,state,points,index,used,width,supportRatioPpm)=>{ + const found=sweepCandidates(tmpl,item,state,points.length>maxCandidatePoints?points.slice(0,maxCandidatePoints):points,index,used,width,supportRatioPpm); + if(tmpl.axleSpec==null||item.w<=0||found.length||timeLimitReached||candidateEffortExceeded())return found; + return sweepCandidates(tmpl,item,state,axleBalancedPoints(tmpl,state.placements,points,item,clear),index,used,width,supportRatioPpm)}; const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const seeded=seedState(tmpl),state=seeded.state;const next=[]; // Running `usedVolume` of `state.placements`, maintained incrementally rather than // recomputed per candidate. Kept local to this call, not on `state`, so it @@ -2683,7 +2878,7 @@ const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const seeded=seedState(tmpl),s if(deadline.expired()){timeLimitReached=true;ok=false;break} if(effortExceeded()){ok=false;break} metrics.search_nodes_expanded++; - const best=candidatesFor(tmpl,item,state,points,index,used,1)[0]; + const best=candidatesFor(tmpl,item,state,points,index,used,1,globalSupportPpm)[0]; if(!best){ok=false;break} state.payload+=item.w; const compressionSensitive=item.shapeType==='compressible'||placedCompressible; @@ -2702,12 +2897,27 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ const fresh=()=>({...seedState(tmpl),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; + // What a placement changes that ranking reads: the placements, payload and used volume. + const placeState=(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}); + node.state.placements.push(candidate)}; + // What it changes that only a later expansion reads: the spatial index and the points. + const placeGeometry=(node,candidate)=>{indexAdd(node.index,node.state.placements.length-1,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed}); retirePointsForPlacement(node.points,candidate);for(const point of pointsFrom(candidate))insertPoint(node.points,point)}; + const place=(node,candidate)=>{placeState(node,candidate);placeGeometry(node,candidate)}; + // A one-item child is ranked before it has points or a spatial index of its own: only the + // beam's survivors are expanded again, so only they copy their parent's. The + // parent is never queried or placed into after its children exist, so a late copy is the + // copy an early one would have made. + const lazyChild=(node,candidate)=>{const child={state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used, + points:null,index:null,unplaced:node.unplaced.slice(),parent:node,candidate}; + if(candidate!==null)placeState(child,candidate);return child}; + const materialize=node=>{if(node.points!==null)return node; + node.points=node.parent.points.slice();node.index=copyIndex(node.parent.index); + if(node.candidate!==null)placeGeometry(node,node.candidate); + node.parent=node.candidate=null;return node}; // Prefix sums of the ascending costs. Costs are non-negative, so the sums never decrease // and `maxCount` is one binary search: O(log f) per node instead of the O(f) walk. const cumulativeCosts=costs=>{costs.sort((a,b)=>ab?1:0);let used=0n;return costs.map(cost=>used+=cost)}; @@ -2727,7 +2937,19 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ const stateKeys=new WeakMap(); const stateKey=state=>{let key=stateKeys.get(state);if(key===undefined){ key={maxZ:state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0),signature:undefined};stateKeys.set(state,key)}return key}; - const signature=state=>{const key=stateKey(state);if(key.signature===undefined)key.signature=state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|');return key.signature}; + const segment=p=>`${p.item.id}@${p.x},${p.y},${p.z}`; + const signature=state=>{const key=stateKey(state);if(key.signature===undefined)key.signature=state.placements.map(segment).join('|');return key.signature}; + // The order of two states' signatures, without comparing the prefix they share. Beam + // siblings hold the same placement objects up to their parent's length, and the same + // object gives the same segment, so both signatures open with that prefix and a '|'; + // past it, only the tails decide. A state whose placements are all shared is a proper + // prefix of the other's signature, which sorts it first, as the whole strings would. + const compareSignatures=(a,b)=>{const left=a.placements,right=b.placements,shared=Math.min(left.length,right.length); + let common=0;while(common{ if(reachA!==reachB)return reachA>reachB?-1:1;if(a.used!==b.used)return a.used>b.used?-1:1; const difference=a.unplaced.length-b.unplaced.length;if(difference)return difference; const az=stateKey(a.state).maxZ,bz=stateKey(b.state).maxZ;if(az!==bz)return az-bz; - return compareCodePoints(signature(a.state),signature(b.state))}; + return compareSignatures(a.state,b.state)}; const compareNode=(a,b,bounds=null)=>{if(byVolume)return compareNodeByVolume(a,b,bounds); let difference=bounds===null?a.unplaced.length-b.unplaced.length:bounds.get(a)-bounds.get(b);if(difference)return difference; difference=a.unplaced.length-b.unplaced.length;if(difference)return difference; difference=b.state.placements.length-a.state.placements.length;if(difference)return difference; const az=stateKey(a.state).maxZ,bz=stateKey(b.state).maxZ; if(az!==bz)return az-bz;if(a.used!==b.used)return a.used>b.used?-1:1; - return compareCodePoints(signature(a.state),signature(b.state))}; + return compareSignatures(a.state,b.state)}; const greedy=tryPackIntoTemplate(tmpl,itemsRemaining);let beam=[fresh()],incumbent=fresh();incumbent.state=greedy.state;incumbent.used=greedy.used;incumbent.unplaced=greedy.next;let nodes=0; // The batch the loop stopped before, when it stopped early (node limit, deadline or // effort). Surviving beam nodes have consumed only the batches before it, so completing @@ -2756,10 +2978,10 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ const batch=batches[position],future=batches.slice(position+1).flat(),expansions=[];let exhausted=false; for(const node of beam){if(nodes>=containerPlanNodeLimit||deadline.expired()||effortExceeded()){exhausted=true;break}nodes++;metrics.search_nodes_expanded++; const children=[]; - if(batch.length===1){for(const candidate of candidatesFor(tmpl,batch[0],node.state,node.points,node.index,node.used,maxCandidatesPerItem)){ - const child=clone(node);place(child,candidate);children.push(child)}}else{ - const child=clone(node);let accepted=true;for(const item of batch){const candidate=candidatesFor(tmpl,item,child.state,child.points,child.index,child.used,1)[0];if(!candidate){accepted=false;break}place(child,candidate)}if(accepted)children.push(child)} - expansions.push(...children);const skipped=clone(node);skipped.unplaced.push(...batch);expansions.push(skipped)} + if(batch.length===1){for(const candidate of candidatesFor(tmpl,batch[0],node.state,node.points,node.index,node.used,maxCandidatesPerItem,globalSupportPpm)) + children.push(lazyChild(node,candidate))}else{ + const child=clone(node);let accepted=true;for(const item of batch){const candidate=candidatesFor(tmpl,item,child.state,child.points,child.index,child.used,1,globalSupportPpm)[0];if(!candidate){accepted=false;break}place(child,candidate)}if(accepted)children.push(child)} + expansions.push(...children);const skipped=lazyChild(node,null);skipped.unplaced.push(...batch);expansions.push(skipped)} // The incumbent is only ever compared and returned (state + unplaced); it never // re-enters the beam, so cloning the candidate points and spatial index for it // was pure allocation per expansion. @@ -2772,7 +2994,7 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ const futureWeights=tmpl.max!=null?cumulativeCosts(future.map(item=>BigInt(item.w))):null; const futureVolume=futureVolumes===null?null:futureVolumes.length?futureVolumes[futureVolumes.length-1]:0n; const bounds=new Map(expansions.map(node=>[node,byVolume?reachableVolume(node,future,futureVolume):lowerBound(node,future,futureVolumes,futureWeights)])); - expansions.sort((a,b)=>compareNode(a,b,bounds));beam=expansions.slice(0,containerPlanBeamWidth)} + expansions.sort((a,b)=>compareNode(a,b,bounds));beam=expansions.slice(0,containerPlanBeamWidth).map(materialize)} // Without the unreached tail a beam leader looks better than every complete incumbent, // and choosing it dropped those items outright: neither placed nor reported unpacked. const pending=pendingFrom===null?[]:batches.slice(pendingFrom).flat(); @@ -2806,11 +3028,11 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ // 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. const childrenOf=(w,batch)=>{ - if(batch.length===1)return candidatesFor(tmpl,batch[0],w.state,w.points,w.index,w.used,null) + if(batch.length===1)return candidatesFor(tmpl,batch[0],w.state,w.points,w.index,w.used,null,globalSupportPpm) .map(candidate=>{const child=cloneWork(w);placeInto(child,candidate);return child}); const child=cloneWork(w); for(const item of batch){ - const best=candidatesFor(tmpl,item,child.state,child.points,child.index,child.used,1)[0]; + const best=candidatesFor(tmpl,item,child.state,child.points,child.index,child.used,1,globalSupportPpm)[0]; if(!best)return []; placeInto(child,best)} return [child]}; @@ -2950,55 +3172,99 @@ const blockLattices=prototype=>{const seen=new Set(),lattices=[]; for(const rotation of prototype.rots){const pd=rotate(prototype.d,rotation),physicalKey=pd.join(':');if(seen.has(physicalKey))continue;seen.add(physicalKey); lattices.push({rotation,pd,ed:grown(pd,2*clear),volume:volume(pd),approximateVolume:pd[0]*pd[1]*pd[2]})} return lattices}; -const blockMode=(tmpl,itemsRemaining,volumeFirst)=>{let spaces=[blockSpace(0,0,0,tmpl.d.slice())],nodes=0,reached=false; - const byType=new Map();for(const item of itemsRemaining){const values=byType.get(item.raw.id)??[];values.push(item);byType.set(item.raw.id,values)} - const itemIds=[...byType.keys()].sort(),lattices=new Map(); - const state={tmpl,placements:[],payload:0}; - while(spaces.length&&[...byType.values()].some(values=>values.length)){ - // The counted limits cannot move during one round's enumeration: the candidate counters - // change only when a block is placed, and the search-node counter moves with `nodes`. - const stopAt=candidateEffortExceeded()?nodes:Math.min(blockNodeLimit, - effort?.max_search_nodes==null?Infinity:nodes+Math.max(0,effort.max_search_nodes-metrics.search_nodes_expanded)); - // A type's capacity and lattices do not depend on the space they are tried in. - const types=[]; - for(let slot=0;slot0)capacity=Math.min(capacity,Math.floor(Math.max(0,tmpl.max-state.payload)/prototype.w)); - if(capacity<=0)continue; - if(!lattices.has(itemId))lattices.set(itemId,blockLattices(prototype)); - const typeLattices=lattices.get(itemId);let minimumX=Infinity,minimumY=Infinity; - for(const lattice of typeLattices){minimumX=Math.min(minimumX,lattice.ed[0]);minimumY=Math.min(minimumY,lattice.ed[1])} - types.push({itemId,slot,capacity,lattices:typeLattices,minimumX,minimumY})} - // Indexed loops: this nest runs mostly before the JIT has warmed up, where for-of and - // destructuring pay for the iterator protocol. - const round={nodes,best:null,volumeFirst}; - for(let spaceIndex=0;spaceIndex=0?stop:round.nodes; - metrics.search_nodes_expanded+=end-nodes;nodes=end; - if(stop>=0){reached=true;break} - const best=round.best;if(best==null)break; - const available=byType.get(best.itemId),chosen=available.splice(0,best.count);let index=0; - for(let z=0;zbyType.get(key));return {state,next,reached}} +// One mode's state between rounds: what is placed, the free spaces, and what is left of +// each type. Spaces and lattices are shared between copies: a space's tally is a function +// of the space and a capacity alone, and a lattice of the type alone. +const blockStart=(tmpl,itemsRemaining)=>{const byType=new Map(); + for(const item of itemsRemaining){const values=byType.get(item.raw.id)??[];values.push(item);byType.set(item.raw.id,values)} + return {state:{tmpl,placements:[],payload:0},spaces:[blockSpace(0,0,0,tmpl.d.slice())],byType,itemIds:[...byType.keys()].sort(),lattices:new Map()}}; +const blockOpen=round=>round.spaces.length>0&&[...round.byType.values()].some(values=>values.length); +const blockCopy=round=>({state:{tmpl:round.state.tmpl,placements:round.state.placements.slice(),payload:round.state.payload}, + spaces:round.spaces,byType:new Map([...round.byType].map(([key,values])=>[key,values.slice()])),itemIds:round.itemIds,lattices:round.lattices}); +const blockFinish=(round,reached)=>({state:round.state,next:round.itemIds.flatMap(key=>round.byType.get(key)),reached}); +// One round's `leaders` leading blocks, best first, under `nodeLimit` and the effort budget, +// and whether a limit stopped the round (which then commits nothing). `counter.nodes` is the +// mode's node count so far. +const blockLeading=(round,volumeFirst,leaders,nodeLimit,counter)=>{const {state,spaces,byType,itemIds,lattices}=round,tmpl=state.tmpl,nodes=counter.nodes; + // The counted limits cannot move during one round's enumeration: the candidate counters + // change only when a block is placed, and the search-node counter moves with `nodes`. + const stopAt=candidateEffortExceeded()?nodes:Math.min(nodeLimit, + effort?.max_search_nodes==null?Infinity:nodes+Math.max(0,effort.max_search_nodes-metrics.search_nodes_expanded)); + // A type's capacity and lattices do not depend on the space they are tried in. + const types=[]; + for(let slot=0;slot0)capacity=Math.min(capacity,Math.floor(Math.max(0,tmpl.max-state.payload)/prototype.w)); + if(capacity<=0)continue; + if(!lattices.has(itemId))lattices.set(itemId,blockLattices(prototype)); + const typeLattices=lattices.get(itemId);let minimumX=Infinity,minimumY=Infinity; + for(const lattice of typeLattices){minimumX=Math.min(minimumX,lattice.ed[0]);minimumY=Math.min(minimumY,lattice.ed[1])} + types.push({itemId,slot,capacity,lattices:typeLattices,minimumX,minimumY})} + // Indexed loops: this nest runs mostly before the JIT has warmed up, where for-of and + // destructuring pay for the iterator protocol. + const scan={nodes,best:null,volumeFirst,leading:[],leaders}; + for(let spaceIndex=0;spaceIndex1)blockRankSpace(space,types,scan)} + // Every node is counted, and a counted limit stops on the exact node it always did; the + // clock is read at every 64th node index, the indices the per-node walk read it at. One + // check over the round's nodes stops where checks over its groups in turn would, and a + // stopped round places nothing, so its incumbent and the nodes past the stop never count. + const stop=blockStopNode(nodes,scan.nodes-nodes,stopAt,deadline),end=stop>=0?stop:scan.nodes; + metrics.search_nodes_expanded+=end-nodes;counter.nodes=end; + if(stop>=0)return {leading:[],reached:true}; + return {leading:leaders>1?scan.leading:scan.best==null?[]:[scan.best],reached:false}}; +// Place every member of `best`, bottom layer first, and carve it from the free spaces. +const blockCommit=(round,best)=>{const chosen=round.byType.get(best.itemId).splice(0,best.count),state=round.state;let index=0; + for(let z=0;z{let reached=false; + while(blockOpen(round)){const step=blockLeading(round,volumeFirst,1,nodeLimit,counter); + if(step.reached){reached=true;break} + if(!step.leading.length)break; + blockCommit(round,step.leading[0])} + return blockFinish(round,reached)}; +const blockMode=(tmpl,itemsRemaining,volumeFirst)=>blockComplete(blockStart(tmpl,itemsRemaining),volumeFirst,blockNodeLimit,{nodes:0}); const blockModeKey=candidate=>{const placements=candidate.state.placements,used=usedVolume(placements),top=placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0); return byVolume?[-used,candidate.next.length,top]:[candidate.next.length,-used,top]}; const blockModeSignature=candidate=>candidate.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|'); +// Negative when block answer `a` ranks before `b`: (lead, top), then the placement signature. +const blockAnswerOrder=(a,b)=>compareBlockKey(blockModeKey(a),blockModeKey(b))||compareCodePoints(blockModeSignature(a),blockModeSignature(b)); +// `greedy`, or a better answer from completing each round's leading blocks. Each +// round ranks its BLOCK_LOOKAHEAD_WIDTH leading blocks, completes each one greedily on a +// copy, and commits the block whose completion ranks best. The greedy answer is the first +// completion, so the result is never worse than it. The round's first block is the greedy +// choice after the block committed last, so its completion is the one already known -- +// unless the greedy pass was stopped by the node limit, when the first round completes it +// under the lookahead's larger budget. When that budget runs out the best completion so far +// stands; only the deadline or the effort budget marks it as stopped. +// O(K*B^2*S*T*R*X*Y) time, bounded by that budget, and O(K*(S+n)) space. +const blockLookahead=(tmpl,itemsRemaining,volumeFirst,greedy)=>{const round=blockStart(tmpl,itemsRemaining),counter={nodes:0},nodeLimit=blockNodeLimit*BLOCK_LOOKAHEAD_NODE_FACTOR; + let best=greedy,known=greedy.reached?null:greedy; + while(blockOpen(round)){const step=blockLeading(round,volumeFirst,BLOCK_LOOKAHEAD_WIDTH,nodeLimit,counter); + if(step.reached||!step.leading.length)break; + let chosen=known,chosenBlock=step.leading[0],reached=false; + for(let index=known==null?0:1;index{if(!homogeneousBlocksSupported()||tmpl.fixed)return tryPackIntoTemplate(tmpl,itemsRemaining); // Modes are ranked by (lead, top, placement signature). A single mode is never ranked, and // the signature, a string over every placement, is built only for two modes the rest ties. - let best=null,bestKey=null;for(const volumeFirst of blockModes){if(deadline.expired()){timeLimitReached=true;break}const candidate=blockMode(tmpl,itemsRemaining,volumeFirst);if(candidate.reached&&deadline.expired())timeLimitReached=true; + let best=null,bestKey=null;for(const volumeFirst of blockModes){if(deadline.expired()){timeLimitReached=true;break}let candidate=blockMode(tmpl,itemsRemaining,volumeFirst); + if(profile==='quality'&&!deadline.expired())candidate=blockLookahead(tmpl,itemsRemaining,volumeFirst,candidate); + if(candidate.reached&&deadline.expired())timeLimitReached=true; if(best==null){best=candidate;continue} if(bestKey==null)bestKey=blockModeKey(best); const key=blockModeKey(candidate),order=compareBlockKey(key,bestKey); @@ -3091,9 +3357,9 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ const child={packed:[...plan.packed,{...trial.state,seq:plan.seq+1}],remaining:trial.next,inventory:nextInventory,seq:plan.seq+1};expansions.push(child);if(compareScore(planScore(child),planScore(incumbent))<0)incumbent=child} if(exhausted)break} if(exhausted||!expansions.length)break; - const dominant=new Map();for(const plan of expansions){const signature=`${plan.remaining.map(i=>i.id).join('|')}::${[...plan.inventory.entries()].sort().map(([k,v])=>`${k}:${v}`).join('|')}`; + const dominant=new Map();for(const plan of expansions){const signature=planSignature(plan); const previous=dominant.get(signature);if(!previous||compareScore(planScore(plan,[]),planScore(previous,[]))<0)dominant.set(signature,plan)} - beam=[...dominant.values()].sort((a,b)=>compareScore(planBound(a),planBound(b))||compareCodePoints(a.remaining.map(i=>i.id).join('|'),b.remaining.map(i=>i.id).join('|'))).slice(0,containerPlanBeamWidth)} + beam=[...dominant.values()].sort((a,b)=>compareScore(planBound(a),planBound(b))||compareIdLists(a.remaining.map(i=>i.id),b.remaining.map(i=>i.id))).slice(0,containerPlanBeamWidth)} packed.splice(0,packed.length,...incumbent.packed);remaining.splice(0,remaining.length,...incumbent.remaining);seq=incumbent.seq; }else while(remaining.length&&packed.length1&&solverAlias!=='exact_small'){ for(const c of packed){const n=(counts.get(c.tmpl.id)??0)+1;counts.set(c.tmpl.id,n);c.seq=n}} const containers=packed.map(c=>{const loads=topLoads(c.placements.map(constraintBox)),used=usedVolume(c.placements),topPlane=bucketByPlane(c.placements,p=>p.z+placementDimensions(p)[2]); const reaction=axleReactions(c.tmpl,c.placements); - return {id:`${c.tmpl.id}#${c.seq}`,container_type:c.tmpl.id,inner_dimensions:outDims(c.tmpl.d,ou),outer_dimensions:outDims(c.tmpl.machine?c.tmpl.outerD:c.tmpl.outer_dimensions?dims(c.tmpl.outer_dimensions,u):c.tmpl.d,ou),...(c.tmpl.bag||c.tmpl.machine?{achieved_dimensions:outDims(bagExtent(c.placements),ou)}:{}),...(c.tmpl.machine?{made_to_measure:{cost_minor:c.tmpl.cost_minor,tare_weight:outWeight(c.tmpl.tare,ow)}}:{}),payload_weight:outWeight(c.payload,ow),gross_weight:outWeight(c.payload+c.tmpl.tare,ow),used_volume_ticks3:used.toString(),volume_utilization:(Number(used)/Number(volume(c.tmpl.d))).toFixed(6),centre_of_mass_offset_ppm:centreOfMassOffsetPpm(c.tmpl,c.placements,clear),...(reaction==null?{}:{axle_reactions:{basis:'gross',denominator:reaction.denominator.toString(),front_numerator:reaction.front.toString(),rear_numerator:reaction.rear.toString()}}),void_fill_reserve_ticks3:(volume(c.tmpl.d)*BigInt(c.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)).toString(),placements:c.placements.map((p,i)=>({item_id:p.item.id,item_type:p.item.raw.id,position:outPoint({x:p.x+clear,y:p.y+clear,z:p.z+clear},ou),dimensions:outDims(p.pd,ou),orientation:p.r,support_ratio:(p.fixed?p.fixedSupport:supportRatioOf(p,c.placements,topPlane)).toFixed(6),top_load:outWeight(loads[i],ow),...(p.fixed?{fixed:true}:{})}))}}); + return {id:`${c.tmpl.id}#${c.seq}`,container_type:c.tmpl.id,inner_dimensions:outDims(c.tmpl.d,ou),outer_dimensions:outDims(c.tmpl.machine?c.tmpl.outerD:c.tmpl.outer_dimensions?dims(c.tmpl.outer_dimensions,u):c.tmpl.d,ou),...(c.tmpl.bag||c.tmpl.machine?{achieved_dimensions:outDims(bagExtent(c.placements),ou)}:{}),...(c.tmpl.machine?{made_to_measure:{cost_minor:c.tmpl.cost_minor,tare_weight:outWeight(c.tmpl.tare,ow)}}:{}),payload_weight:outWeight(c.payload,ow),gross_weight:outWeight(c.payload+c.tmpl.tare,ow),used_volume_ticks3:used.toString(),volume_utilization:sixPlaces(used,volume(c.tmpl.d)),centre_of_mass_offset_ppm:centreOfMassOffsetPpm(c.tmpl,c.placements,clear),...(reaction==null?{}:{axle_reactions:{basis:'gross',denominator:reaction.denominator.toString(),front_numerator:reaction.front.toString(),rear_numerator:reaction.rear.toString()}}),void_fill_reserve_ticks3:(volume(c.tmpl.d)*BigInt(c.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)).toString(),placements:c.placements.map((p,i)=>({item_id:p.item.id,item_type:p.item.raw.id,position:outPoint({x:p.x+clear,y:p.y+clear,z:p.z+clear},ou),dimensions:outDims(p.pd,ou),orientation:p.r,support_ratio:sixPlaces(...(p.fixed?p.fixedSupport:supportFraction(p,c.placements,topPlane))),top_load:outWeight(loads[i],ow),...(p.fixed?{fixed:true}:{})}))}}); const fitsWithRotations=(i,rots)=>templates.some(c=>rots.some(r=>fitsTemplateAlone(c,rotate(i.d,r).map(x=>x+2*clear)))); const openedByType=new Map();for(const c of packed)openedByType.set(c.tmpl.id,(openedByType.get(c.tmpl.id)??0)+1); +// Python's and PHP's `support_is_the_blocker`. Each packed container's final scene is +// replayed once, and an item type is asked once whether it fits with the support rules -- the +// minimum ratio and the ground contact rule -- set aside but not with them: two one-candidate +// sweeps per scene. An observed fact about this layout, so its proof is `observed`. The sweeps +// share the search's counters and flags, which are put back so the replay leaves no trace. +const supportCanRefuse=i=>Math.max(globalSupportPpm,i.supportPpm)>0||(i.groundRule!=null&&i.groundRule!=='free'); +const supportVerdicts=new Map();let finishedScenes=null; +const supportIsTheBlocker=i=>{ + if(!supportCanRefuse(i))return false; + if(supportVerdicts.has(i.raw.id))return supportVerdicts.get(i.raw.id); + if(finishedScenes==null)finishedScenes=packed.map(c=>{const tmpl={...c.tmpl,fixed:c.placements};return {tmpl,...seedState(tmpl)}}); + const unsupported={...i,supportPpm:0,groundRule:null},counters={...metrics},reached=timeLimitReached,tick=candidateClockTick; + const fits=(scene,item,ratio)=>candidatesFor(scene.tmpl,item,scene.state,scene.points,scene.index,scene.used,1,ratio).length>0; + const blocked=finishedScenes.some(scene=>fits(scene,unsupported,0)&&!fits(scene,i,globalSupportPpm)); + Object.assign(metrics,counters);timeLimitReached=reached;candidateClockTick=tick; + supportVerdicts.set(i.raw.id,blocked);return blocked}; const unpacked=remaining.map(i=>{ // Same geometric check with every physical orientation allowed, not only the // item's own restricted set: distinguishes "genuinely too big in any rotation" @@ -3138,7 +3420,7 @@ const unpacked=remaining.map(i=>{ // whatever a policy says, and naming the policy first would send a caller to change the // wrong thing. const cited=policyRules.length?provesUnplaceable(policyRules,i.tags,templates):null; - const reason=!fitsAnyRotation?'no_compatible_container_dimensions':!dimensionFit?'rotation_restricted':!weightFit?'payload_exceeded':!eligibleFit?'no_eligible_container':cited!==null?'policy_rule':timeLimitReached?'time_limit':effortExceeded()?'effort_limit':i.group!==null?'group_cannot_fit_together':limitReason(i,templates,openedByType,maxContainers,clear)??'search_exhausted',details=reason==='policy_rule'?[cited]:[]; + const reason=!fitsAnyRotation?'no_compatible_container_dimensions':!dimensionFit?'rotation_restricted':!weightFit?'payload_exceeded':!eligibleFit?'no_eligible_container':cited!==null?'policy_rule':timeLimitReached?'time_limit':effortExceeded()?'effort_limit':i.group!==null?'group_cannot_fit_together':supportIsTheBlocker(i)?'insufficient_support':limitReason(i,templates,openedByType,maxContainers,clear)??'search_exhausted',details=reason==='policy_rule'?[cited]:[]; return {item_id:i.id,item_type:i.raw.id,reason,details,proof:proofForReason(reason,details)} }); // Canonical objective vector — see docs/OBJECTIVE.md. Five lexicographic keys, @@ -3190,7 +3472,7 @@ function resultTicks(value,name){ function rebalanceContext(req,result){ rejectUnsupported(req); const unit=req.units?.length??'mm',clear=scalar(req.configuration?.clearance??0,unit,LEN); - const globalSupportPpm=Math.round((req.configuration?.minimum_support_ratio??0)*SUPPORT_SCALE); + const globalSupportPpm=supportPpm(req.configuration?.minimum_support_ratio??0); const items=new Map(); for(const raw of req.items??[]){ const d=dims(raw.dimensions,unit),w=scalar(raw.weight??0,'g',WT),accepts=acceptedStackingGroups(raw); @@ -3198,7 +3480,7 @@ function rebalanceContext(req,result){ raw,d,w,id:`${raw.id}#${sequence}`, rots:raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)), stackable:raw.stackable!==false,maxTop:raw.max_top_load==null?null:scalar(raw.max_top_load,'g',WT), - supportPpm:Math.round((raw.minimum_support_ratio??0)*SUPPORT_SCALE), + supportPpm:supportPpm(raw.minimum_support_ratio??0), tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null, nesting:raw.nesting_height==null?null:scalar(raw.nesting_height,unit,LEN), maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null, @@ -3217,7 +3499,7 @@ function rebalanceContext(req,result){ const template={...raw,d,outerD:raw.outer_dimensions?dims(raw.outer_dimensions,unit):d,bag:raw.deformable_boundary!=null, max:raw.max_payload==null?null:scalar(raw.max_payload,'g',WT), tare:scalar(raw.tare_weight??0,'g',WT),axleSpec, - reservePpm:Math.round((raw.void_fill_reserve_ratio??0)*SUPPORT_SCALE), + reservePpm:scaledRatio(raw.void_fill_reserve_ratio??0), tagLimits:raw.tag_limits??{}, maxStackDensity:raw.max_stack_density==null?null:scalar(raw.max_stack_density,'g',WT), obs:(raw.obstacles??[]).flatMap(obstacle=>[obstacle,...(obstacle.additional_boxes??[])]).map(obstacle=>({ @@ -3307,19 +3589,19 @@ function rebalanceCandidatePoints(state){ return [...keys].map(key=>key.split(',').map(Number)).sort((a,b)=>a[2]-b[2]||a[1]-b[1]||a[0]-b[0]) } function publicRebalancedContainers(req,context){ - const outputLength=req.output?.length_unit??req.units?.length??'mm',outputWeight=req.output?.weight_unit??'g'; + const outputLength=req.output?.length_unit??outputLengthUnit(req.units?.length??'mm'),outputWeight=req.output?.weight_unit??'g'; return context.states.map(state=>{ const loads=topLoads(state.placements.map(constraintBox)); const payload=state.placements.reduce((total,placement)=>total+placement.item.w,0); const used=usedVolume(state.placements),reaction=axleReactions(state.tmpl,state.placements); const container={...state.publicContainer,payload_weight:outWeight(payload,outputWeight), gross_weight:outWeight(payload+state.tmpl.tare,outputWeight),used_volume_ticks3:used.toString(), - volume_utilization:(Number(used)/Number(volume(state.tmpl.d))).toFixed(6), + volume_utilization:sixPlaces(used,volume(state.tmpl.d)), centre_of_mass_offset_ppm:centreOfMassOffsetPpm(state.tmpl,state.placements,context.clear), placements:state.placements.map((placement,index)=>({...placement.publicPlacement, position:outPoint({x:placement.x+context.clear,y:placement.y+context.clear,z:placement.z+context.clear},outputLength), dimensions:outDims(placement.pd,outputLength),orientation:placement.r, - support_ratio:supportRatioOf(placement,state.placements).toFixed(6), + support_ratio:sixPlaces(...supportFraction(placement,state.placements)), top_load:outWeight(loads[index],outputWeight), })), }; diff --git a/index.d.ts b/index.d.ts index 5b045df..c741282 100644 --- a/index.d.ts +++ b/index.d.ts @@ -73,3 +73,12 @@ export const commerce:{ evaluatePolicy(document:unknown,request:unknown):CommerceResult; catalogVersionInfo(document:unknown,request:unknown):CommerceResult; }; + +/** One way a result fails to be a sound answer to its request (docs/VALIDATION-CONTRACT.md). */ +export interface ResultIssue{code:string;detail:string} +/** The result does not have the documented shape; `kind` says how the reference answers it. */ +export class MalformedResult extends Error{readonly kind:'contract'|'shape';constructor(message:string,kind:'contract'|'shape')} +/** `validate` was called without the compiled addon; the fallback's validator is. */ +export class ValidatorUnavailableError extends Error{readonly code:'validator_unavailable'} +/** The reference validator's issues, identical across packages; empty when valid. Needs the compiled addon. */ +export function validate(request:PackingRequest,result:PackingResult):ResultIssue[]; diff --git a/index.js b/index.js index a582b5e..e7a7b13 100644 --- a/index.js +++ b/index.js @@ -19,8 +19,12 @@ export { export { FixedPlacementError, InvalidRequestError, UnsupportedFeatureError }; import * as commerceFallback from './commerce.js'; export { CommerceInputError } from './commerce.js'; +import { SUITE_VERSION } from './artifacts.js'; const require = createRequire(import.meta.url); +/** The largest `maxMoves` the addon's `u32` parameter holds; napi wraps anything above it. */ +const MAX_U32 = 0xffffffff; + /** * Every module this package can load as a compiled backend: a binary built beside this * file. No npm package is probed -- a specifier the manifest does not declare cannot be @@ -39,14 +43,66 @@ const NATIVE_CANDIDATES = ['./packvium-native.node']; * misses is not an error: absent, unbuilt and ABI-incompatible addons all land here, and * every one of them means the same thing -- answer from the JavaScript fallback, which * returns the same result more slowly. + * + * An addon built for another release is treated the same way: it would answer + * with that release's rules and schema while `version()` and the package claim this one. */ function loadNative() { try { - return require('./packvium-native.node'); + const binding = require('./packvium-native.node'); + return isThisRelease(binding) ? binding : null; } catch { /* no addon beside this file */ } return null; } +/** Whether `binding` reports exactly the suite version this package was released at. */ +function isThisRelease(binding) { + return typeof binding?.version === 'function' && binding.version() === SUITE_VERSION; +} + +/** + * The fields the core names in its one unsupported-feature sentence (`packvium-core` + * `api.rs`, "does not yet implement a, b; the request was rejected ..."), read from the + * message because the core keeps only that sentence -- the same reading `@packvium/browser` + * makes of the same error. + */ +const UNSUPPORTED_FIELDS = /does not yet implement (.+?); the request was rejected/; + +/** + * The class a native refusal stands for, or the error itself when it is not one: the addon + * raises a plain `Error` whose `code`, `reason`, `field` and `detail` carry the core's + * structured refusal, and a caller must get the class the fallback throws for the same input. + */ +function fromNative(error) { + if (!(error instanceof Error)) return error; + if (error.code === InvalidRequestError.code) { + return new InvalidRequestError(error.reason, error.field, error.detail); + } + if (error.code === FixedPlacementError.code) { + return new FixedPlacementError(error.detail, error.reason, error.field); + } + if (error.code === 'unsupported_feature') return unsupportedFromNative(error.message); + return error; +} + +/** The core's refusal as the fallback's class, keeping the core's own sentence as the message. */ +function unsupportedFromNative(message) { + const named = UNSUPPORTED_FIELDS.exec(message); + const translated = new UnsupportedFeatureError(named ? named[1].split(', ') : []); + // The sentence names the engine that refused -- here the Rust core, not the JavaScript fallback. + translated.message = message; + return translated; +} + +/** The native call's answer, or its refusal re-thrown as the fallback's class. */ +function answered(call) { + try { + return call(); + } catch (error) { + throw fromNative(error); + } +} + const native = loadNative(); export const backend = () => (native ? 'rust' : 'javascript'); @@ -61,7 +117,7 @@ export function packJsonWith(binding, input) { // The native binding reports errors as text; checking the rule table here first gives a // caller the same InvalidRequestError whichever backend answers. checkRequestShape(JSON.parse(input)); - return binding.packJson(input); + return answered(() => binding.packJson(input)); } return JSON.stringify(packFallback(JSON.parse(input))); } @@ -70,17 +126,74 @@ export function pack(request) { return JSON.parse(packJson(JSON.stringify(request))); } -export function rebalanceWeight(request, result, { maxMoves = 64 } = {}) { - if (!Number.isSafeInteger(maxMoves) || maxMoves < 0) { - throw new RangeError('maxMoves must be a non-negative safe integer'); +export function rebalanceWeight(request, result, options) { + return rebalanceWeightWith(native, request, result, options); +} + +/** + * Separate from `rebalanceWeight` so the native path can be exercised without a native build. + * + * The bound is the addon's: it takes `maxMoves` as a `u32`, and napi wraps anything above it + * (`2 ** 32` would arrive as zero moves). Refused on both backends, with `@packvium/browser`'s + * message, so the answer to an out-of-range value does not depend on which one is installed. + */ +export function rebalanceWeightWith(binding, request, result, { maxMoves = 64 } = {}) { + if (!Number.isSafeInteger(maxMoves) || maxMoves < 0 || maxMoves > MAX_U32) { + throw new RangeError('maxMoves must be an integer from 0 to 4294967295'); } - if (native?.rebalanceJson) { - return JSON.parse(native.rebalanceJson(JSON.stringify(request), JSON.stringify(result), maxMoves)); + if (binding?.rebalanceJson) { + return JSON.parse(answered(() => binding.rebalanceJson(JSON.stringify(request), JSON.stringify(result), maxMoves))); } return rebalanceFallback(request, result, { maxMoves }); } -export const version = () => native?.version?.() ?? '1.6.0-js-fallback'; +export const version = () => native?.version?.() ?? '1.7.0-js-fallback'; + +/** + * The result is not a document the reference validator can judge (docs/VALIDATION-CONTRACT.md): + * a contract break, not a packing defect. `kind` is `contract` when the reference raises + * `MalformedResult` with this same message, `shape` when it fails on the input another way. + */ +export class MalformedResult extends Error { + constructor(message, kind) { + super(message); + this.name = 'MalformedResult'; + this.kind = kind; + } +} + +/** + * `validate` needs the compiled addon: the reference validator is ported to the Rust core + *, and the JavaScript fallback's own port is. + */ +export class ValidatorUnavailableError extends Error { + constructor() { + super('validate() needs the compiled addon; the JavaScript fallback has no validator yet'); + this.name = 'ValidatorUnavailableError'; + this.code = 'validator_unavailable'; + } +} + +/** + * Every way `result` fails to be a sound answer to `request`, as `{code, detail}` objects + * identical to `packvium.validate`'s in every package; an empty list means valid. Throws + * `MalformedResult` for a result that does not have the documented shape at all. + */ +export function validate(request, result) { + return validateWith(native, request, result); +} + +// Separate from `validate` so the native path can be exercised without a native build. +export function validateWith(binding, request, result) { + if (!binding?.validateJson) { + throw new ValidatorUnavailableError(); + } + const outcome = JSON.parse(binding.validateJson(JSON.stringify(request), JSON.stringify(result))); + if (outcome.refusal) { + throw new MalformedResult(outcome.refusal.message, outcome.refusal.kind); + } + return outcome.issues; +} /** * The exported commercial and control-plane API: a quote, a policy decision and catalog diff --git a/package.json b/package.json index 7ca2288..0190b07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name":"@packvium/engine", - "version":"1.6.0", + "version":"1.7.0", "description":"Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.", "author":{"name":"Packvium contributors","url":"https://packvium.com"}, "keywords":["3d-bin-packing","bin-packing","3d-packing","cartonization","container-loading","pallet-packing","truck-loading","packing-algorithm","logistics","deterministic"], @@ -10,8 +10,8 @@ "type":"module", "main":"index.js", "types":"index.d.ts", - "exports":{".":{"types":"./index.d.ts","import":"./index.js"},"./execution.js":{"types":"./execution.d.ts","import":"./execution.js"},"./artifacts.js":{"types":"./artifacts.d.ts","import":"./artifacts.js"},"./artifact-exports.js":{"types":"./artifact-exports.d.ts","import":"./artifact-exports.js"},"./revisions.js":{"types":"./revisions.d.ts","import":"./revisions.js"}}, - "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","execution.js","canonical-json.js","artifacts.js","artifact-exports.js","revisions.js","request-errors.js","made-to-measure.js","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","revisions.d.ts","README.md","SECURITY.md"], + "exports":{".":{"types":"./index.d.ts","import":"./index.js"},"./execution.js":{"types":"./execution.d.ts","import":"./execution.js"},"./artifacts.js":{"types":"./artifacts.d.ts","import":"./artifacts.js"},"./artifact-exports.js":{"types":"./artifact-exports.d.ts","import":"./artifact-exports.js"},"./revisions.js":{"types":"./revisions.d.ts","import":"./revisions.js"},"./scene.js":{"types":"./scene.d.ts","import":"./scene.js"}}, + "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","execution.js","canonical-json.js","artifacts.js","artifact-exports.js","revisions.js","scene.js","request-errors.js","made-to-measure.js","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","revisions.d.ts","scene.d.ts","README.md","SECURITY.md"], "engines":{"node":">=16"}, "scripts":{ "test":"node test/run-tests.mjs", diff --git a/request-errors.js b/request-errors.js index 0c455d2..6dc7424 100644 --- a/request-errors.js +++ b/request-errors.js @@ -12,7 +12,7 @@ * - `detail`: the fixed text for the reason, so the message is identical in every engine. * * `checkRequest` walks the schema's rules over the raw JSON before any model is built, and the - * first violation wins, in the order every engine follows: units, configuration, items, + * first violation wins, in the order every engine follows: units, output, configuration, items, * containers. A cross-engine suite holds all four engines to the same reason, field and * message. * @@ -27,6 +27,13 @@ import { boxCost } from './made-to-measure.js'; export const SOLVER_PROFILES = Object.freeze(['fast', 'balanced', 'quality', 'exact_small']); export const OBJECTIVES = Object.freeze(['default', 'lowest_cost', 'shipping_cost', 'lowest_landed_cost', 'open_dimension_height', 'maximum_value', 'maximum_volume']); export const ACCESS_DIRECTIONS = Object.freeze(['+x', '-x', '+y', '-y', '+z', '-z']); +// The schema's closed vocabularies, each refused as `not_allowed` like the two above. +export const SOLVERS = Object.freeze(['grid', 'extreme_points', 'homogeneous_blocks', 'layer', 'maximal_spaces', 'exact_small']); +export const ROTATIONS = Object.freeze(['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']); +export const LENGTH_UNITS = Object.freeze(['mm', 'cm', 'm', 'in', 'ft']); +export const WEIGHT_UNITS = Object.freeze(['mg', 'g', 'kg', 'oz', 'lb']); +export const GROUND_CONTACT_RULES = Object.freeze(['free', 'covered', 'single', 'multiple']); +export const SHAPE_TYPES = Object.freeze(['rigid_cuboid', 'convex_hull', 'compressible']); /** The request is not one any engine may answer. Nothing was solved. */ export class InvalidRequestError extends RangeError { @@ -87,6 +94,12 @@ function requireOneOf(value, field, allowed) { } } +const oneOf = (allowed) => (value, field) => requireOneOf(value, field, allowed); + +const eachOneOf = (allowed) => (raw, where) => { + requireList(raw, where).forEach((value, index) => requireOneOf(value, where + pointer(index), allowed)); +}; + /** * The schema closes this object: a key it does not name is refused, never ignored. The first * unknown key in code-point order is named, the order every engine can share. @@ -165,14 +178,15 @@ const atLeast = (minimum) => (value, field) => requireInteger(value, field, mini export function checkRequest(data, measures) { const request = requireObject(data, ''); const unit = checkUnits(request.units, measures.length); + checkOutput(request.output); const length = (value, field) => requireMeasure(value, field, measures.length, unit); const weight = (value, field) => requireMeasure(value, field, measures.weight, 'g'); checkConfiguration(request.configuration, length); const items = requiredList(request, 'items', ''); - items.forEach((raw, index) => checkItem(raw, pointer('items', index), length, weight)); + const lengthTicks = (value) => measures.length.parse(value, unit); + items.forEach((raw, index) => checkItem(raw, pointer('items', index), length, weight, lengthTicks)); requireUniqueIds(items, 'items'); const containers = requiredList(request, 'containers', ''); - const lengthTicks = (value) => measures.length.parse(value, unit); containers.forEach((raw, index) => checkContainer(raw, pointer('containers', index), length, weight, lengthTicks)); requireUniqueIds(containers, 'containers'); } @@ -187,12 +201,22 @@ function checkUnits(raw, lengths) { return length; } +function checkOutput(raw) { + if (raw == null) return; + const output = requireObject(raw, '/output'); + optional(output, 'length_unit', '/output', oneOf(LENGTH_UNITS)); + optional(output, 'weight_unit', '/output', oneOf(WEIGHT_UNITS)); +} + function checkConfiguration(raw, length) { if (raw == null) return; const configuration = requireObject(raw, '/configuration'); requireKnownFields(configuration, '/configuration', CONFIGURATION_FIELDS); - optional(configuration, 'solver_profile', '/configuration', (v, f) => requireOneOf(v, f, SOLVER_PROFILES)); - optional(configuration, 'objective', '/configuration', (v, f) => requireOneOf(v, f, OBJECTIVES)); + optional(configuration, 'solver_profile', '/configuration', oneOf(SOLVER_PROFILES)); + optional(configuration, 'objective', '/configuration', oneOf(OBJECTIVES)); + optional(configuration, 'solvers', '/configuration', eachOneOf(SOLVERS)); + optional(configuration, 'dimensional_weight_length_unit', '/configuration', oneOf(LENGTH_UNITS)); + optional(configuration, 'dimensional_weight_weight_unit', '/configuration', oneOf(WEIGHT_UNITS)); for (const [name, minimum] of CONFIGURATION_INTEGERS) optional(configuration, name, '/configuration', atLeast(minimum)); optional(configuration, 'minimum_support_ratio', '/configuration', requireRatio); optional(configuration, 'clearance', '/configuration', length); @@ -202,7 +226,7 @@ function checkConfiguration(raw, length) { for (const name of EFFORT_LIMITS) optional(budget, name, '/configuration/effort_budget', atLeast(1)); } -function checkItem(raw, where, length, weight) { +function checkItem(raw, where, length, weight, lengthTicks) { const item = requireObject(raw, where); requiredString(item, 'id', where); optional(item, 'quantity', where, atLeast(1)); @@ -210,6 +234,7 @@ function checkItem(raw, where, length, weight) { optional(item, 'weight', where, weight); optional(item, 'max_top_load', where, weight); optional(item, 'nesting_height', where, length); + optional(item, 'nesting_height', where, (value, field) => nestingBelowHeight(value, field, item, lengthTicks)); optional(item, 'max_stacked_items', where, atLeast(1)); optional(item, 'stop_index', where, atLeast(0)); optional(item, 'value', where, atLeast(0)); @@ -218,6 +243,19 @@ function checkItem(raw, where, length, weight) { optional(item, 'compression_ratio', where, requireUnbounded); optional(item, 'stacking_group', where, stackingGroupName); optional(item, 'accepts_stacking_groups', where, stackingGroupNames); + optional(item, 'allowed_rotations', where, eachOneOf(ROTATIONS)); + optional(item, 'ground_contact_rule', where, oneOf(GROUND_CONTACT_RULES)); + optional(item, 'shape_type', where, oneOf(SHAPE_TYPES)); +} + +/** + * A nested copy sinks by less than its own height, or it would vanish into the one below + *. Both measures have passed their own rules already. O(1). + */ +function nestingBelowHeight(value, field, item, lengthTicks) { + if (lengthTicks(value) >= lengthTicks(item.dimensions.height)) { + throw new InvalidRequestError('invalid_value', field, "must be less than the item's height"); + } } function stackingGroupName(value, field) { @@ -307,10 +345,7 @@ function setBesideABag(name, value) { return value != null; } -function accessDirections(raw, where) { - const directions = requireList(raw, where); - directions.forEach((direction, index) => requireOneOf(direction, where + pointer(index), ACCESS_DIRECTIONS)); -} +const accessDirections = eachOneOf(ACCESS_DIRECTIONS); function dimensions(raw, where, length) { const sides = requireObject(raw, where); diff --git a/revisions.js b/revisions.js index 776f3c8..f844d11 100644 --- a/revisions.js +++ b/revisions.js @@ -252,7 +252,7 @@ function validatePlacement(event) { requireName(get(placement, 'item_type'), 'placement.item_type'); requireName(get(placement, 'container_type'), 'placement.container_type'); if (!ORIENTATIONS.includes(get(placement, 'orientation'))) { - throw new PlanRevisionError('invalid_event', 'placement.orientation is one of the six codes'); + throw new PlanRevisionError('invalid_event', `placement.orientation must be one of ${jsonSpelling(ORIENTATIONS)}`); } const instance = jsonInteger(hasOwn(placement, 'container_instance') ? placement.container_instance : 1); if (instance === null || instance < 1) { diff --git a/scene.d.ts b/scene.d.ts new file mode 100644 index 0000000..3abab89 --- /dev/null +++ b/scene.d.ts @@ -0,0 +1,59 @@ +// Types for `@packvium/engine/scene.js` — visualization scenes from a packing result. +// +// visualizer/spec/scene.md and scene.schema.json are the contract: one scene is one container, +// every length an exact tick string beside a display-only `mm` decimal. +import type { PackingRequest, PackingResult } from './index.js'; + +export const SCENE_FORMAT: 'packvium-visualization-scene/v1'; + +/** An exact tick count, and the same length in millimetres for display only. */ +export interface SceneLength{ticks:string;mm:string} +export interface ScenePoint{x:SceneLength;y:SceneLength;z:SceneLength} +export interface SceneDimensions{length:SceneLength;width:SceneLength;height:SceneLength} +export type SceneRotation='LWH'|'LHW'|'WLH'|'WHL'|'HLW'|'HWL'; +export interface SceneItem{id:string;position:ScenePoint;dimensions:SceneDimensions;rotation:SceneRotation} +export interface SceneStep{step:number;item_id:string} + +export interface Scene{ + format:'packvium-visualization-scene/v1'; + display_unit?:'mm'|'cm'|'m'|'in'|'ft'; + container:{id:string;dimensions:SceneDimensions}; + items:SceneItem[]; + obstacles:[]; + sequence_kind?:'loading'; + sequence?:SceneStep[]; +} + +/** A plan's address for a placement: what a scene item id is spelled from. */ +export interface ScenePlacementReference{readonly item_type:string;readonly orientation:string;readonly position_ticks:{readonly x:number;readonly y:number;readonly z:number}} +type SceneTickDimensions={readonly length:string;readonly width:string;readonly height:string}; + +/** + * A plan container. `sequence` stays optional to match `ExecutionStep`, but under a `loading` + * order every step must carry it -- the plan builder always numbers them, and the number is the + * scene's `step` -- and `artifactToScene` refuses one that does not. An `unavailable` order's + * steps are not read. + */ +export interface ScenePlanContainer{readonly order:'loading'|'unavailable';readonly steps:ReadonlyArray<{readonly sequence?:number;readonly placement:ScenePlacementReference}>} + +/** + * The fields of an operational artifact a scene is read from. Any `OperationalArtifact` from + * `./artifacts.js` satisfies it, as does a reader's own narrower parse of the same document. + */ +export interface SceneSource{ + readonly geometry:{readonly containers:ReadonlyArray<{readonly inner_dimensions:SceneTickDimensions;readonly placements:ReadonlyArray<{readonly placement:ScenePlacementReference;readonly dimensions:SceneTickDimensions}>}>}; + readonly plan:{readonly containers:ReadonlyArray}; + readonly work_order:{readonly length_unit:string|null}; +} + +/** Every container of the result `pack` returned for `request`, one scene each, in order. */ +export function resultToScenes(request:PackingRequest,result:PackingResult):Scene[]; + +/** + * One container of an operational artifact; a `RangeError` for an index it does not have, and + * for any field above that is missing or mistyped, a loading step without its `sequence` included. + */ +export function artifactToScene(artifact:SceneSource,containerIndex:number):Scene; + +/** `@,,/`, unique within a container; a `RangeError` for a malformed reference. */ +export function sceneItemId(reference:ScenePlacementReference):string; diff --git a/scene.js b/scene.js new file mode 100644 index 0000000..b771a1b --- /dev/null +++ b/scene.js @@ -0,0 +1,185 @@ +/** + * Visualization scenes (`packvium-visualization-scene/v1`, visualizer/spec/scene.md) from a + * packing result, so a caller can draw one without writing the conversion. + * + * A scene is read from the operational artifact (docs/OPERATIONAL-ARTIFACTS.md), never from a + * solver, a request or a clock: sizes from its `geometry`, the sequence from its `plan`. One + * scene is one container. The visualizer's viewer draws through `artifactToScene` and admits + * artifacts by `sceneItemId`, so this module is the only scene converter. + */ + +import { buildOperationalArtifact } from './artifacts.js'; + +export const SCENE_FORMAT = 'packvium-visualization-scene/v1'; + +const TICKS_PER_MM = 16000n; +const DISPLAY_UNITS = new Set(['mm', 'cm', 'm', 'in', 'ft']); + +/** + * Every container of a packing result as a scene, in the result's container order. The result + * must be the one `pack` returned for this request. O(P) for P placements. + */ +export function resultToScenes(request, result) { + const artifact = buildOperationalArtifact(request, result); + return artifact.geometry.containers.map((_, index) => artifactToScene(artifact, index)); +} + +/** + * One container of an operational artifact as a scene. O(P) for the container's P placements. + * + * - Item ids come from the plan's placement reference; the artifact carries no `item_id`. + * - A sequence exists only when the plan's order is `loading`. An `unavailable` order yields + * no sequence rather than one inferred from array position, which would present the order + * the solver walked its candidates in as a safe order to lift boxes in. + * - `step` is the plan's own `sequence`, the number the printed work order shows. A loading + * step without one is refused, not numbered here: the scene must replay the sheet's numbers. + * - Obstacles are empty: the artifact's geometry states none, and reconstructing them from + * `provenance.request` would draw geometry the artifact itself does not assert. + * + * Every departure from the fields `SceneSource` documents is a `RangeError` naming the path of + * the bad value, as is an index the artifact has no container for. + */ +export function artifactToScene(artifact, containerIndex) { + const source = requireObject(artifact, 'artifact'); + const geometries = requireList(requireObject(source.geometry, 'artifact.geometry').containers, 'artifact.geometry.containers'); + const plans = requireList(requireObject(source.plan, 'artifact.plan').containers, 'artifact.plan.containers'); + if (!Number.isInteger(containerIndex) || containerIndex < 0 || containerIndex >= geometries.length + || containerIndex >= plans.length) { + throw new RangeError(`container ${containerIndex} is not one of the artifact's ${geometries.length} containers`); + } + const unit = lengthUnit(source.work_order); + const geometry = requireObject(geometries[containerIndex], `artifact.geometry.containers[${containerIndex}]`); + const scene = { + format: SCENE_FORMAT, + ...(DISPLAY_UNITS.has(unit) ? { display_unit: unit } : {}), + container: { id: String(containerIndex), dimensions: sceneDimensions(geometry.inner_dimensions, `artifact.geometry.containers[${containerIndex}].inner_dimensions`) }, + items: sceneItems(geometry.placements, `artifact.geometry.containers[${containerIndex}].placements`), + obstacles: [], + }; + const sequence = loadingSequence(plans[containerIndex], `artifact.plan.containers[${containerIndex}]`); + return sequence === null ? scene : { ...scene, sequence_kind: 'loading', sequence }; +} + +function lengthUnit(workOrder) { + const unit = requireObject(workOrder, 'artifact.work_order').length_unit; + if (unit !== null && typeof unit !== 'string') { + throw new RangeError('artifact.work_order.length_unit must be a string or null'); + } + return unit; +} + +function sceneItems(placements, path) { + return requireList(placements, path).map((entry, index) => { + const at = `${path}[${index}]`; + const { placement, dimensions } = requireObject(entry, at); + const reference = readReference(placement, `${at}.placement`); + const { x, y, z } = reference.position_ticks; + return { + id: spellItemId(reference), + position: { x: lengthValue(x), y: lengthValue(y), z: lengthValue(z) }, + dimensions: sceneDimensions(dimensions, `${at}.dimensions`), + rotation: reference.orientation, + }; + }); +} + +/** The plan's loading steps as scene steps, or `null` when the plan established no order. */ +function loadingSequence(raw, path) { + const plan = requireObject(raw, path); + if (plan.order !== 'loading' && plan.order !== 'unavailable') { + throw new RangeError(`${path}.order must be loading or unavailable`); + } + const steps = requireList(plan.steps, `${path}.steps`); + if (plan.order === 'unavailable') return null; + return steps.map((entry, index) => { + const at = `${path}.steps[${index}]`; + const step = requireObject(entry, at); + if (!Number.isSafeInteger(step.sequence) || step.sequence < 1) { + throw new RangeError(`${at}.sequence must be a positive integer when the order is loading`); + } + return { step: step.sequence, item_id: spellItemId(readReference(step.placement, `${at}.placement`)) }; + }); +} + +function sceneDimensions(raw, path) { + const sides = requireObject(raw, path); + return { + length: lengthValue(tickString(sides.length, `${path}.length`)), + width: lengthValue(tickString(sides.width, `${path}.width`)), + height: lengthValue(tickString(sides.height, `${path}.height`)), + }; +} + +/** + * The scene item id for a plan placement reference, `@,,/`. + * Injective within a container even when `item_type` contains `@` or `/`: read from the right, + * the orientation is one of six letter triples and the coordinates hold only digits, commas and + * minus signs (docs/EXECUTION-PLAN.md). Exported so a caller can match a scene item to its step. + * A reference without those fields is a `RangeError`. + */ +export function sceneItemId(reference) { + return spellItemId(readReference(reference, 'reference')); +} + +/** The id of a reference `readReference` has already admitted. */ +function spellItemId({ item_type: itemType, orientation, position_ticks: { x, y, z } }) { + return `${itemType}@${x},${y},${z}/${orientation}`; +} + +const ROTATIONS = new Set(['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']); + +/** A placement reference holding every field a scene reads, or a `RangeError` naming the first that is not. */ +function readReference(raw, path) { + const reference = requireObject(raw, path); + if (typeof reference.item_type !== 'string') throw new RangeError(`${path}.item_type must be a string`); + if (!ROTATIONS.has(reference.orientation)) { + throw new RangeError(`${path}.orientation must be one of ${[...ROTATIONS].join(', ')}`); + } + const position = requireObject(reference.position_ticks, `${path}.position_ticks`); + for (const axis of ['x', 'y', 'z']) { + if (!Number.isInteger(position[axis])) throw new RangeError(`${path}.position_ticks.${axis} must be an integer`); + } + return reference; +} + +const TICKS = /^-?[0-9]+$/; + +function tickString(value, path) { + if (typeof value !== 'string' || !TICKS.test(value)) { + throw new RangeError(`${path} must be a tick count spelled as decimal digits`); + } + return value; +} + +function requireObject(value, path) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new RangeError(`${path} must be an object`); + } + return value; +} + +function requireList(value, path) { + if (!Array.isArray(value)) throw new RangeError(`${path} must be a list`); + return value; +} + +/** + * The exact `lengthValue` for a tick count: `mm` is the decimal quotient with no rounding and + * no trailing zeros. BigInt, never a float: a float would render 0.1 mm as + * 0.09999999999999999. The loop runs at most seven times, because 16000 = 2^7 * 5^3 makes + * every quotient terminate within seven decimal places. + */ +function lengthValue(ticks) { + const exact = BigInt(ticks); + const negative = exact < 0n; + const magnitude = negative ? -exact : exact; + let remainder = magnitude % TICKS_PER_MM; + let fraction = ''; + while (remainder !== 0n) { + remainder *= 10n; + fraction += String(remainder / TICKS_PER_MM); + remainder %= TICKS_PER_MM; + } + const whole = String(magnitude / TICKS_PER_MM); + return { ticks: String(exact), mm: `${negative ? '-' : ''}${whole}${fraction === '' ? '' : `.${fraction}`}` }; +} diff --git a/test/commerce-native-dispatch.test.mjs b/test/commerce-native-dispatch.test.mjs index 89e593f..72cc0e6 100644 --- a/test/commerce-native-dispatch.test.mjs +++ b/test/commerce-native-dispatch.test.mjs @@ -23,8 +23,15 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import Module from 'node:module'; -/** Reshaped per case; `index.js` reads its entry points at call time, not at load time. */ -const stub = {}; +import { SUITE_VERSION } from '../artifacts.js'; + +/** + * Reshaped per case; `index.js` reads its entry points at call time, not at load time. The + * one thing it reads at load time is `version()`: an addon from another release is not used + * at all, so the stub reports this one and every case keeps it. + */ +const version = () => SUITE_VERSION; +const stub = { version }; const previousLoad = Module._load; Module._load = function loadWithStubbedNativeBackend(request, parent, isMain) { @@ -40,7 +47,7 @@ const REQUEST = { marker: 'probe' }; /** @param {Record} entries what this case's addon exports */ function addon(entries) { for (const key of Object.keys(stub)) delete stub[key]; - Object.assign(stub, entries); + Object.assign(stub, { version }, entries); } test('each commerce function reaches its own native entry point', () => { diff --git a/test/fallback.test.mjs b/test/fallback.test.mjs index faf18fa..8c31c2f 100644 --- a/test/fallback.test.mjs +++ b/test/fallback.test.mjs @@ -7,7 +7,7 @@ import { BoundOverflowError, __spatialIndexForTests, __inspectHullShapeForTests, __objectiveBoundsForTests, __occupiesLessThanItsBoxForTests, __candidatePrefixForTests, __groupBatchesForTests, aggregateTermination, InvalidRequestError, explainUnpackedItem, explanationForUnpackedItem, packFallback, rebalanceWeight, - verifyLoadingPrefixBusinessRules, + verifyLoadingPrefixBusinessRules, __ratiosForTests, __planBeamForTests, } from '../fallback.js'; import { objective as recomputeObjective, validate } from './validate.mjs'; @@ -52,6 +52,27 @@ test('bounded candidate retention equals the stable full-sort prefix', () => { } }); +// Mirrors Rust's `far_corner_y_outranks_any_x_on_a_long_container`. Past the +// sheet, the tile's far corner is half a millimetre deeper in y at x=0 than at x=12 m; a +// weighted `y2 * 1e4 + x2` let the 12 m of x outweigh that step, the lexicographic key does not. +for (const profile of ['fast', 'balanced', 'quality']) { + test(`far-corner y outranks any x on a long container (${profile})`, () => { + const result = packFallback({ + configuration: { solver_profile: profile }, + items: [ + { id: 'sheet', quantity: 1, dimensions: { length: '12000', width: '0.5', height: '100' }, allowed_rotations: ['LWH'] }, + { id: 'tile', quantity: 1, dimensions: { length: '400', width: '999', height: '100' }, allowed_rotations: ['LWH'] }, + ], + containers: [{ id: 'long', quantity: 1, inner_dimensions: { length: '12400', width: '1000', height: '100' } }], + fixed_placements: [{ item_type: 'sheet', container_type: 'long', container_instance: 1, + position: { x: '0', y: '0', z: '0' }, orientation: 'LWH' }], + }); + const tile = result.containers.flatMap((container) => container.placements) + .find((placement) => placement.item_id.startsWith('tile')); + assert.deepEqual([tile.position.x.value, tile.position.y.value, tile.position.z.value], ['12000', '0', '0']); + }); +} + test('group batching preserves first occurrence and member order', () => { const items = ['00', null, '0', '00', '1', '0', null, '1'] .map((group, id) => ({group, id})); @@ -278,7 +299,7 @@ test('explicit solver selection is reported honestly', () => { () => packFallback(request([cube('a', 50)], [box('c')], { configuration: { solvers: ['unknown'] }, })), - /unknown solver/, + /\/configuration\/solvers\/0: must be one of/, ); }); @@ -344,7 +365,7 @@ test('a nesting item on a route is held to stop order against the whole column', cube('early', 10, { quantity: 2, stop_index: 1, allowed_rotations: ['LWH'] }), ], [box('bin', 10, 10, 30, { quantity: 1 })], - { configuration: { time_limit_ms: 60_000 } }, + { configuration: { multi_start_orders: 1, time_limit_ms: 60_000 } }, ); const blocked = packSound(reversed); assert.deepEqual(placements(blocked).map(placement => placement.item_id), ['cup#1', 'cup#2', 'cup#3']); @@ -382,6 +403,26 @@ test('axle reactions use gross load and include centred tare', () => { assert.equal(reaction.front_numerator, reaction.rear_numerator); }); +test('an item only an axle limit places floats on the floor at that limit, as in Python', () => { + // Alone, the heavy item overloads the front axle flush against the front wall and is legal + // only from x = 42.857 mm, which touches no wall and no box. Every other candidate point is + // flush against one, so without the axle-balanced points it was left unpacked; one start, + // so no shuffle can stand in for the missing candidate. + const slab = { dimensions: mm(200, 100, 100), allowed_rotations: ['LWH'] }; + const result = packSound(request( + [{ id: 'light', quantity: 3, weight: '100 g', ...slab }, { id: 'heavy', weight: '700 g', ...slab }], + [box('truck', 2000, 100, 100, { + axles: [{ position: '0', max_load: '650 g' }, { position: '2000', max_load: '650 g' }], + })], + { configuration: { time_limit_ms: 60_000, multi_start_orders: 1 } }, + )); + assert.deepEqual(result.unpacked_items, []); + assert.deepEqual(placements(result).map((placement) => [placement.item_id, at(placement)]), [ + ['heavy#1', [685_715, 0, 0]], + ['light#1', [0, 0, 0]], ['light#2', [200 * MM, 0, 0]], ['light#3', [400 * MM, 0, 0]], + ]); +}); + test('nesting height fits an extra layer and removes double-counted volume', () => { const result = packFallback(request( [cube('a', 10, { quantity: 3, nesting_height: '2', allowed_rotations: ['LWH'] })], @@ -789,8 +830,7 @@ test('top-load shares use exact integer multiplication at the safe-number bounda }); const payload = (leftLimit) => ({ units: { length: 'ticks' }, - output: { length_unit: 'ticks', weight_unit: 'ticks' }, - configuration: { max_containers: 1, time_limit_ms: 60_000 }, + configuration: { max_containers: 1, multi_start_orders: 1, time_limit_ms: 60_000 }, items: [ { id: 'left', priority: 3, must_be_on_floor: true, @@ -836,7 +876,9 @@ test('cumulative top load stays exact beyond the safe-number boundary', () => { ], [box('column', 10, 10, 30)], { - output: { weight_unit: 'ticks' }, + // `ticks` is no output unit the schema names; a milligram is 8000 ticks, so + // 2^53 + 1 ticks is an exact decimal no Number division could write. + output: { weight_unit: 'mg' }, configuration: { solvers: ['extreme_points'], max_containers: 1, time_limit_ms: 60_000 }, }, ); @@ -853,7 +895,7 @@ test('cumulative top load stays exact beyond the safe-number boundary', () => { const reported = packFallback(payload(null)); const bottom = placements(reported).find(placement => placement.item_type === 'bottom'); assert.equal(bottom.top_load.ticks, boundary); - assert.equal(bottom.top_load.value, '9007199254740993'); + assert.equal(bottom.top_load.value, '1125899906842.624125'); assert.doesNotThrow(() => JSON.stringify(reported)); }); @@ -890,6 +932,7 @@ test('a high-priority item leads the ordering', () => { const result = packSound(request( [cube('big', 100), cube('small', 10, { priority: 5 })], [box('box', 100, 100, 100, { quantity: 1 })], + { configuration: { multi_start_orders: 1 } }, )); assert.deepEqual(placements(result).map((p) => p.item_id), ['small#1']); }); @@ -2569,7 +2612,9 @@ test('explicit solvers retain every nested restart record and honour max_restart // The first cartesian-portfolio implementation executed every run, but the outer // solver loop collapsed each solver's restarts to one synthetic record. It also // ignored max_restarts, unlike Python, PHP and Rust. Four is a total portfolio cap: - // three exact-small orders followed by the first extreme-point order. + // three exact-small orders followed by the first extreme-point order. The third + // exact-small shuffle repeats an earlier item order, so its start runs a neighbour of + // the first order instead. const result = packFallback(request( [cube('a', 30), cube('b', 25), cube('c', 20)], [box('box', 60, 50, 40, { quantity: 2 })], @@ -2586,7 +2631,7 @@ test('explicit solvers retain every nested restart record and honour max_restart [ 'exact_small:javascript_fallback', 'exact_small:javascript_fallback:seeded_1', - 'exact_small:javascript_fallback:seeded_2', + 'exact_small:javascript_fallback+swap-0', 'extreme_points:javascript_fallback', ], ); @@ -2616,6 +2661,52 @@ test('all restarts share one absolute deadline and preserve unstarted records', assert.equal(result.termination.code, 'time_limit'); }); +// The shared conformance fixture `regression-stacking-under`, verbatim. +const stackingUnderRequest = { + units: { length: 'mm' }, + configuration: { + solver_profile: 'balanced', time_limit_ms: 300000, clearance: '2', minimum_support_ratio: 0.5, + effort_budget: { max_candidates_evaluated: 1000000, max_placement_attempts: 1000000, max_search_nodes: 1000000 }, + }, + items: [ + { id: 'wide', quantity: 2, dimensions: { length: '83', width: '75', height: '54' }, + weight: { value: '473', unit: 'g' }, stackable: false }, + { id: 'tall', quantity: 2, dimensions: { length: '56', width: '24', height: '68' }, + weight: { value: '271', unit: 'g' }, group: 'a' }, + { id: 'small', dimensions: { length: '45', width: '24', height: '33' }, + weight: { value: '479', unit: 'g' }, stackable: false, minimum_support_ratio: 0.75 }, + ], + containers: [ + { id: 'tallbox', inner_dimensions: { length: '100', width: '136', height: '203' }, quantity: 2, cost_minor: 390 }, + { id: 'widebox', inner_dimensions: { length: '100', width: '148', height: '136' }, cost_minor: 74 }, + ], +}; + +test('a start never repeats an earlier start\'s item-type order', () => { + // On two items, starts 1, 2, 3 repeat start 0's order, so none of seeded_1..3 runs; + // freed starts run an adjacent-swap neighbour instead. + const twoItems = packFallback(request( + [cube('a', 30), cube('b', 25)], + [box('box', 60, 50, 40)], + { configuration: { multi_start_orders: 4, time_limit_ms: 60_000, seed: 42 } }, + )); + assert.ok(!twoItems.termination.starts.some((start) => start.id.startsWith('javascript_fallback:seeded_'))); + assert.equal(twoItems.algorithm.solver, 'javascript_fallback'); + + // Conformance `regression-stacking-under`, inlined so the published package, which ships + // no conformance tree, runs the same check: the sixth start repeats an earlier order, so + // its start runs the first order with its second and third items swapped. That neighbour + // does not strictly beat the fifth start's answer, so the fifth start keeps it. + const result = packFallback(stackingUnderRequest); + const ids = result.termination.starts.map((start) => start.id); + assert.deepEqual(ids, [ + 'javascript_fallback', 'javascript_fallback:seeded_1', 'javascript_fallback:seeded_2', 'javascript_fallback:seeded_3', + 'javascript_fallback:seeded_4', 'javascript_fallback:seeded_6', 'javascript_fallback:seeded_7', 'javascript_fallback+swap-1', + ]); + assert.equal(result.algorithm.solver, 'javascript_fallback:seeded_4'); + assert.deepEqual(result.score, [0, 1, 74, 557470, 992647]); +}); + test('restart orderings are a pure function of the seed', () => { const build = (seed) => packFallback(request( [cube('a', 30, { quantity: 3 }), cube('b', 25, { quantity: 4 }), cube('c', 20, { quantity: 5 })], @@ -2677,17 +2768,29 @@ test('homogeneous-block search falls back for placement-distinguishing rules', ( assert.deepEqual(validate(payload, result), []); }); -// One 60x60x40 base and three 40x60x50 tops in a 100 mm crate: the base ends on two tops -// and overhangs them. +// One 60x60x40 base and three 40x60x50 tops in a 100 mm crate. The greedy block pass, which +// `balanced` runs, ends the base on two tops so that it overhangs them; `quality`'s lookahead +// finds a layout with nothing overhanging. const aBaseAndThreeTops = (configuration = {}) => request( [{ id: 'base', dimensions: mm(60, 60, 40) }, { id: 'top', quantity: 3, dimensions: mm(40, 60, 50) }], [box('crate', 100, 100, 100, { quantity: 1 })], { configuration: { - solver_profile: 'quality', solvers: ['homogeneous_blocks'], time_limit_ms: 60_000, - effort_budget: { max_search_nodes: 100_000 }, ...configuration, + solver_profile: 'balanced', solvers: ['homogeneous_blocks'], container_plan_node_limit: 100_000, + time_limit_ms: 60_000, effort_budget: { max_search_nodes: 100_000 }, ...configuration, } }, ); +test('the quality lookahead finds a layout with nothing overhanging', () => { + const greedy = packFallback(aBaseAndThreeTops()); + const payload = aBaseAndThreeTops({ solver_profile: 'quality' }); + const result = packFallback(payload); + const all = placements(result); + assert.equal(all.length, 4); + assert.ok(all.every((placement) => restingShare(all, placement) === 1)); + assert.notDeepEqual(result.score, greedy.score); + assert.deepEqual(validate(payload, result), []); +}); + // The share of a placement's base on the floor or on a top face, from geometry alone. function restingShare(all, placement) { const ticks = (p) => ({ @@ -3015,3 +3118,63 @@ test('stacking groups: rebalancing never moves an item onto a lower item that re const withBase = balanced.containers.find((c) => c.placements.some((p) => p.item_type === 'base')); assert.deepEqual(withBase.placements.map((p) => p.item_type), ['base']); }); + +// -------------------------------------------------------------------------------- + +test('every support ratio crosses into ppm by one rule (S11)', () => { + const { scaledRatio, supportPpm } = __ratiosForTests; + // floor(r * 1e6 + 0.5), Python's `scaled_ratio`, for support, reserve and compression alike. + assert.equal(scaledRatio(0.5), 500_000); + assert.equal(scaledRatio(0.0000015), 2); + // A positive ratio never becomes "no support required", on any path. + assert.equal(supportPpm(0), 0); + assert.equal(supportPpm(0.0000001), 1); + assert.equal(supportPpm(0.75), 750_000); +}); + +test('a per-item support ratio below one ppm demands contact, as the global one does (S11)', () => { + const payload = request([ + { id: 'i0', dimensions: mm(72, 37, 50), minimum_support_ratio: 0.0000001 }, + { id: 'i1', dimensions: mm(65, 42, 36), minimum_support_ratio: 0.0000001 }, + { id: 'i2', dimensions: mm(23, 89, 64), minimum_support_ratio: 0.0000001 }, + { id: 'i3', dimensions: mm(38, 85, 23), minimum_support_ratio: 0.0000001 }, + ], [box('box', 100, 100, 100, { quantity: 1 })], { + configuration: { solver_profile: 'balanced' }, + }); + const all = placements(packFallback(payload)); + assert.ok(all.every((placement) => placement.position.z.ticks === 0 || Number(placement.support_ratio) > 0)); +}); + +test('the default deadline reads a monotonic clock, and the clock stays injectable (S15)', () => { + const realNow = Date.now; + try { + // A wall clock stepped back an hour must not stretch the deadline. + let wall = realNow(); + Date.now = () => { wall -= 3_600_000; return wall; }; + const deadline = new Deadline(50); + assert.ok(deadline.elapsedMs() >= 0); + assert.ok(deadline.elapsedMs() < 3_600_000); + } finally { + Date.now = realNow; + } + let now = 1_000; + const driven = new Deadline(10, () => now); + now += 25; + assert.equal(driven.elapsedMs(), 25); +}); + +test('plan-beam dominance keys and their tie-break cannot be fooled by a separator in an id (S18)', () => { + const { planSignature, compareIdLists } = __planBeamForTests; + const plan = (ids, inventory) => ({ remaining: ids.map((id) => ({ id })), inventory: new Map(inventory) }); + // Joined with "|" these two remaining lists were the same key. + assert.notEqual(planSignature(plan(['a|b'], [['box', 1]])), planSignature(plan(['a', 'b'], [['box', 1]]))); + // Joined with ":" and "|" these two inventories were the same key. + assert.notEqual(planSignature(plan([], [['a:1|b', 2]])), planSignature(plan([], [['a', '1|b:2']]))); + // Insertion order of the inventory does not change the key. + assert.equal(planSignature(plan(['x'], [['a', 1], ['b', 2]])), planSignature(plan(['x'], [['b', 2], ['a', 1]]))); + // Python's tuple order: element by element, then the shorter first -- not the joined text. + assert.ok(compareIdLists(['a', 'c'], ['ab']) < 0); + assert.ok(compareIdLists(['box'], ['box-a']) < 0); + assert.ok(compareIdLists(['a'], ['a', 'b']) < 0); + assert.equal(compareIdLists(['a', 'b'], ['a', 'b']), 0); +}); diff --git a/test/fixed-placements.test.mjs b/test/fixed-placements.test.mjs index 355c7a0..f0d3618 100644 --- a/test/fixed-placements.test.mjs +++ b/test/fixed-placements.test.mjs @@ -224,8 +224,8 @@ const SHAPES = [ ['a missing key', [{ item_type: 'cube' }], 'fixed_placements[0] needs ["container_type","orientation"]', '/fixed_placements/0'], ['an empty item type', [{ ...entry(), item_type: '' }], 'fixed_placements[0].item_type is a non-empty string', '/fixed_placements/0/item_type'], ['a numeric container type', [{ ...entry(), container_type: 5 }], 'fixed_placements[0].container_type is a non-empty string', '/fixed_placements/0/container_type'], - ['an unknown orientation', [{ ...entry(), orientation: 'XYZ' }], 'fixed_placements[0].orientation is one of the six codes', '/fixed_placements/0/orientation'], - ['an orientation in a list', [{ ...entry(), orientation: ['LWH'] }], 'fixed_placements[0].orientation is one of the six codes', '/fixed_placements/0/orientation'], + ['an unknown orientation', [{ ...entry(), orientation: 'XYZ' }], 'fixed_placements[0].orientation must be one of ["LWH","LHW","WLH","WHL","HLW","HWL"]', '/fixed_placements/0/orientation'], + ['an orientation in a list', [{ ...entry(), orientation: ['LWH'] }], 'fixed_placements[0].orientation must be one of ["LWH","LHW","WLH","WHL","HLW","HWL"]', '/fixed_placements/0/orientation'], ['a text instance', [{ ...entry(), container_instance: '1' }], 'fixed_placements[0].container_instance counts from 1', '/fixed_placements/0/container_instance'], ['a boolean instance', [{ ...entry(), container_instance: true }], 'fixed_placements[0].container_instance counts from 1', '/fixed_placements/0/container_instance'], ['a fractional instance', [{ ...entry(), container_instance: 1.5 }], 'fixed_placements[0].container_instance counts from 1', '/fixed_placements/0/container_instance'], @@ -243,7 +243,7 @@ const SHAPES = [ 'fixed_placements[0].position.y is a measure', '/fixed_placements/0/position/y'], ['a negative coordinate', [{ ...entry(), position: { x: '100', z: '-1' } }], 'fixed_placements[0].position.z cannot be negative', '/fixed_placements/0/position/z'], - ['a later entry', [entry(), { ...entry(), orientation: 'lwh' }], 'fixed_placements[1].orientation is one of the six codes', '/fixed_placements/1/orientation'], + ['a later entry', [entry(), { ...entry(), orientation: 'lwh' }], 'fixed_placements[1].orientation must be one of ["LWH","LHW","WLH","WHL","HLW","HWL"]', '/fixed_placements/1/orientation'], ]; for (const [name, placements, detail, field] of SHAPES) { diff --git a/test/force-fallback.test.mjs b/test/force-fallback.test.mjs index 472f08e..034bf85 100644 --- a/test/force-fallback.test.mjs +++ b/test/force-fallback.test.mjs @@ -9,13 +9,14 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; +import { packageWithNativeStandIn } from './native-stand-in.mjs'; + const require = createRequire(import.meta.url); const { NATIVE_CANDIDATES } = require('./force-fallback.cjs'); @@ -25,29 +26,9 @@ const HOOK = path.join(HERE, 'force-fallback.cjs'); const PROBE = "import {backend} from './index.js'; process.stdout.write(backend());"; -/** - * A copy of the package with a resolvable `./packvium-native.node` beside it. The - * stand-in is plain JavaScript rather than a compiled addon because `index.js` only ever - * asks whether the module resolves and exposes `packJson` -- nothing about the block - * depends on it being real machine code, and a real one cannot be built inside a unit - * test. It is a *directory* of that name: a file ending in `.node` is handed to - * `dlopen`, while a directory resolves through its `package.json` like any package. - */ +/** A copy of the package with a fake but resolvable addon of this release beside it. */ function packageWithAFakeNativeBackend() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'packvium-forced-fallback-')); - // Every module the package ships, taken from the manifest rather than listed again - // here: a hand-written copy silently stops being a copy of the package the day a - // module is added, and the probe fails to resolve an import for reasons nothing in - // the diff explains. - const shipped = JSON.parse(fs.readFileSync(path.join(PACKAGE, 'package.json'), 'utf8')).files; - for (const name of shipped.filter((file) => file.endsWith('.js'))) { - fs.copyFileSync(path.join(PACKAGE, name), path.join(root, name)); - } - fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ type: 'module' })); - const fake = path.join(root, 'packvium-native.node'); - fs.mkdirSync(fake); - fs.writeFileSync(path.join(fake, 'package.json'), JSON.stringify({ main: 'index.cjs' })); - fs.writeFileSync(path.join(fake, 'index.cjs'), 'module.exports={packJson:(s)=>s};'); + const root = packageWithNativeStandIn('module.exports={packJson:(s)=>s,version:()=>SUITE_VERSION};'); fs.writeFileSync(path.join(root, 'probe.mjs'), PROBE); return root; } diff --git a/test/limit-reasons.test.mjs b/test/limit-reasons.test.mjs index 52f0bbb..9f30ea4 100644 --- a/test/limit-reasons.test.mjs +++ b/test/limit-reasons.test.mjs @@ -76,3 +76,10 @@ test('a single restart packs worse than the full portfolio', () => { assert.equal(unpacked(1), 4); assert.equal(unpacked(null), 3); }); + +test('an item no container tags admit is proven unplaceable', () => { + const result = solve([cube('cold-item', 1, '10', { eligible_container_tags: ['cold'] }), cube('dry-item', 1)], + [box('20', '20', '20', { tags: ['dry'] })]); + assert.deepEqual(reasons(result), [['no_eligible_container', 'proven']]); + assert.equal(result.summary.packed_item_count, 1); +}); diff --git a/test/multi-start-orders.test.mjs b/test/multi-start-orders.test.mjs index 6166341..2e834fb 100644 --- a/test/multi-start-orders.test.mjs +++ b/test/multi-start-orders.test.mjs @@ -37,12 +37,12 @@ test('the effort budget is shared by every start', () => { [[true, true], ...Array.from({ length: 7 }, () => [true, true])]); }); -test('a shuffled ordering keeps priority ahead of size', () => { - // The box holds one of the two. Every one of the eight orderings must offer it to the - // high-priority item first, or a shuffle that leads with the big one fills the box. +test('start 0 leads with priority; shuffled starts are free shuffles', () => { + // Start 0 respects priority. Later starts re-solve a plain shuffle, allowing the portfolio + // to try low-priority items first (JS gap 2 revert). const items = [cube('big', 100), cube('small', 10, { priority: 5 })]; - for (let start = 1; start < 8; start++) { - const result = packFallback(scene({}, items), Date.now, null, start); - assert.deepEqual(result.containers.flatMap((c) => c.placements.map((p) => p.item_id)), ['small#1'], `start ${start}`); - } + const start0 = packFallback(scene({}, items), Date.now, null, 0); + assert.deepEqual(start0.containers.flatMap((c) => c.placements.map((p) => p.item_id)), ['small#1']); + const start4 = packFallback(scene({}, items), Date.now, null, 4); + assert.deepEqual(start4.containers.flatMap((c) => c.placements.map((p) => p.item_id)), ['big#1']); }); diff --git a/test/native-binding.test.mjs b/test/native-binding.test.mjs new file mode 100644 index 0000000..ab7454f --- /dev/null +++ b/test/native-binding.test.mjs @@ -0,0 +1,177 @@ +/** + * The native path of `index.js` against the fallback's contract: an addon's + * refusals arrive as the fallback's error classes, `maxMoves` is bounded by the addon's + * `u32`, and an addon from another release is not used. + * + * The first part drives `packJsonWith` / `rebalanceWeightWith` with stand-in bindings that + * raise what the compiled addon raises -- a plain `Error` whose `code`, `reason`, `field` and + * `detail` are properties -- so it runs everywhere. The last test loads a real compiled addon + * when `PACKVIUM_NODE_ADDON` names one (`cargo build --release -p packvium-node-native` + * output); no build is part of this suite, so without it that test is skipped. + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { execFileSync } from 'node:child_process'; + +import { + FixedPlacementError, + InvalidRequestError, + UnsupportedFeatureError, + packJsonWith, + rebalanceWeightWith, +} from '../index.js'; +import { packFallback } from '../fallback.js'; +import { PACKAGE_VERSION, packageWithNativeAddon, packageWithNativeStandIn } from './native-stand-in.mjs'; + +const REQUEST = JSON.stringify({ + items: [{ id: 'cube', quantity: 1, dimensions: { length: '1', width: '1', height: '1' } }], + containers: [{ id: 'box', inner_dimensions: { length: '2', width: '2', height: '2' } }], +}); + +/** What the compiled addon throws: a plain `Error` with the core's refusal as properties. */ +function nativeRefusal(message, properties) { + return Object.assign(new Error(message), properties); +} + +const raising = (error) => ({ packJson: () => { throw error; } }); + +test('a native invalid_request arrives as the fallback\'s InvalidRequestError', () => { + const error = nativeRefusal('invalid_request: /items/0/quantity: must be at least 1', { + code: 'invalid_request', reason: 'below_minimum', field: '/items/0/quantity', detail: 'must be at least 1', + }); + assert.throws(() => packJsonWith(raising(error), REQUEST), (thrown) => thrown instanceof InvalidRequestError + && thrown.name === 'InvalidRequestError' + && thrown.message === error.message + && [thrown.code, thrown.reason, thrown.field, thrown.detail].join('|') + === 'invalid_request|below_minimum|/items/0/quantity|must be at least 1'); +}); + +test('a native invalid_fixed_placement arrives as the fallback\'s FixedPlacementError', () => { + const error = nativeRefusal('invalid_fixed_placement: collision: cube#1 with cube#2', { + code: 'invalid_fixed_placement', reason: 'cannot_hold', field: '/fixed_placements', + detail: 'collision: cube#1 with cube#2', + }); + assert.throws(() => packJsonWith(raising(error), REQUEST), (thrown) => thrown instanceof FixedPlacementError + && thrown instanceof InvalidRequestError + && thrown.message === error.message + && [thrown.reason, thrown.field, thrown.detail].join('|') + === 'cannot_hold|/fixed_placements|collision: cube#1 with cube#2'); +}); + +test('a native unsupported_feature arrives as UnsupportedFeatureError, fields and core sentence kept', () => { + const message = 'unsupported_feature: Rust core does not yet implement container.a, item.b; ' + + 'the request was rejected instead of silently ignoring public fields'; + const error = nativeRefusal(message, { code: 'unsupported_feature' }); + assert.throws(() => packJsonWith(raising(error), REQUEST), (thrown) => thrown instanceof UnsupportedFeatureError + && thrown.code === 'unsupported_feature' + && thrown.message === message + && thrown.fields.join() === 'container.a,item.b'); +}); + +test('a native error with any other code is re-thrown as raised', () => { + const error = nativeRefusal('time limit reached', { code: 'time_limit' }); + assert.throws(() => packJsonWith(raising(error), REQUEST), (thrown) => thrown === error); +}); + +test('rebalanceWeight refuses a maxMoves the addon\'s u32 cannot hold, on either backend', () => { + const calls = []; + const binding = { rebalanceJson: (request, result, maxMoves) => { calls.push(maxMoves); return '{}'; } }; + for (const backend of [binding, null]) { + for (const maxMoves of [2 ** 32, -1, 1.5]) { + assert.throws(() => rebalanceWeightWith(backend, {}, {}, { maxMoves }), + { name: 'RangeError', message: 'maxMoves must be an integer from 0 to 4294967295' }); + } + } + assert.deepEqual(rebalanceWeightWith(binding, {}, {}, { maxMoves: 0xffffffff }), {}); + assert.deepEqual(calls, [0xffffffff], 'the largest u32 still reaches the addon, nothing above it does'); +}); + +test('a native rebalance refusal arrives as the fallback\'s class', () => { + const error = nativeRefusal('invalid_request: /items: must be a list', { + code: 'invalid_request', reason: 'wrong_type', field: '/items', detail: 'must be a list', + }); + const binding = { rebalanceJson: () => { throw error; } }; + assert.throws(() => rebalanceWeightWith(binding, {}, {}), InvalidRequestError); +}); + +const BACKEND_PROBE = "import {backend, version} from './index.js'; process.stdout.write(backend() + ' ' + version());"; + +function probe(root, source) { + fs.writeFileSync(path.join(root, 'probe.mjs'), source); + return execFileSync(process.execPath, [path.join(root, 'probe.mjs')], { cwd: root, encoding: 'utf8' }); +} + +function withPackage(root, body) { + try { + return body(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test('an addon from another release is not used, exactly as if it were absent', () => { + const addon = 'module.exports={packJson:(s)=>s,version:()=>SUITE_VERSION};'; + withPackage(packageWithNativeStandIn(addon), (root) => { + assert.equal(probe(root, BACKEND_PROBE), `rust ${PACKAGE_VERSION}`, + 'an addon of this release must be used, or the refusal below proves nothing'); + }); + withPackage(packageWithNativeStandIn(addon, '0.1.3'), (root) => { + assert.equal(probe(root, BACKEND_PROBE), `javascript ${PACKAGE_VERSION}-js-fallback`); + }); + withPackage(packageWithNativeStandIn('module.exports={packJson:(s)=>s};'), (root) => { + assert.equal(probe(root, BACKEND_PROBE), `javascript ${PACKAGE_VERSION}-js-fallback`, + 'an addon that cannot say which release it is cannot be trusted to be this one'); + }); +}); + +/** Requests each engine refuses, chosen so the refusal comes from the native core itself. */ +const SIDE = { length: '100', width: '100', height: '100' }; +const fixed = (x) => ({ item_type: 'cube', container_type: 'box', container_instance: 1, + position: { x, y: '0', z: '0' }, orientation: 'LWH' }); +const REFUSED = { + collision: { items: [{ id: 'cube', quantity: 2, dimensions: SIDE }], + containers: [{ id: 'box', inner_dimensions: { length: '200', width: '100', height: '100' } }], + fixed_placements: [fixed('0'), fixed('0')] }, + 'unknown item': { items: [{ id: 'cube', quantity: 1, dimensions: SIDE }], + containers: [{ id: 'box', inner_dimensions: SIDE }], + fixed_placements: [{ ...fixed('0'), item_type: 'crate' }] }, + unsupported: { items: [{ id: 'cube', quantity: 1, dimensions: SIDE }], + containers: [{ id: 'box', inner_dimensions: SIDE, pallet_overhang_limit: '1' }] }, +}; + +const describe = (error) => ({ name: error.name, code: error.code, reason: error.reason, + field: error.field, detail: error.detail, fields: error.fields }); + +function fallbackRefusal(request) { + try { + packFallback(structuredClone(request)); + } catch (error) { + return describe(error); + } + return null; +} + +const ADDON = process.env.PACKVIUM_NODE_ADDON; + +test('the compiled addon refuses with the fallback\'s classes and fields', { skip: !ADDON && 'PACKVIUM_NODE_ADDON is not set' }, () => { + withPackage(packageWithNativeAddon(ADDON), (root) => { + const source = `import {backend, pack} from './index.js'; + const requests = ${JSON.stringify(REFUSED)}; + const out = {backend: backend()}; + for (const [name, request] of Object.entries(requests)) { + try { pack(request); out[name] = null; } + catch (e) { out[name] = {name: e.name, code: e.code, reason: e.reason, field: e.field, detail: e.detail, fields: e.fields}; } + } + process.stdout.write(JSON.stringify(out));`; + const native = JSON.parse(probe(root, source)); + assert.equal(native.backend, 'rust', 'the addon was not loaded; the comparison below would be fallback against itself'); + for (const [name, request] of Object.entries(REFUSED)) { + const expected = fallbackRefusal(request); + assert.ok(expected, `${name}: the fallback accepted the request`); + assert.deepEqual(native[name], JSON.parse(JSON.stringify(expected)), name); + } + }); +}); diff --git a/test/native-stand-in.mjs b/test/native-stand-in.mjs new file mode 100644 index 0000000..caf1013 --- /dev/null +++ b/test/native-stand-in.mjs @@ -0,0 +1,52 @@ +/** + * Copies of the package with something resolvable at `./packvium-native.node`, for tests + * that must watch `index.js` decide at load time whether to use an addon. + * + * Shared by `force-fallback.test.mjs` and `native-binding.test.mjs`: a copy is built from + * the manifest's `files` rather than a hand-written list, because a hand-written copy + * silently stops being a copy of the package the day a module is added. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PACKAGE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const MANIFEST = JSON.parse(fs.readFileSync(path.join(PACKAGE, 'package.json'), 'utf8')); + +/** The version a stand-in reports unless told otherwise: the one this package ships at. */ +export const PACKAGE_VERSION = MANIFEST.version; + +function packageCopy() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'packvium-native-stand-in-')); + for (const name of MANIFEST.files.filter((file) => file.endsWith('.js'))) { + fs.copyFileSync(path.join(PACKAGE, name), path.join(root, name)); + } + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ type: 'module' })); + return root; +} + +/** + * A copy whose addon is plain JavaScript, with `SUITE_VERSION` bound to `version` for its + * source to report. It is a *directory* named `packvium-native.node`: a file ending in + * `.node` is handed to `dlopen`, while a directory resolves through its `package.json` like + * any package -- and `index.js` asks nothing of the addon that machine code would answer + * differently. + */ +export function packageWithNativeStandIn(moduleSource, version = PACKAGE_VERSION) { + const root = packageCopy(); + const addon = path.join(root, 'packvium-native.node'); + fs.mkdirSync(addon); + fs.writeFileSync(path.join(addon, 'package.json'), JSON.stringify({ main: 'index.cjs' })); + fs.writeFileSync(path.join(addon, 'index.cjs'), + `const SUITE_VERSION = ${JSON.stringify(version)};\n${moduleSource}\n`); + return root; +} + +/** A copy with a real compiled addon (a built `cdylib`) beside it. */ +export function packageWithNativeAddon(addonPath) { + const root = packageCopy(); + fs.copyFileSync(addonPath, path.join(root, 'packvium-native.node')); + return root; +} diff --git a/test/package-exports.test.mjs b/test/package-exports.test.mjs index 7651b43..2be86a0 100644 --- a/test/package-exports.test.mjs +++ b/test/package-exports.test.mjs @@ -57,6 +57,14 @@ test('plan revisions resolve by package name', async () => { assert.equal(revisions.FORMAT, 'packvium-plan-revision/v1'); }); +test('visualization scenes resolve by package name', async () => { + const scene = await import(`${manifest.name}/scene.js`); + for (const name of ['resultToScenes', 'artifactToScene']) { + assert.equal(typeof scene[name], 'function', name); + } + assert.equal(scene.SCENE_FORMAT, 'packvium-visualization-scene/v1'); +}); + test('a module an export imports is published even when it is not exported itself', () => { // `canonical-json.js` is internal: absent from `exports` on purpose, but `artifacts.js` and // `execution.js` import it, so a tarball without it would break both at load time. diff --git a/test/request-errors.test.mjs b/test/request-errors.test.mjs index cb1c161..9f4b1f2 100644 --- a/test/request-errors.test.mjs +++ b/test/request-errors.test.mjs @@ -179,8 +179,8 @@ test('an unknown objective or access direction is refused with not_allowed and i }); test('an error the rule table does not name still reaches the caller as a request error', () => { - assertRefusedWith((r) => { r.items[0].nesting_height = '100'; }, - "invalid_request: nesting_height must be at least zero and strictly less than the item's own height"); + assertRefusedWith((r) => { r.containers[0].rate_table = { weight_brackets_g: [5000, 1000], prices_minor: [500, 900] }; }, + 'invalid_request: rate_table weight_brackets_g must be strictly ascending'); }); function assertRefusedWith(edit, message) { @@ -222,3 +222,62 @@ test('the native backend gets the same InvalidRequestError, and only well-formed assert.equal(root.packJsonWith(binding, JSON.stringify(base())), '{"native":true}'); assert.equal(calls.length, 1); }); + +test('a value outside any schema enum is not_allowed at its own pointer', () => { + const rotations = '["LWH","LHW","WLH","WHL","HLW","HWL"]'; + const lengths = '["mm","cm","m","in","ft"]', weights = '["mg","g","kg","oz","lb"]'; + const cases = [ + [(r) => { r.items[0].allowed_rotations = ['LWH', 'XYZ']; }, '/items/0/allowed_rotations/1', rotations], + [(r) => { r.items[0].ground_contact_rule = 'floating'; }, '/items/0/ground_contact_rule', + '["free","covered","single","multiple"]'], + [(r) => { r.items[0].shape_type = 'sphere'; }, '/items/0/shape_type', '["rigid_cuboid","convex_hull","compressible"]'], + [(r) => { r.configuration.solvers = ['grid', 'simplex']; }, '/configuration/solvers/1', + '["grid","extreme_points","homogeneous_blocks","layer","maximal_spaces","exact_small"]'], + [(r) => { r.configuration.dimensional_weight_length_unit = 'furlong'; }, + '/configuration/dimensional_weight_length_unit', lengths], + [(r) => { r.configuration.dimensional_weight_weight_unit = 'stone'; }, + '/configuration/dimensional_weight_weight_unit', weights], + [(r) => { r.output = { length_unit: 'furlong' }; }, '/output/length_unit', lengths], + [(r) => { r.output = { weight_unit: 'stone' }; }, '/output/weight_unit', weights], + ]; + for (const [edit, field, allowed] of cases) assertRefused(edit, 'not_allowed', field, `must be one of ${allowed}`); + assertRefused((r) => { r.items[0].allowed_rotations = 'LWH'; }, 'wrong_type', '/items/0/allowed_rotations', 'must be a list'); + assertRefused((r) => { r.output = 'mm'; }, 'wrong_type', '/output', 'must be an object'); +}); + +test('a fixed placement orientation outside the six codes names them as a requirement', () => { + const error = refusalOf((r) => { + r.fixed_placements = [{ item_type: 'cube', container_type: 'box', orientation: 'XYZ' }]; + }); + assert.ok(error instanceof FixedPlacementError, `${error.name}: ${error.message}`); + assert.deepEqual([error.reason, error.field, error.message], ['malformed', '/fixed_placements/0/orientation', + 'invalid_fixed_placement: fixed_placements[0].orientation must be one of ["LWH","LHW","WLH","WHL","HLW","HWL"]']); +}); + +test('a nesting height not below the item height is refused at its pointer', () => { + for (const nesting of ['100', { value: '11', unit: 'cm' }]) { + assertRefused((r) => { r.items[0].nesting_height = nesting; }, 'invalid_value', '/items/0/nesting_height', + "must be less than the item's height"); + } +}); + +test('a unit code is matched ignoring surrounding space at every measure, as in Python', () => { + const request = base(); + request.units = { length: ' in ' }; + request.items[0].weight = { value: '1', unit: ' KG ' }; + request.items[0].dimensions.length = { value: '1', unit: ' ft ' }; + const result = packFallback(request); + assert.equal(result.containers[0].inner_dimensions.length.unit, 'in'); + assert.equal(result.containers[0].inner_dimensions.length.ticks, 200 * 406_400); + assert.equal(result.containers[0].payload_weight.value, '2000'); + const { length, width, height } = result.containers[0].placements[0].dimensions; + assert.ok([length, width, height].some((measure) => measure.ticks === 12 * 406_400)); +}); + +test('without an output block the result is in the request unit spelt as its code', () => { + for (const [unit, code] of [['cm', 'cm'], ['MM', 'mm'], ['IN', 'in'], ['Inches', 'in'], ['millimeter', 'mm'], ['tick', 'mm']]) { + const request = base(); + request.units = { length: unit }; + assert.equal(packFallback(request).containers[0].inner_dimensions.length.unit, code, unit); + } +}); diff --git a/test/result-validation.test.mjs b/test/result-validation.test.mjs new file mode 100644 index 0000000..4303c2a --- /dev/null +++ b/test/result-validation.test.mjs @@ -0,0 +1,135 @@ +/** + * `validate(request, result)`: the reference validator, through the compiled addon. + * + * The addon returns one JSON document, `{issues}` or `{refusal: {kind, message}}`, so a + * refusal's `kind` survives the boundary; `validateWith` turns that into the package's own + * `MalformedResult`. Issue-for-issue agreement with `packvium.validate` over the whole + * conformance corpus is `conformance/result_validation/run.py`'s job; this pins the wrapper. + */ + +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +import { + MalformedResult, + ValidatorUnavailableError, + backend, + pack, + validate, + validateWith, +} from '@packvium/engine'; + +const REQUEST = { + items: [{ id: 'cube', quantity: 2, dimensions: { length: '10', width: '10', height: '10' } }], + containers: [{ id: 'box', inner_dimensions: { length: '20', width: '10', height: '10' } }], +}; + +const answering = (outcome) => ({ validateJson: () => JSON.stringify(outcome) }); + +test('the addon\'s issues are returned as they came', () => { + const issues = [{ code: 'collision', detail: 'cube#1 with cube#2 in box#1' }]; + assert.deepEqual(validateWith(answering({ issues }), REQUEST, {}), issues); +}); + +test('a refusal becomes a MalformedResult carrying its kind', () => { + const binding = answering({ refusal: { kind: 'contract', message: "result is missing 'status'" } }); + assert.throws(() => validateWith(binding, REQUEST, {}), (error) => { + assert.ok(error instanceof MalformedResult); + assert.equal(error.message, "result is missing 'status'"); + assert.equal(error.kind, 'contract'); + return true; + }); +}); + +test('without the addon, validate says so rather than answering', () => { + assert.throws(() => validateWith(null, REQUEST, {}), ValidatorUnavailableError); + if (backend() === 'javascript') { + assert.throws(() => validate(REQUEST, pack(REQUEST)), (error) => error.code === 'validator_unavailable'); + } +}); + +test('the real addon judges a packed result valid and a broken one not', (t) => { + const require = createRequire(import.meta.url); + let native = null; + // The same specifiers `force-fallback.cjs` blocks, so a run pinned to the fallback never + // loads the addon here. + for (const candidate of ['./packvium-native.node', '../../packvium-rust/bindings/node']) { + try { native = require(candidate); break; } catch { /* not built or blocked */ } + } + if (!native?.validateJson) { + t.skip('no native addon exporting validateJson is loadable here'); + return; + } + const result = pack(REQUEST); + assert.deepEqual(validateWith(native, REQUEST, result), []); + const broken = structuredClone(result); + const placements = broken.containers[0].placements; + placements[1].position = structuredClone(placements[0].position); + assert.deepEqual(validateWith(native, REQUEST, broken).map((issue) => issue.code), ['collision']); +}); + +// (audit F5): the void-fill reserve is `floor(volume * ppm / 10^6)` with the solvers' +// double conversion of the ratio. `0.0001245` is 124 ppm there where the exact decimal rounds +// to 125, so a 10^7 cubic tick container reserves 1240 ticks, not 1250. +test('the real addon judges the void-fill reserve by the solvers\' ppm rule', (t) => { + const require = createRequire(import.meta.url); + let native = null; + for (const candidate of ['./packvium-native.node', '../../packvium-rust/bindings/node']) { + try { native = require(candidate); break; } catch { /* not built or blocked */ } + } + if (!native?.validateJson) { + t.skip('no native addon exporting validateJson is loadable here'); + return; + } + const request = { + units: { length: 'tick' }, + items: [{ id: 'bar', quantity: 1, allowed_rotations: ['LWH'], dimensions: { length: '1', width: '1', height: '1' } }], + containers: [{ id: 'box', void_fill_reserve_ratio: 0.0001245, + inner_dimensions: { length: '1', width: '10000000', height: '1' } }], + }; + const result = pack(request); + assert.equal(result.containers[0].void_fill_reserve_ticks3, '1240'); + assert.deepEqual(validateWith(native, request, result), []); + const widened = (width) => { + const edited = structuredClone(result); + const container = edited.containers[0]; + container.placements[0].dimensions.width = { ticks: width, value: String(width), unit: 'tick' }; + container.used_volume_ticks3 = String(width); + const widenedRequest = structuredClone(request); + widenedRequest.items[0].dimensions.width = String(width); + return validateWith(native, widenedRequest, edited) + .filter((issue) => issue.code.startsWith('void_fill_reserve')) + .map((issue) => `${issue.code}: ${issue.detail}`); + }; + assert.deepEqual(widened(9_998_760), []); + assert.deepEqual(widened(9_998_761), ['void_fill_reserve_exceeded: box#1: used 9998761 > usable 9998760']); +}); + +// `compression_ratio` goes through the same conversion. A 5 kg brick holds a 1 m +// compressible pad at half its 10 kPa limit, where one ppm moves the occupied height by about +// eight ticks, so 0.0001245 must compress exactly as 0.000124 does. +test('the real addon judges compression by the solvers\' ppm rule', (t) => { + const require = createRequire(import.meta.url); + let native = null; + for (const candidate of ['./packvium-native.node', '../../packvium-rust/bindings/node']) { + try { native = require(candidate); break; } catch { /* not built or blocked */ } + } + if (!native?.validateJson) { + t.skip('no native addon exporting validateJson is loadable here'); + return; + } + const request = (ratio) => ({ + items: [ + { id: 'pad', quantity: 1, must_be_on_floor: true, allowed_rotations: ['LWH'], + dimensions: { length: '100', width: '100', height: '1000' }, + shape_type: 'compressible', compression_ratio: ratio, max_compression_pressure_kpa: 10 }, + { id: 'brick', quantity: 1, allowed_rotations: ['LWH'], + dimensions: { length: '100', width: '100', height: '10' }, weight: { value: '5', unit: 'kg' } }, + ], + containers: [{ id: 'box', inner_dimensions: { length: '100', width: '100', height: '2000' } }], + }); + const result = pack(request(0.0001245)); + assert.equal(result.containers[0].used_volume_ticks3, pack(request(0.000124)).containers[0].used_volume_ticks3); + assert.deepEqual(validateWith(native, request(0.0001245), result), []); +}); diff --git a/test/result-writer.test.mjs b/test/result-writer.test.mjs index c17d53c..d705d6e 100644 --- a/test/result-writer.test.mjs +++ b/test/result-writer.test.mjs @@ -100,3 +100,37 @@ test('usedVolume over nesting placements alone equals the every-pair scan', () = assert.equal(usedVolume(placements), everyPairVolume(placements)); } }); + +// -------------------------------------------------------------------------------- + +test('six-place ratios round an exact half to even, from the exact fraction (S13)', async () => { + const { __ratiosForTests: { sixPlaces, doubleFraction } } = await import('../fallback.js'); + assert.equal(sixPlaces(1, 128), '0.007812'); // 0.0078125: toFixed gives 0.007813 + assert.equal(sixPlaces(3, 128), '0.023438'); // 0.0234375: the half rounds up to even + assert.equal(sixPlaces(1, 1), '1.000000'); + assert.equal(sixPlaces(0, 7), '0.000000'); + assert.equal(sixPlaces(2, 3), '0.666667'); + // Past 2^53 a Number division is already wrong in the sixth place; the fraction is not. + const huge = 2n ** 60n; + assert.equal(sixPlaces(huge - 1n, huge), '1.000000'); + assert.equal(sixPlaces(huge / 3n, huge), '0.333333'); + const equals = ([numerator, denominator], [n, d]) => numerator * d === n * denominator; + assert.ok(equals(doubleFraction(0.0078125), [1n, 128n])); + assert.ok(equals(doubleFraction(3), [3n, 1n])); + assert.ok(equals(doubleFraction(0), [0n, 1n])); + assert.equal(sixPlaces(...doubleFraction(0.0078125)), '0.007812'); + assert.equal(sixPlaces(...doubleFraction(0.1)), (0.1).toFixed(6)); + assert.ok(equals(doubleFraction(Number.MIN_VALUE), [1n, 2n ** 1074n])); +}); + +test('support_ratio and volume_utilization are written half to even (S13)', async () => { + const { packFallback } = await import('../fallback.js'); + const mm = (side) => ({ length: side, width: side, height: side }); + // 1 / 128 of the box: a utilization of exactly 0.0078125. + const result = packFallback({ + units: { length: 'mm' }, configuration: { solver_profile: 'fast' }, + items: [{ id: 'cube', dimensions: mm('10') }], + containers: [{ id: 'box', quantity: 1, inner_dimensions: { length: '80', width: '40', height: '40' } }], + }); + assert.equal(result.containers[0].volume_utilization, '0.007812'); +}); diff --git a/test/revisions.test.mjs b/test/revisions.test.mjs index ea9f077..2c2c9e1 100644 --- a/test/revisions.test.mjs +++ b/test/revisions.test.mjs @@ -202,7 +202,7 @@ const MALFORMED_EVENTS = [ [[{ sequence: 1, type: 'item_missing', item_type: 'cube', quantity: 0 }], 'item_missing.quantity is a positive integer'], [[{ sequence: true, type: 'item_missing', item_type: 'cube', quantity: 1 }], 'event sequence true does not continue the chain at 1'], [[{ sequence: 1, type: 'placement_locked', placement: { ...locked().placement, orientation: 'XYZ' } }], - 'placement.orientation is one of the six codes'], + 'placement.orientation must be one of ["LWH","LHW","WLH","WHL","HLW","HWL"]'], [[{ sequence: 1, type: 'placement_locked', placement: { ...locked().placement, container_instance: 0 } }], 'placement.container_instance counts from 1'], [['item_missing'], 'an event is a JSON object'], diff --git a/test/scene.test.mjs b/test/scene.test.mjs new file mode 100644 index 0000000..5d22907 --- /dev/null +++ b/test/scene.test.mjs @@ -0,0 +1,141 @@ +/** + * Visualization scenes at the unit level: exact millimetre spelling, item ids, the + * container range, the loading sequence and the refusal of a malformed artifact. + * visualizer/spec/tests/test_engine_scenes.py holds every golden result to scene.schema.json; + * these pin the arithmetic the visualizer used to own. + */ + +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { pack } from '@packvium/engine'; +import { buildOperationalArtifact } from '@packvium/engine/artifacts.js'; +import { artifactToScene, resultToScenes, sceneItemId } from '@packvium/engine/scene.js'; + +const REFERENCE = { item_type: 'a', orientation: 'HLW', position_ticks: { x: 0, y: 1760000, z: 0 } }; + +/** One container whose inner length is `ticks`, holding one placement and no loading order. */ +function artifactWithLength(ticks) { + const cube = { length: '16000', width: '16000', height: '16000' }; + return { + geometry: { + containers: [{ inner_dimensions: { ...cube, length: ticks }, placements: [{ placement: REFERENCE, dimensions: cube }] }], + }, + plan: { containers: [{ order: 'unavailable', steps: [{ placement: REFERENCE }] }] }, + work_order: { length_unit: 'mm' }, + }; +} + +const EXACT = [ + ['0', '0'], + ['16000', '1'], + ['1760000', '110'], + ['1600000', '100'], + ['8000', '0.5'], + ['1600', '0.1'], + ['1', '0.0000625'], + ['-24000', '-1.5'], + ['-1', '-0.0000625'], + ['123456789012345678901234', '7716049313271604931.327125'], + // 2^64 + 1: a float would round this long before the last digit. + ['18446744073709551617', '1152921504606846.9760625'], +]; + +for (const [ticks, mm] of EXACT) { + test(`${ticks} ticks are exactly ${mm} mm`, () => { + assert.deepEqual(artifactToScene(artifactWithLength(ticks), 0).container.dimensions.length, { ticks, mm }); + }); +} + +test('an item id is spelled from the placement reference', () => { + assert.equal(sceneItemId(REFERENCE), 'a@0,1760000,0/HLW'); + assert.deepEqual(artifactToScene(artifactWithLength('16000'), 0).items.map((item) => item.id), ['a@0,1760000,0/HLW']); +}); + +test('an unavailable order yields no sequence', () => { + const scene = artifactToScene(artifactWithLength('16000'), 0); + assert.equal('sequence' in scene, false); + assert.equal('sequence_kind' in scene, false); +}); + +for (const containerIndex of [1, -1, 0.5]) { + test(`container ${containerIndex} is a RangeError`, () => { + assert.throws(() => artifactToScene(artifactWithLength('16000'), containerIndex), RangeError); + }); +} + +/** The one-container artifact with a loading order of one step numbered `sequence`. */ +function loadingArtifact(step) { + const artifact = artifactWithLength('16000'); + return { ...artifact, plan: { containers: [{ order: 'loading', steps: [step] }] } }; +} + +test('a loading order replays the plan’s own step numbers', () => { + const scene = artifactToScene(loadingArtifact({ sequence: 1, placement: REFERENCE }), 0); + assert.equal(scene.sequence_kind, 'loading'); + assert.deepEqual(scene.sequence, [{ step: 1, item_id: 'a@0,1760000,0/HLW' }]); +}); + +// scene.schema.json requires an integer `step`; numbering it here from array position would +// let the picture disagree with the printed work order, so a step without one is refused. +for (const sequence of [undefined, 0, 1.5, '1']) { + test(`a loading step whose sequence is ${JSON.stringify(sequence)} is a RangeError`, () => { + assert.throws( + () => artifactToScene(loadingArtifact({ sequence, placement: REFERENCE }), 0), + { name: 'RangeError', message: 'artifact.plan.containers[0].steps[0].sequence must be a positive integer when the order is loading' }, + ); + }); +} + +const MALFORMED = [ + ['no artifact', () => null, 'artifact must be an object'], + ['no geometry', (a) => ({ ...a, geometry: undefined }), 'artifact.geometry must be an object'], + ['geometry containers not a list', (a) => ({ ...a, geometry: { containers: {} } }), 'artifact.geometry.containers must be a list'], + ['no plan', (a) => ({ ...a, plan: undefined }), 'artifact.plan must be an object'], + ['plan containers not a list', (a) => ({ ...a, plan: {} }), 'artifact.plan.containers must be a list'], + ['no work order', (a) => ({ ...a, work_order: undefined }), 'artifact.work_order must be an object'], + ['a numeric length unit', (a) => ({ ...a, work_order: { length_unit: 1 } }), 'artifact.work_order.length_unit must be a string or null'], + ['an unknown order', (a) => ({ ...a, plan: { containers: [{ order: 'guessed', steps: [] }] } }), + 'artifact.plan.containers[0].order must be loading or unavailable'], + ['steps not a list', (a) => ({ ...a, plan: { containers: [{ order: 'unavailable' }] } }), + 'artifact.plan.containers[0].steps must be a list'], + ['a container that is not an object', (a) => ({ ...a, geometry: { containers: [null] } }), + 'artifact.geometry.containers[0] must be an object'], + ['no inner dimensions', (a) => ({ ...a, geometry: { containers: [{ placements: [] }] } }), + 'artifact.geometry.containers[0].inner_dimensions must be an object'], + ['a numeric tick string', (a) => ({ ...a, geometry: { containers: [{ ...a.geometry.containers[0], inner_dimensions: { length: 16000, width: '1', height: '1' } }] } }), + 'artifact.geometry.containers[0].inner_dimensions.length must be a tick count spelled as decimal digits'], + ['placements not a list', (a) => ({ ...a, geometry: { containers: [{ ...a.geometry.containers[0], placements: null }] } }), + 'artifact.geometry.containers[0].placements must be a list'], + ['a placement with no reference', (a) => ({ ...a, geometry: { containers: [{ ...a.geometry.containers[0], placements: [{ dimensions: a.geometry.containers[0].placements[0].dimensions }] }] } }), + 'artifact.geometry.containers[0].placements[0].placement must be an object'], + ['an unknown orientation', (a) => ({ ...a, geometry: { containers: [{ ...a.geometry.containers[0], placements: [{ ...a.geometry.containers[0].placements[0], placement: { ...REFERENCE, orientation: 'XYZ' } }] }] } }), + 'artifact.geometry.containers[0].placements[0].placement.orientation must be one of LWH, LHW, WLH, WHL, HLW, HWL'], + ['a fractional position', (a) => ({ ...a, geometry: { containers: [{ ...a.geometry.containers[0], placements: [{ ...a.geometry.containers[0].placements[0], placement: { ...REFERENCE, position_ticks: { x: 0.5, y: 0, z: 0 } } }] }] } }), + 'artifact.geometry.containers[0].placements[0].placement.position_ticks.x must be an integer'], +]; + +for (const [name, edit, message] of MALFORMED) { + test(`an artifact with ${name} is a RangeError naming the field`, () => { + assert.throws(() => artifactToScene(edit(artifactWithLength('16000')), 0), { name: 'RangeError', message }); + }); +} + +test('a malformed placement reference is a RangeError, not a TypeError', () => { + assert.throws(() => sceneItemId(undefined), { name: 'RangeError', message: 'reference must be an object' }); + assert.throws(() => sceneItemId({ ...REFERENCE, position_ticks: undefined }), + { name: 'RangeError', message: 'reference.position_ticks must be an object' }); +}); + +test('a packing result becomes one scene per container, read through its artifact', () => { + const request = { + items: [{ id: 'cube', quantity: 3, dimensions: { length: '100', width: '100', height: '100' } }], + containers: [{ id: 'box', inner_dimensions: { length: '200', width: '100', height: '100' } }], + configuration: { solver_profile: 'fast' }, + }; + const result = pack(request); + const scenes = resultToScenes(request, result); + const artifact = buildOperationalArtifact(request, result); + assert.equal(scenes.length, result.containers.length); + assert.deepEqual(scenes, result.containers.map((_, index) => artifactToScene(artifact, index))); +});