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
83 changes: 83 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,89 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). As of `1.
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.1.0]

An additive release on the 1.0.0 freeze. Route-aware unloading becomes a property of the
container rather than of the whole solve, and the result contract grows the reserved,
typed shape a future optimality gap needs. Every schema change is additive: a request that
omits the new field gets the answer it got before, and both frozen public API surfaces are
unchanged.

The four engines' candidate hot paths were also profiled and rewritten. That work changes
no result — all 399 corpus fixtures are byte-identical — and it is in this release because
two of its findings were correctness fixes rather than speed.

### Added

- **Doors on a container: `container.access_directions`.** Naming which walls an item can
be pulled through, so that nothing due at a later stop stands between an earlier item and
a door. Previously this could only be stated once for a whole solve; a container now
states its own doors and falls back to that setting when it states none. An empty list
leaves the rule inert — it is not read as a sealed container, and it is deliberately not
read as all six walls, which would switch a real constraint on for callers who never set
the field. Implemented in all four engines, and refused by none.
- **A reserved shape for reporting an optimality gap.** The result schema now declares the
names, types and ceilings a gap will use, together with the rule that an attained bound
carries no gap at all rather than a zero. No engine emits any of them yet. The existing
extension point on that object stays open, so a producer accepted by the 1.0 schema is
still accepted by 1.1.

### Changed

- **The solvers do less repeated work.** Byte-identical results everywhere, with the
largest gains where a scene has many contacts: on a 120-item contact-heavy request the
Rust engine goes from 10.1 s to 1.4 s and the JavaScript fallback from 1.70 s to 0.76 s;
on a 200-item adversarial free-space scene JavaScript goes from 3.25 s to 0.58 s. Some
scenes are unchanged and one is marginally slower. **This is not a speed-leadership
claim** — measured against other libraries on identical hardware, that claim is false on
latency, and no Packvium surface makes it.
- **Every published package manifest now names its author, licence, repository, issue
tracker and homepage.** Absent fields are read as an anonymous package.
- **`@packvium/engine` no longer declares `@packvium/native` as an optional dependency.**
That package is not published, so the entry named something npm could not fetch. Nothing
a caller can observe changes: the install already succeeded and answered from the
JavaScript engine, and `index.js` still loads `@packvium/native` by literal specifier, so
installing it yourself alongside the engine still selects the compiled backend.

### Fixed

- **The Rust engine accepted placements the validator refused.** Support-ratio comparison
used a floating-point epsilon wide enough to admit an area a whole square tick short of
the requirement. It is now the same exact integer rule the other three engines use.
- **The JavaScript fallback accepted door names no other engine would.** An unknown
direction was refused only on requests that took the general solving path; a request
simple enough to be answered by the compact-grid shortcut was answered instead of
refused.
- **The JavaScript fallback broke identifier ties by host locale.** That is neither stable
across machines nor equal to the code-point order the other engines use, so two hosts
could order the same items differently. Every tie-break now uses the shared code-point
order.
- **A container's doors could be answered from another container's cached corridor.** Two
containers of the same size with different doors are two different questions; the cache
key did not separate them, which could have silently accepted a placement that walls an
item in.
- **The PHP package could not be loaded on PHP 7.3 or 7.4.** The package advertises
`php: >=7.3` and carries a second, downgraded source tree for runtimes below 8.2. In
`1.0.0` that tree contained one line of PHP 8.1 syntax, so the whole of it failed to
parse on both older runtimes. `1.1.0` is the first version whose legacy tree loads. If
you are on PHP 8.2 or newer you were never affected — the canonical tree is what your
runtime selects.

- **The engine package no longer loads its native backend through a computed specifier.**
Every module the package can load can now be resolved by reading the source. Behaviour
is identical: an absent, unbuilt or incompatible native addon still means the pure
JavaScript engine answers instead.

### Not claimed

- **A reported optimality gap.** The names are reserved and typed; nothing emits them.
- **`container.pallet_overhang_limit`.** Reserved in the request schema and refused by all
four engines.
- **Identical placements across engines.** Unchanged from 1.0.0: different engines may
return different, equally valid arrangements, and that is measured and budgeted.
- **Optimal packings for arbitrary requests.** 3D packing remains NP-hard.
- **Fastest engine.** Still false on latency and still claimed nowhere.

## [1.0.0]

The stable core release. It freezes the contract that already exists rather than adding a
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ and execute without a project around it.
| File | What it shows |
| --- | --- |
| [`basic.py`](examples/basic.py) | Pack through the adapter and report which backend answered. |
| [`shapes.py`](examples/shapes.py) | 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 — the same numbers whichever backend answered. |

```bash
python3 examples/basic.py
Expand Down
2 changes: 1 addition & 1 deletion docs/GUARANTEES.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ silently — if you need them, they belong in your own layer above this library.

## Status of this release

Version `1.0.0` freezes the public API. Field names, status codes, the objective
Version `1.1.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.
Expand Down
8 changes: 7 additions & 1 deletion docs/PUBLIC-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ move backwards. This is a testing seam, not a serialized request field.
- three independent result facts:
- `feasibility.code`: `feasible`, `infeasible`, or `unknown`;
- `termination.code`: `complete`, `time_limit`, `effort_limit`, or `error`;
- `optimality.code`: `proven_optimal`, `proven_infeasible`, `best_found`, or `not_proven`;
- `optimality.code`: `proven_optimal`, `proven_infeasible`, `best_found`, or `not_proven`.
The 1.1.0 freeze reserved and typed `gap_key`, `absolute_gap` and `relative_gap`
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;
- 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
Expand Down
2 changes: 2 additions & 0 deletions docs/UNITS-AND-NUMERICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Conversion supports floor, ceiling and ties-to-even nearest rounding. Applicatio

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.

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.

Ordering keys are exact too. Two volumes that differ by one cubic tick must not collapse onto the same value, or two implementations of one algorithm can order the same items differently.

## PHP integer limits
Expand Down
103 changes: 103 additions & 0 deletions examples/shapes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Shapes through the adapter: the same answer from whichever backend is installed.

Run it:

python3 examples/shapes.py

`basic.py` shows that the adapter picks a backend for you. This example answers the
question that follows: does the accelerated path support everything the pure one does?

It does, and the point is worth making with the newest capability rather than the oldest.
`shape_type` says an item is not simply its declared box -- `convex_hull` narrows it in
space, `compressible` in height under load -- and both belong to the shared JSON contract
rather than to any one engine. The adapter forwards the request untouched, so the numbers
below are the same whether the compiled Rust wheel answered or the pure-Python package
did. `backend()` says which one it was; the answer does not depend on it.

If you ever see these numbers change when the backend changes, that is a bug worth
reporting rather than a difference to work around.
"""

from packvium_native import backend, pack

print(f"answered by the {backend()} backend\n")

MM = {"units": {"length": "mm"}}


def crate(length: str, width: str, height: str) -> list:
return [{"id": "crate",
"inner_dimensions": {"length": length, "width": width, "height": height}}]


def summarise(label: str, request: dict) -> None:
"""Print only what the shape changed: containers, placements and unused volume."""
result = pack(request)
placed = sum(len(container["placements"]) for container in result["containers"])
print(f" {label:22s} {len(result['containers'])} container(s), {placed} placed, "
f"unused volume {result['score'][3]} ppm")


# ------------------------------------------------------------------ convex_hull
#
# Two triangular prisms cut from the same cube along its diagonal. Their bounding boxes
# are identical and each fills the crate alone, so as cuboids the second has nowhere to
# go. As hulls they are complementary halves and share the crate exactly: collisions are
# decided by an exact integer separating-axis test on the vertices, not a box overlap.

LOWER_WEDGE = [{"x": "0", "y": "0", "z": "0"}, {"x": "100", "y": "0", "z": "0"},
{"x": "0", "y": "100", "z": "0"}, {"x": "0", "y": "0", "z": "100"},
{"x": "100", "y": "0", "z": "100"}, {"x": "0", "y": "100", "z": "100"}]
UPPER_WEDGE = [{"x": "100", "y": "100", "z": "0"}, {"x": "100", "y": "0", "z": "0"},
{"x": "0", "y": "100", "z": "0"}, {"x": "100", "y": "100", "z": "100"},
{"x": "100", "y": "0", "z": "100"}, {"x": "0", "y": "100", "z": "100"}]


def wedge(item_id: str, vertices: "list | None") -> dict:
item = {"id": item_id, "quantity": 1,
"dimensions": {"length": "100", "width": "100", "height": "100"},
"weight": {"value": "1", "unit": "kg"}}
if vertices is not None:
item["shape_type"] = "convex_hull"
item["hull_vertices"] = vertices
return item


print("convex_hull -- two complementary wedges cut from one cube")
summarise("as cuboids", {**MM,
"items": [wedge("wedge-lower", None), wedge("wedge-upper", None)],
"containers": crate("100", "100", "100")})
summarise("as hulls", {**MM,
"items": [wedge("wedge-lower", LOWER_WEDGE),
wedge("wedge-upper", UPPER_WEDGE)],
"containers": crate("100", "100", "100")})

# ----------------------------------------------------------------- compressible
#
# `compression_ratio` is the fraction of its own height an item may lose under load;
# `max_compression_pressure_kpa` is where yielding becomes crushing and the load is
# refused instead. `must_be_on_floor` is not decoration -- without it the solver may put
# the brick underneath, nothing bears on the cushion, and the feature never engages.

CUSHION = {"id": "cushion", "quantity": 1,
"dimensions": {"length": "100", "width": "100", "height": "100"},
"weight": {"value": "2", "unit": "kg"},
"must_be_on_floor": True,
"shape_type": "compressible",
"compression_ratio": 0.25,
"max_compression_pressure_kpa": 100}


def brick(kilograms: int) -> dict:
return {"id": "brick", "quantity": 1,
"dimensions": {"length": "100", "width": "100", "height": "100"},
"weight": {"value": str(kilograms), "unit": "kg"}}


# The crate is 100x100x200 and both items are 100 mm cubes, so rigidly they fill it and
# nothing is unused. Under 101 kg the cushion gives up part of its quarter. One more
# kilogram crosses 100 kPa over its 0.01 m^2 face and the stack is refused instead.
print("\ncompressible -- a cushion that yields to the load above it")
for kilograms in (101, 102):
summarise(f"brick {kilograms} kg", {**MM, "items": [CUSHION, brick(kilograms)],
"containers": crate("100", "100", "200")})
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "packvium-native"
version = "1.0.0"
version = "1.1.0"
requires-python = ">=3.9"
description = "Backend selector for Packvium's native and pure Python engines"
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion src/packvium_native/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import json
from typing import Any

__version__ = "1.0.0"
__version__ = "1.1.0"


def backend() -> str:
Expand Down
Loading