Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@ What changed in `@packvium/engine` on npm, release by release. The format follow
[Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.5.0]

No API changes. The JavaScript engine's beam search is faster; route packing,
`rebalanceWeight` and the `quality` termination report are fixed, and a misspelt configuration
key is now refused (see *Fixed*).

### Changed

- **Beam search 7–9% faster in the JavaScript engine, same results.** Expansions that cannot
beat the best plan so far are no longer copied, and the bound that ranks beam nodes is one
binary search per node.
- **No probe for `@packvium/native`.** The package tried to load that name, which is neither
published nor declared, so a supply-chain scanner reported an unresolved dependency. A
compiled addon is still loaded from `packvium-native.node` beside `index.js`; an
`npm install` runs the JavaScript engine, as it already did.

### Fixed

- **`quality` with `minimum_support_ratio` no longer returns unsupported items.** The
homogeneous block solver ran even when the request asked for support, and a block set on a
smaller one overhangs it: the result could hold items below the requested ratio, some
resting on nothing, reported as `best_found`. Such a request is now left to the per-item
search, and every item meets the ratio. Present since the block solver was added.
- **A misspelt `configuration` key is refused.** A key the request schema does not declare in
`configuration` or its `effort_budget` (`profile` for `solver_profile`, `top_k` for
`alternatives`) was silently ignored, so the request ran on defaults. `pack` now throws
`InvalidRequestError` with reason `not_allowed` and the key's pointer, for example
`/configuration/profile`.
- **`rebalanceWeight` no longer strands a supported item.** It could move the item underneath
and leave the one above floating; such a move is now discarded, as documented.
- **`quality` reports `effort_limit` when the effort budget stopped it,** not `time_limit`.
- **Route packing with a `-x` door now packs all five items** in the regression scene (formerly
two), keeps next-stop parcels behind the first stop's pallet, and orients upright pallets to
save length.
- **An unknown `objective` or access direction is refused with its pointer** (`not_allowed`,
e.g. `/configuration/objective`) instead of `invalid_value` with an empty field; refusal
messages match the documentation.

## [1.4.0]

Replanning a job that has already started, and container ids that match the other engines.
Expand Down
25 changes: 25 additions & 0 deletions CITATION.cff
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
cff-version: 1.2.0
message: "If you use Packvium in your research, please cite it as below."
title: "Packvium for Node.js"
type: software
authors:
- name: "Packvium contributors"
website: "https://packvium.com"
version: "1.5.0"
license: MIT
url: "https://packvium.com"
repository-code: "https://github.com/toxakara/packvium-node"
repository-artifact: "https://www.npmjs.com/package/@packvium/engine"
abstract: >-
Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.
keywords:
- 3d-bin-packing
- bin-packing
- 3d-packing
- cartonization
- container-loading
- pallet-packing
- truck-loading
- packing-algorithm
- logistics
- deterministic
9 changes: 4 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,10 @@ local and needs no coordination.

## Native backend

This repository ships the pure-JavaScript fallback only. The optional N-API addon comes
from `@packvium/native`, built from a separate Rust workspace this repository does not
carry. It is **not** declared as a dependency, so nothing is installed automatically and
nothing has to resolve; install `@packvium/native` yourself if you want the compiled
backend. When it is not resolvable at `require()` time,
This repository ships the pure-JavaScript fallback only. The optional N-API addon is
built from a separate Rust workspace this repository does not carry, and is loaded only
from `packvium-native.node` beside `index.js`; no npm package is probed, so nothing is
installed automatically and nothing has to resolve. When no addon loads at `require()` time,
`backend()` reports `'javascript'` and every call runs the fallback above. Treat the
native path as best-effort acceleration, not a dependency to code against.

Expand Down
161 changes: 143 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
Deterministic 3D cartonization for Node.js. It uses the optional native engine when
available and automatically falls back to the bundled JavaScript implementation.

Use it to pick the smallest carton for an order, build a pallet, load a shipping container,
or load a truck within its axle ratings and delivery-stop order. Every answer comes back as
coordinates and rotations for each item, with a reason for anything that did not fit.

Full documentation, the constraint reference and benchmarks live at
[packvium.com](https://packvium.com).

Expand All @@ -21,8 +25,15 @@ production.
import { backend, commerce, pack } from '@packvium/engine';

const result = pack({
units: { length: 'mm' },
configuration: {
// Stop by counted work, not by the clock, so the answer is the same on any machine.
// The time limit is only a safety fuse; see "Deterministic results" below.
effort_budget: { max_candidates_evaluated: 1000000 },
time_limit_ms: 60000,
},
items: [{
id: 'book', quantity: 4,
id: 'book', quantity: 4, weight: '450 g',
dimensions: { length: '210', width: '140', height: '30' },
}],
containers: [{
Expand All @@ -33,7 +44,7 @@ const result = pack({

console.log(backend()); // "rust" or "javascript"
console.log(result.status); // "feasible"
console.log(result.containers);
console.log(result.containers[0].placements.length); // 4

const commerceDocument = { tariffs: [{
carrier_id: 'acme', service_id: 'ground',
Expand All @@ -50,6 +61,54 @@ const quote = commerce.quote(commerceDocument, {
console.log(quote.quote.total_minor);
```

Lengths and weights are strings on purpose: they are parsed into exact integers, so `'0.1'`
is a tenth of a millimetre and never `0.09999999999999999`. Every measurement in the result
comes back as `{ ticks, value, unit }` -- `ticks` is the exact integer the engine reasoned
with, `value` the same number written for a person.

## Two backends

`pack()` uses a compiled native addon when one is built beside `index.js` and loads,
and the bundled JavaScript engine otherwise. The published package ships no binary, so an
`npm install` runs the JavaScript engine. `backend()` says which one answered.

- **Packing:** both return a valid packing that honours every rule in the request, and each is
deterministic on its own. They are independent searches, so for the same request they may
choose a different arrangement, a different container, or a differently worded reason for an
item left out. Do not compare results across backends byte for byte.
- **Commerce:** the two are held to the same answer: a quote's price is one exact integer
and both compute the same one, and an unanswerable request is rejected by both with the same
code and fields.

## Deterministic results

A solve stops when it finishes or when a limit stops it, and which limit matters:

- `configuration.time_limit_ms` is a wall clock. How far a search gets in it depends on the
machine and its load, so an answer the clock cut short can differ on the next run. The
JavaScript engine uses **1000 ms** when you set nothing.
- `configuration.effort_budget` counts work -- `max_candidates_evaluated`,
`max_placement_attempts`, `max_search_nodes`, `max_restarts` -- and stops at the same point
on every machine, so even a partial answer repeats exactly.

For anything you store, compare or audit, set an `effort_budget` and a `time_limit_ms` far above
what that work takes, as a fuse rather than a limit. The result says which one stopped it:

```js
const result = pack({ ...request, configuration: {
effort_budget: { max_candidates_evaluated: 200000 },
time_limit_ms: 60000,
} });

result.termination.code; // "complete", "effort_limit" or "time_limit"
result.algorithm.effort_limit_reached; // true: partial, but a rerun gives the same answer
result.algorithm.time_limit_reached; // true: the clock decided -- do not cache it as the answer
```

Items the search never reached are listed in `unpacked_items` with reason `effort_limit` or
`time_limit`, not as items that do not fit. [`examples/reproducibility.mjs`](examples/reproducibility.mjs)
shows both.

## Errors

A request that no engine may answer throws `InvalidRequestError`, a `RangeError`, before anything
Expand All @@ -69,11 +128,26 @@ try {
}
```

`reason` is one of `missing_field`, `wrong_type`, `below_minimum`, `above_maximum`, `negative_measure`, `invalid_unit`, `duplicate_id`, `not_allowed` or `invalid_value`. `FixedPlacementError` extends it, with code
`invalid_fixed_placement` and reason `malformed` or `cannot_hold`.
The message is the same in every Packvium engine. Branch on `reason` and `field`; show the
message to a person. A request that is valid but does not fit completely is not an error: the
result lists what was left out, and why, in `unpacked_items`.
`reason` is one of `missing_field`, `wrong_type`, `below_minimum`, `above_maximum`,
`negative_measure`, `invalid_unit`, `duplicate_id`, `not_allowed` or `invalid_value`. `field` is
an RFC 6901 JSON Pointer: `~1` stands for `/` and `~0` for `~` inside a key such as a tag name,
and `''` means the request as a whole. The message is `<code>: <field>: <detail>`, the same in
every Packvium engine. Branch on `reason` and `field`; show the message to a person.

- `FixedPlacementError` extends it, with code `invalid_fixed_placement` and reason `malformed`
(`field` points at the bad value) or `cannot_hold` (the fixed items are not a valid packing on
their own; `field` is `/fixed_placements`). One `catch` for `InvalidRequestError` covers both.
- `UnsupportedFeatureError` is separate, with code `unsupported_feature` and a `fields` list: the
request uses a field the schema reserves but this engine does not implement yet, such as
`container.pallet_overhang_limit`. It is refused rather than silently ignored.
- Numbers are judged by value, as the other engines judge them: `2.0` is the integer `2`, `2.5`
and `'2'` are not integers, and an integer above `Number.MAX_SAFE_INTEGER` (2^53 - 1) is
`above_maximum` -- also when it arrives as JSON text, which `JSON.parse` would otherwise round.
- Text that is not JSON makes `packJson()` throw the `SyntaxError` from `JSON.parse`.

A request that is valid but does not fit completely is not an error: the result lists what was
left out, and why, in `unpacked_items`. [`examples/errors.mjs`](examples/errors.mjs) walks every
case above.

## Quotes, policy and catalog versions

Expand Down Expand Up @@ -115,32 +189,56 @@ Two kinds of failure, and they are not interchangeable:
from a closed set and structured fields naming what was missing.

`commerce.backend()` reports whether the native addon or the JavaScript implementation
answered; both return the same result for the same input. A runnable walk-through of all
three functions is in [examples/commerce.mjs](examples/commerce.mjs), and the full
contract — document format, every result shape, all ten rejection codes, complexity and
limitations — is `docs/COMMERCE-API.md`.
answered; both give the same price and the same rejection for the same input. A runnable
walk-through of all three functions is in [examples/commerce.mjs](examples/commerce.mjs), and
the full contract — document format, every result shape, all ten rejection codes, complexity
and limitations — is [COMMERCE-API.md](https://github.com/toxakara/packvium-node/blob/main/docs/COMMERCE-API.md).

## Examples

Runnable, in [`examples/`](examples). Each one is a single file you can read top to bottom
and execute without a project around it.
and execute without a project around it, and each prints what it teaches.

| File | What it shows |
| --- | --- |
| [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. |
| [`objectives.mjs`](examples/objectives.mjs) | All six objectives on scenes where they genuinely disagree — the same scores the Python, PHP and Rust engines print for the same request. |
| [`objectives.mjs`](examples/objectives.mjs) | All six objectives on scenes where they genuinely disagree about which container to open. |
| [`constraints.mjs`](examples/constraints.mjs) | Rotations and `keep_upright`, support ratio, top load, stacking caps, incompatible tags and atomic groups — each with and without the rule — and `explainUnpackedItem` turning a refusal into a sentence. |
| [`limits.mjs`](examples/limits.mjs) | Wheel arches as `obstacles` with `additional_boxes`, `max_items` and `tag_limits` per container, and what happens when container stock or `max_containers` runs out. |
| [`trucking.mjs`](examples/trucking.mjs) | A multi-drop van: `stop_index` and a rear door in `access_directions`, two `axles` with load limits, and a safe loading order fed into `buildExecutionPlan`. |
| [`shapes.mjs`](examples/shapes.mjs) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, and a cushion that compresses under load until the crush limit refuses it. |
| [`constraints.mjs`](examples/constraints.mjs) | Stacking caps, incompatible tags and atomic groups — each shown with and without the rule, plus how to read the structured refusal. |
| [`units.mjs`](examples/units.mjs) | Why lengths travel as strings: fractional inches kept exact, one tick deciding a fit, and the point where a JavaScript number stops being exact and a quote is refused rather than rounded. |
| [`serialization.mjs`](examples/serialization.mjs) | `packJson` with JSON text in and out, runners-up through `alternatives`, and exactly which mistakes are refused and which are ignored. |
| [`reproducibility.mjs`](examples/reproducibility.mjs) | `effort_budget` against `time_limit_ms`: a budget that never binds, one that cuts the search short and still repeats exactly, and how to tell from the result which one stopped it. |
| [`errors.mjs`](examples/errors.mjs) | `InvalidRequestError`'s `code`, `reason`, `field` and message for each kind of bad request, JavaScript's integer range, `FixedPlacementError` and `UnsupportedFeatureError` — and a valid request that simply does not fit. |
| [`fixed_placements.mjs`](examples/fixed_placements.mjs) | Pack around items already loaded, lock half of an earlier plan by quoting its placements, and a fixed set that cannot hold. |
| [`rebalancing.mjs`](examples/rebalancing.mjs) | `rebalanceWeight` evening out payload between packed crates, the moves it made, and `maxMoves`. |
| [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. |
| [`execution.mjs`](examples/execution.mjs) | Turn a result into dock instructions — and the clearest place to see what "byte-identical" does and does not promise: the four adapters agree on any given result, while this engine is free to reach a different packing than Python does. |
| [`execution.mjs`](examples/execution.mjs) | Turn a result into dock instructions, and what "byte-identical" does and does not promise: the four adapters agree on any given result, while this engine is free to reach a different packing than Python does. |
| [`artifacts.mjs`](examples/artifacts.mjs) | Hand a result to a system with no engine: one document with the plan, geometry and the request that produced it, exported as CSV and a printable HTML work order, and a refusal for a format it does not know. |
| [`revisions.mjs`](examples/revisions.mjs) | Replan a half-loaded job: a missing item and a locked placement recorded against the approved plan, a replan that keeps the locked item in place, and a hash-chained record that notices an edit. |

```bash
node examples/basic.mjs
```

## Subpath modules

The main entry point has packing, sequencing, explanations, rebalancing and `commerce`. Four
more modules are separate imports, so an application that only packs never loads them:

```js
import { buildExecutionPlan, canonicalPlanJson } from '@packvium/engine/execution.js';
import { buildOperationalArtifact, canonicalArtifactJson } from '@packvium/engine/artifacts.js';
import { exportCsv, exportJson, exportWorkOrderHtml } from '@packvium/engine/artifact-exports.js';
import { deriveRevision, rootRevision, verifyRevisionChain } from '@packvium/engine/revisions.js';
```

`execution.js` turns a result into numbered work-order steps. It never computes a loading order
itself: pass one from `safeLoadingOrder()` as `loadingOrders`, or the plan says its order is
`unavailable` rather than guessing. `artifacts.js` and `artifact-exports.js` package a result for a
system with no engine; `revisions.js` records changes against an approved plan.

## Features

- Exact, deterministic placement with no floating-point geometry decisions.
Expand All @@ -166,7 +264,9 @@ The native addon is optional. `npm install` works on unsupported platforms too;
## The Packvium family

One request and result contract, implemented independently in four engines (Rust,
Python, PHP, JavaScript) and held to identical placements on a shared fixture set.
Python, PHP, JavaScript) and checked against each other on a shared fixture set: Python and
PHP to identical placements, Rust and JavaScript to a valid packing that scores no worse
than a per-fixture floor.
Pick the package for your stack; mixing them in one system is safe.

Documentation, the constraint reference and the benchmarks are at
Expand All @@ -185,8 +285,33 @@ Documentation, the constraint reference and the benchmarks are at
## API and support

TypeScript declarations are included. See the package's `index.d.ts` for the complete
request and result types, and `docs/COMMERCE-API.md` for the commercial/control-plane
contract. Report security issues through [SECURITY.md](SECURITY.md).
request and result types. The documentation is in the source repository, not in the npm
package:

- [GUARANTEES.md](https://github.com/toxakara/packvium-node/blob/main/docs/GUARANTEES.md) — what is promised and what is not;
- [PUBLIC-API.md](https://github.com/toxakara/packvium-node/blob/main/docs/PUBLIC-API.md) — every request field, result shape and status;
- [UNITS-AND-NUMERICS.md](https://github.com/toxakara/packvium-node/blob/main/docs/UNITS-AND-NUMERICS.md) — units, accepted input forms, exact
arithmetic and the 2^53 ceiling;
- [COMMERCE-API.md](https://github.com/toxakara/packvium-node/blob/main/docs/COMMERCE-API.md) — the commercial/control-plane contract.

Report security issues through [SECURITY.md](SECURITY.md).

## Citation

If Packvium supports your research, cite it as software. GitHub's **Cite this repository**
button reads [`CITATION.cff`](https://github.com/toxakara/packvium-node/blob/main/CITATION.cff), and
[`codemeta.json`](https://github.com/toxakara/packvium-node/blob/main/codemeta.json) carries the same record in
CodeMeta form.

```bibtex
@software{packvium_node,
author = {{Packvium contributors}},
title = {Packvium for Node.js},
version = {1.5.0},
license = {MIT},
url = {https://packvium.com}
}
```

## License

Expand Down
2 changes: 1 addition & 1 deletion artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const FORMAT = 'packvium-operational-artifact/v1';
* The engine's own name is deliberately not recorded: four correct builders naming themselves
* would emit four different documents.
*/
export const SUITE_VERSION = '1.4.0';
export const SUITE_VERSION = '1.5.0';

/** The deterministic part of `result.algorithm`; `duration_ms` is wall-clock time. */
const SOLVER_FIELDS = ['profile', 'solver', 'seed', 'time_limit_reached', 'effort_limit_reached'];
Expand Down
Loading
Loading