diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c9512..648baf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index edceb0b..2b560c4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 3d2f188..34bed67 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -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. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index 5b6e831..a6561f2 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -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 diff --git a/docs/UNITS-AND-NUMERICS.md b/docs/UNITS-AND-NUMERICS.md index 9b1aa73..1badad2 100644 --- a/docs/UNITS-AND-NUMERICS.md +++ b/docs/UNITS-AND-NUMERICS.md @@ -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 diff --git a/examples/shapes.py b/examples/shapes.py new file mode 100644 index 0000000..79be82b --- /dev/null +++ b/examples/shapes.py @@ -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")}) diff --git a/pyproject.toml b/pyproject.toml index d3a5de8..143713e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/packvium_native/__init__.py b/src/packvium_native/__init__.py index 37c6a11..692e45b 100644 --- a/src/packvium_native/__init__.py +++ b/src/packvium_native/__init__.py @@ -3,7 +3,7 @@ import json from typing import Any -__version__ = "1.0.0" +__version__ = "1.1.0" def backend() -> str: