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
60 changes: 58 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,64 @@
# Changelog

The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the caveat
that the public API is not frozen until `1.0.0`. Pin an exact version.
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). As of `1.0.0` the
public API, the request and result schemas, the numeric and units policy, the validation
rules and the compatibility policy are frozen: breaking any of them costs a major version.

## [1.0.0]

The stable core release. It freezes the contract that already exists rather than adding a
feature wave: the public API, the request and result schemas, the numeric and units policy,
the validation rules and the compatibility policy will not break without a major version.

### Read this before depending on 1.0.0

- **Platforms exercised for this release: Linux x86_64, Linux aarch64 and macOS arm64.**
Python wheels are built and installed into a clean Python 3.9 on all three; the PHP FFI
bridge is verified against the real shared library on PHP 8.2, 8.3, 8.4 and 8.5 on both
Linux architectures. **Windows and macOS x86_64 native binaries are not built and not
published.** Without a native binary the pure Python and PHP engines run unchanged — that
is the documented default, and it costs speed rather than correctness.
- **Release artifacts carry no build-provenance attestation.** `gh attestation verify` will
not succeed against 1.0.0. The SBOM and SHA-256 manifests are the integrity evidence for
this release; check them before installing from anywhere other than the official
registry.

### Added

- **A sound lower bound on the objective, in every engine.** Computed from the request
alone before a search begins, and identical across the Python, PHP, Rust and JavaScript
implementations on 381 corpus cases. It is not a result field: reporting an optimality
gap would widen the contract this release exists to freeze.
- **A declared numeric ceiling shared by all four engines.** A sum too large to stay exact
returns a structured refusal instead of a number that one language would round and
another would not. The limit is stated by the library rather than inherited from each
language, so the four agree about which requests are answerable.
- **A published coverage frontier for optimality claims.** Where the bound is actually
attained is measured and committed, so `optimal` is bounded by evidence rather than used
as a label.

### Fixed

- **A bound could be returned that JavaScript cannot represent exactly.** Values above
`2^53-1` are now refused rather than silently rounded in one engine and exact in the
others.
- **The prebuilt Node addon had no quality floor.** It is now held to a per-fixture budget
like the other independent engines.

### Not claimed

- **Identical placements across engines.** Different engines may return different, equally
valid arrangements. This is measured and budgeted, not accidental — and it includes the
JavaScript fallback and the prebuilt native addon, which differ on 18 of 397 corpus
fixtures.
- **Optimal packings for arbitrary requests.** 3D packing remains NP-hard; the bound says
what is provable, not that every answer is optimal.
- **Fastest engine.** Measured against other libraries on identical hardware, that claim is
false on latency, and no Packvium surface makes it. On the report's separate two-axis
time-and-peak-memory frontier, `packvium-rust` is Pareto-optimal in 6 of 9 profiles and
the only engine of ten never dominated. The latency result and the trade-off result are
reported together; neither is a general speed-leadership claim.

## [0.1.3]

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

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

## Install

```bash
Expand Down Expand Up @@ -100,6 +103,8 @@ and execute without a project around it.
| File | What it shows |
| --- | --- |
| [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. |
| [`objectives.mjs`](examples/objectives.mjs) | All six objectives on scenes where they genuinely disagree — the same scores the Python, PHP and Rust engines print for the same request. |
| [`shapes.mjs`](examples/shapes.mjs) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, and a cushion that compresses under load until the crush limit refuses it. |
| [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. |

```bash
Expand Down Expand Up @@ -133,7 +138,7 @@ Documentation, the constraint reference and the benchmarks are at
| --- | --- | --- |
| Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) |
| PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) |
| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "0.1"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.0"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
| Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) |
| Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) |
| PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) |
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

## Supported versions

Only the latest `0.1.x` release receives fixes. This is an early release; there is no
long-term support branch yet.
Only the latest `1.x` release receives fixes. The `0.1.x` line is superseded by `1.0.0`
and receives none. There is no long-term support branch for older majors.

## Reporting a vulnerability

Expand Down
172 changes: 143 additions & 29 deletions contact-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,106 @@
* representation can avoid. The exact overlap function remains authoritative.
* This module is package-internal: package.json exports only the root entry point.
*/
export function buildContactGraph(boxes, overlapXY) {
const supporters = boxes.map(() => []);
const children = boxes.map(() => []);
if (boxes.length === 0) return { supporters, children, candidateChecks: 0 };

const cell = Math.max(1, ...boxes.map(box => Math.max(box.d[0], box.d[1])));
const byTop = new Map();
const levels = new Map();
const cells = box => {
const x1 = Math.floor(box.x / cell);
const x2 = Math.floor((box.x + box.d[0] - 1) / cell);
const y1 = Math.floor(box.y / cell);
const y2 = Math.floor((box.y + box.d[1] - 1) / cell);
return [...new Set([`${x1}:${y1}`, `${x2}:${y1}`, `${x1}:${y2}`, `${x2}:${y2}`])];
};

/**
* The at-most-four cells `box` occupies.
*
* `cell` must be at least as large as the largest footprint dimension of every box
* hashed into the index or queried against it -- not just the ones being indexed. Only
* then is a box guaranteed to span no more than a 2x2 block, which is what makes two
* overlapping boxes always share a cell. Sizing it from the indexed boxes alone would be
* exactly wrong: a larger querying box could step over cells in the middle of its own
* footprint and silently miss a real overlap.
*/
function cellsOf(box, cell) {
const x1 = Math.floor(box.x / cell);
const x2 = Math.floor((box.x + box.d[0] - 1) / cell);
const y1 = Math.floor(box.y / cell);
const y2 = Math.floor((box.y + box.d[1] - 1) / cell);
return [...new Set([`${x1}:${y1}`, `${x2}:${y1}`, `${x1}:${y2}`, `${x2}:${y2}`])];
}

function bucketsByPlane(boxes, plane) {
const byPlane = new Map();
boxes.forEach((box, index) => {
const top = box.z + box.d[2];
if (!byTop.has(top)) byTop.set(top, []);
byTop.get(top).push(index);
const key = plane(box);
if (!byPlane.has(key)) byPlane.set(key, []);
byPlane.get(key).push(index);
});
return byPlane;
}

const topOf = box => box.z + box.d[2];
const bottomOf = box => box.z;

function levelIndex(boxes, indices, cell) {
const level = new Map();
for (const index of indices) {
for (const key of cellsOf(boxes[index], cell)) {
if (!level.has(key)) level.set(key, []);
level.get(key).push(index);
}
}
return level;
}

/**
* Every box in `buckets` on `plane` that really overlaps `box`, ascending by index.
*
* Ascending order is contract, not presentation: `topLoads` splits a conserved integer
* across the supporter list and hands the rounding remainder to whichever edge is last.
*/
function overlapsOnPlane(graph, buckets, cache, plane, box, overlapXY) {
const indices = buckets.get(plane);
if (!indices) return [];
let level = cache.get(plane);
if (level == null) {
level = levelIndex(graph.boxes, indices, graph.cell);
cache.set(plane, level);
}
const nearby = new Set();
for (const key of cellsOf(box, graph.cell)) {
for (const index of level.get(key) ?? []) nearby.add(index);
}
const found = [];
for (const other of [...nearby].sort((left, right) => left - right)) {
const area = overlapXY(graph.boxes[other], box);
if (area > 0) found.push([other, area]);
}
return found;
}

/**
* `cellHint` is an upper bound on the footprint of any box that may later be appended
* with `appendContactBox`.
*
* Without it the cell is sized from the boxes present now, and appending anything wider
* has to fall back to a full rebuild -- correct, but it defeats the point, because in a
* search the base is what is already placed and the candidate is a *new* item that may
* well be the widest in the request. A caller that knows the item set passes its widest
* footprint once and the delta path then always applies. Too large a hint only makes
* each bucket coarser; too small a one cannot give a wrong answer, because the fallback
* covers it.
*/
export function buildContactGraph(boxes, overlapXY, cellHint = 1) {
const supporters = boxes.map(() => []);
const children = boxes.map(() => []);
const cell = Math.max(1, cellHint, ...boxes.map(box => Math.max(box.d[0], box.d[1])));
const byTop = bucketsByPlane(boxes, topOf);
const byBottom = bucketsByPlane(boxes, bottomOf);
const topLevels = new Map();
let candidateChecks = 0;

boxes.forEach((upper, upperIndex) => {
const candidates = byTop.get(upper.z);
if (!candidates) return;
let level = levels.get(upper.z);
let level = topLevels.get(upper.z);
if (level == null) {
level = new Map();
for (const index of candidates) {
for (const key of cells(boxes[index])) {
if (!level.has(key)) level.set(key, []);
level.get(key).push(index);
}
}
levels.set(upper.z, level);
level = levelIndex(boxes, candidates, cell);
topLevels.set(upper.z, level);
}
const nearby = new Set();
for (const key of cells(upper)) {
for (const key of cellsOf(upper, cell)) {
for (const index of level.get(key) ?? []) nearby.add(index);
}
for (const lowerIndex of [...nearby].sort((left, right) => left - right)) {
Expand All @@ -57,5 +118,58 @@ export function buildContactGraph(boxes, overlapXY) {
}
}
});
return { supporters, children, candidateChecks };
// The downward-facing indexes stay empty here: only an append queries them, and a graph
// built once and read once would otherwise pay for an index nothing looks at.
return { supporters, children, candidateChecks, boxes, cell, byTop, byBottom, topLevels,
bottomLevels: new Map() };
}

/**
* `graph` plus one more box, appended at the next index.
*
* Adding a box cannot create or destroy contact between two boxes already in the graph:
* contact is a pairwise geometric predicate over two boxes and nothing else. That is the
* whole reason a delta is sound, and it is why only the new box's own two planes are
* queried instead of every box being re-examined.
*
* The result is required to be identical to `buildContactGraph([...boxes, box])`, not
* merely equivalent -- see `overlapsOnPlane` on why edge order is contract. The new box
* takes the highest index, so appending it to an existing list keeps that list ascending.
*
* `graph` is not modified: the returned graph shares every edge list the append did not
* touch, and copies the two or three it did.
*/
export function appendContactBox(graph, box, overlapXY) {
const index = graph.boxes.length;
const footprint = Math.max(box.d[0], box.d[1]);
if (footprint > graph.cell) {
// The broad phase is only correct while its cell covers every box hashed into it or
// queried against it, so this is a correctness fallback, not an optimisation choice.
return buildContactGraph([...graph.boxes, box], overlapXY, footprint);
}

const below = overlapsOnPlane(graph, graph.byTop, graph.topLevels, box.z, box, overlapXY);
const above = overlapsOnPlane(graph, graph.byBottom, graph.bottomLevels, topOf(box), box, overlapXY);

const supporters = graph.supporters.slice();
const children = graph.children.slice();
supporters.push(below.map(([lower, area]) => [lower, area]));
children.push(above.map(([upper]) => upper));
for (const [lower] of below) children[lower] = [...children[lower], index];
for (const [upper, area] of above) supporters[upper] = [...supporters[upper], [index, area]];

// One box joins exactly two planes, so only those two buckets change, and only the two
// level indexes describing them are invalidated. A level index is never mutated after
// it is built, so every other one is shared with the base rather than rebuilt.
const byTop = new Map(graph.byTop);
byTop.set(topOf(box), [...(byTop.get(topOf(box)) ?? []), index]);
const byBottom = new Map(graph.byBottom);
byBottom.set(box.z, [...(byBottom.get(box.z) ?? []), index]);
const topLevels = new Map(graph.topLevels);
topLevels.delete(topOf(box));
const bottomLevels = new Map(graph.bottomLevels);
bottomLevels.delete(box.z);

return { supporters, children, candidateChecks: graph.candidateChecks,
boxes: [...graph.boxes, box], cell: graph.cell, byTop, byBottom, topLevels, bottomLevels };
}
19 changes: 13 additions & 6 deletions docs/GUARANTEES.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,16 @@ silently — if you need them, they belong in your own layer above this library.

## Status of this release

Version `0.1.3` is an early release. The public API is not yet frozen: field names,
status codes and the objective vector may change before `1.0.0`. Pin an exact version.

The algorithm complexities documented in `ALGORITHMS-AND-COMPLEXITY.md` are design
intent that has not yet been confirmed by profiling. Treat them as guidance for choosing
a solver profile, not as a performance contract.
Version `1.0.0` freezes the public API. Field names, status codes, the objective
vector, the numeric policy and the validation rules do not change without a major
version, so any `1.x` is a safe upgrade from any earlier `1.x`. A caret or tilde
constraint on `1.0` is enough; an exact pin is no longer required.

What the freeze does not cover: which of several equally valid packings a solver
returns. That is bounded by the objective vector, not by the placement list, and a minor
release may return a different arrangement with the same or a better score.

The algorithm complexities documented in `ALGORITHMS-AND-COMPLEXITY.md` are asymptotic
design bounds held by review, not per-release measurements. They are not a wall-clock
performance contract either: constants, input shape and host all move the real number.
Use them to choose a solver profile, not to predict a duration.
26 changes: 26 additions & 0 deletions docs/PUBLIC-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@
## Core inputs

- `Item`: id, dimensions, weight, quantity, rotations, upright/floor/stacking rules, top-load limit, support ratio, group, tags, metadata, and an optional `nesting_height` (how much this item sinks into an identical one beneath it when stacked). Only the same item type with the exact same footprint may nest; its adjacent predecessor is one full-footprint direct supporter for support ratio, ground-contact, stack/load and route rules, while non-adjacent same-column face coincidences are shadowed. An optional, exact non-negative integer `value` (no unit or currency — the caller's own economic scale) means nothing to placement or to any other objective; only the `maximum_value` objective reads it.
- **Item geometry beyond the box.** `shape_type` is an enum —
`rigid_cuboid` (the default), `convex_hull`, `compressible` — accompanied by
`hull_vertices`, `compression_ratio` and `max_compression_pressure_kpa`. **A request that
omits `shape_type` is unaffected**, byte for byte: the default is `rigid_cuboid`, the whole
existing golden corpus is unchanged, and writing `"shape_type": "rigid_cuboid"` explicitly
is served rather than refused.

The two other values are rolling out one engine at a time, which is what the fields were
reserved before the freeze for, and the rollout is finished: **all four engines implement
both**, and are held to byte-identical results on the shared fixtures. The one
recorded difference is the JavaScript fallback on a scene whose answer depends on item
ordering -- it makes a single ordering pass where the other three run a portfolio, so it can
pack validly but less densely; that is pinned in `conformance/native-quality-budget.json`
rather than left to drift.

Where they are implemented: a `convex_hull` item's collisions are decided by an exact
integer separating-axis test rather than by its box, and its occupied volume is the hull's
own — two complementary wedges share a crate that fits one of their bounding boxes. A
`compressible` item loses height linearly with the pressure resting on it, rounded up, and
a load above `max_compression_pressure_kpa` is refused as `crush_violation` rather than
packed. Three situations deliberately fall back to the bounding box, always over-reserving
space: a configured `clearance`, an item on a route (`stop_index`), and the uniform-lattice
fast path. Hull coordinates are non-negative offsets from the corner of the item's own
bounding box and are capped at 10^8 length ticks -- 6.25 m, beyond ordinary parcel sizes, and the
bound that keeps the exact geometry inside a 64-bit integer everywhere it can be.
IRREGULAR-ITEMS.md is the model and states why.
- `Container`: id, inner/outer dimensions, tare/payload, cost, inventory quantity,
obstacles (each a union of one or more exact boxes — `additional_boxes`
approximates a non-rectangular zone such as a wheel arch or tapered roof without
Expand Down
Loading
Loading