diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..73c57fc Binary files /dev/null and b/.DS_Store differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f498e..e1c9512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/README.md b/README.md index d14dea0..5f6f80d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,12 @@ Deterministic 3D cartonization and rectangular bin packing. Pure Python, **no runtime dependencies**, exact integer geometry. -> **Version 0.1.3 — early release.** The public API is not frozen; pin an exact version. +Full documentation, the constraint reference and benchmarks live at +[packvium.com](https://packvium.com). + +> **Version 1.0.0 — the public API is frozen.** Field names, status codes and the +> objective vector do not change without a major version, so any `1.x` is a safe upgrade +> from any earlier `1.x`. > Read [docs/GUARANTEES.md](https://github.com/toxakara/packvium-python/blob/main/docs/GUARANTEES.md) before relying on a result. ```bash @@ -58,6 +63,7 @@ in `constraints.py` have failed you. | [`constraints.py`](https://github.com/toxakara/packvium-python/blob/main/examples/constraints.py) | Upright-only, floor-only, non-stackable, top-load limits, and tags that keep two items out of the same box — plus how to read the reason an item was refused. | | [`units.py`](https://github.com/toxakara/packvium-python/blob/main/examples/units.py) | Why there are no floats anywhere: fractional inches, exact ticks, and the one-tick difference between a fit and a refusal. | | [`serialization.py`](https://github.com/toxakara/packvium-python/blob/main/examples/serialization.py) | The same request as JSON, the result in full, and exactly which mistakes are refused and which are silently ignored. | +| [`shapes.py`](https://github.com/toxakara/packvium-python/blob/main/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. | | [`nested.py`](https://github.com/toxakara/packvium-python/blob/main/examples/nested.py) | Units into cartons, cartons onto a pallet, in one call. | | [`commerce.py`](https://github.com/toxakara/packvium-python/blob/main/examples/commerce.py) | Rate a shipment, apply an eligibility rule, and pin a catalog version. | | [`extensions.py`](https://github.com/toxakara/packvium-python/blob/main/examples/extensions.py) | A rule the schema has no field for — and an honest account of what you give up by writing one. | @@ -108,7 +114,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) | diff --git a/SECURITY.md b/SECURITY.md index 658f11c..dff5f2f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index af5efb3..3d2f188 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -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. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index 2089bb4..f403f3f 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -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 diff --git a/docs/UNITS-AND-NUMERICS.md b/docs/UNITS-AND-NUMERICS.md index a56d23e..9b1aa73 100644 --- a/docs/UNITS-AND-NUMERICS.md +++ b/docs/UNITS-AND-NUMERICS.md @@ -42,6 +42,33 @@ The JavaScript fallback also performs load distribution in `BigInt`: every when the intermediate product exceeds `Number` precision. The final tick count is 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 +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. + +The reason is the same one this document already gives for coordinates, taken one step +further. Python's integers are unbounded, PHP's silently become doubles on overflow, +JavaScript's `Number` stops being exact past `2^53`, and Rust's `i128` wraps. Four engines +refusing at four native limits would disagree about which requests are answerable at all -- +a caller would get a number from one and a refusal from another for the same input. So each +carries the guarded sums in a representation that holds `10^30` exactly: Python's `int`, +PHP's `BigInt` decimal strings, JavaScript's `BigInt`, Rust's `i128`. + +That intermediate ceiling is deliberately not the result ceiling. The five bound keys can +cross JSON and are returned as JavaScript `Number` values, so every engine also refuses a +final key above **`2^53 - 1` (`9,007,199,254,740,991`)**. This is the largest integer all four +bindings can return without changing its value. In particular, an unlimited inventory does +not make an expensive container count as one during validation: the selected opening costs +are summed exactly, checked against `10^30`, then checked against the portable result ceiling +before PHP or JavaScript converts them to a native integer. + +The value comes from the widest intermediate the formulas form -- a summed volume times +`10^6` -- which puts the largest product at `10^36`, about 170-fold inside an `i128`. It never +binds on a real request: `10^30` cubic ticks is 244 million cubic metres. + ## Decimal rendering `value` fields (e.g. `Length.decimal()`, `Weight.decimal()`, PHP's `RationalParser::decimalString()`) render an exact `ticks / divisor` rational to a fixed number of digits (8 by default). All four engines round the truncated remainder **ties-to-even**, matching Python's `Decimal.quantize` under its default context — the last kept digit rounds up when the discarded remainder is more than half the divisor, stays put when it's less, and on an exact half rounds to whichever choice makes that digit even. diff --git a/examples/serialization.py b/examples/serialization.py index e30431b..8f74fdb 100644 --- a/examples/serialization.py +++ b/examples/serialization.py @@ -16,7 +16,8 @@ survives the trip intact (see units.py for why that matters); - a field this engine has deliberately not implemented yet is refused by name, never quietly ignored -- but a key the parser simply does not recognise *is* ignored. The - difference matters, and the last section shows both. + difference matters, and the last section shows both -- the refusal through the guard's + own test hook, because this engine has caught up and now refuses nothing of its own. """ import json @@ -129,16 +130,24 @@ print("unknown objective:", str(refusal)[:100]) # And a field this engine has named as not-yet-implemented is refused explicitly, so a -# request written for a newer engine fails loudly instead of being half-honoured. The -# list is empty right now, which is what "this engine is caught up" looks like. +# request written for a newer engine fails loudly instead of being half-honoured. The list +# below is the engine's own constant, and it is empty: implemented `convex_hull` +# and `compressible`, the last reserved names left on it, so this engine now serves every +# field and every `shape_type` value the schema defines. print("fields this engine refuses by name:", {scope: fields for scope, fields in UNSUPPORTED_FIELDS.items() if fields} or "none") -from_the_future = json.loads(json.dumps(request)) -from_the_future["items"][0]["shape_type"] = "convex_hull" + +# Caught up is the right state and a poor demonstration, so the guard takes its lists as +# parameters -- the same hook its own tests use. Passing the value retired shows +# the refusal a caller still gets from an engine that is behind, and shows it naming the +# *value* rather than the field: `rigid_cuboid` is the default and is implemented, so a +# caller who spells the default out must be served, not refused. +behind = json.loads(json.dumps(request)) +behind["items"][0]["shape_type"] = "convex_hull" try: - reject_unsupported(from_the_future, {"item": ("shape_type",), "request": (), "configuration": (), "container": ()}) + reject_unsupported(behind, shape_types=("convex_hull",)) except UnsupportedFeatureError as refusal: - print(" what it looks like when one is:", str(refusal)[:110]) + print(" what one looks like, from an engine that is not:", str(refusal)[:110]) # --------------------------------------------------------------------------------- # The same document drives the command line, which reads a request on stdin and writes diff --git a/examples/shapes.py b/examples/shapes.py new file mode 100644 index 0000000..15504e7 --- /dev/null +++ b/examples/shapes.py @@ -0,0 +1,143 @@ +"""Shapes: when an item is not its box. + +Run it: + + PYTHONPATH=src python3 examples/shapes.py + +Every other example treats an item as the box it declares. That is the default and it is +right for almost everything, because a carton *is* a cuboid. Two kinds of goods are not: +a moulded or tapered part that leaves a usable void beside it, and a soft one that gives +way under whatever is stacked on it. + +`shape_type` narrows the box in one direction each -- `convex_hull` in space, +`compressible` in height under load -- and neither is ever inferred. An engine that quietly +packed a hull as its bounding box would return a plan that validates and does not +physically fit, so the value must be asked for. + +Both are written here through `pack_from_dict`, the request contract the four engines +share. That is deliberate: the shape fields are part of the JSON contract, so the same +request runs unchanged against the Python, PHP, Rust and JavaScript engines. +""" + +from packvium import pack_from_dict + + +def summarise(label: str, request: dict) -> None: + """Run one request and print only what the shape changed: containers and refusals. + + `pack_from_dict` answers in the same JSON shape the other three engines return, so + everything read here is the cross-language contract rather than a Python attribute. + """ + result = pack_from_dict(request) + containers = result["containers"] + placed = sum(len(container["placements"]) for container in containers) + print( + f" {label:22s} {result['status']:10s} " + f"{len(containers)} container(s), {placed} placed, " + f"{len(result['unpacked_items'])} refused" + ) + + +def crate(length: str, width: str, height: str) -> list: + return [{"id": "crate", + "inner_dimensions": {"length": length, "width": width, "height": height}}] + + +MM = {"units": {"length": "mm"}} + + +# ------------------------------------------------------------------ convex_hull +# +# Two triangular prisms, each cut from the same 100 mm cube along the diagonal. Their +# bounding boxes are identical and fill the crate on their own, so as cuboids the second +# one has nowhere to go. As hulls they are complementary halves and share the crate +# exactly -- the collision test is an exact integer separating-axis test on the vertices, +# not a box overlap. +# +# The hull is given in the item's own coordinates, in the request's length unit, and must +# fit inside the declared dimensions. It is not a replacement for them: the box still +# bounds the item, the hull only says how much of that box is solid. + +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")}) + +# One crate instead of two, for the same goods and the same crate. Nothing about the +# request changed except the claim that the items are wedges rather than blocks. + + +# ----------------------------------------------------------------- compressible +# +# `compression_ratio` is the fraction of its own height an item may lose when something +# rests on it -- 0.25 means it can give up a quarter. The mass above it is what decides +# how much it actually gives, so the occupied height of a compressible item is not a +# property of the item alone; it depends on what the solver put on top. +# +# `max_compression_pressure_kpa` is the other half of the same field. Past that pressure +# the item is not compressed further, it is crushed, and the load is refused instead. +# +# Note `must_be_on_floor` on the cushion. Without it the solver is free to put the brick +# underneath, nothing bears on the cushion, and the feature never engages -- which is the +# honest reason the rule is here and not an incidental detail of the example. + +def cushion(crush_kpa: int) -> dict: + return {"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": crush_kpa} + + +def brick(kilograms: int) -> dict: + return {"id": "brick", "quantity": 1, + "dimensions": {"length": "100", "width": "100", "height": "100"}, + "weight": {"value": str(kilograms), "unit": "kg"}} + + +def load(label: str, kilograms: int) -> None: + """One crate, one cushion, one brick -- only the brick's mass changes.""" + result = pack_from_dict({**MM, "items": [cushion(100), brick(kilograms)], + "containers": crate("100", "100", "200")}) + unused = result["score"][3] + print(f" {label:22s} {len(result['containers'])} container(s), " + f"unused volume {unused} ppm") + + +# The crate is 100x100x200 and the two items are 100 mm cubes, so rigidly they fill it +# exactly and nothing is unused. Under 101 kg the cushion gives up part of its quarter, +# the pair still ships as one stack, and the volume it stopped occupying shows up as +# unused. One more kilogram crosses 100 kPa over the cushion's 0.01 m^2 face: the stack +# is refused, the brick opens a second crate, and half of each crate is empty. +print("\ncompressible -- a cushion that yields to the load above it") +load("brick 101 kg", 101) +load("brick 102 kg", 102) + +# Both shapes are refused rather than approximated wherever an engine cannot honour them +# exactly -- a hull on a route, a hull under a configured clearance, a compressible item +# with `nesting_height`. A wrong answer that validates is worse than a refusal that does +# not, which is the whole reason these are opt-in. diff --git a/pyproject.toml b/pyproject.toml index 2952a89..60a60ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "packvium" -version = "0.1.3" +version = "1.0.0" description = "Deterministic, extensible 3D cartonization and rectangular bin-packing library" readme = "README.md" requires-python = ">=3.9" diff --git a/src/packvium/bounds.py b/src/packvium/bounds.py new file mode 100644 index 0000000..b0196a2 --- /dev/null +++ b/src/packvium/bounds.py @@ -0,0 +1,395 @@ +"""Lower bounds on the objective vector, and the gap against an incumbent. + +The mathematics is fixed by [docs/OPTIMALITY-CERTIFICATES.md](../../../docs/OPTIMALITY-CERTIFICATES.md) +and `scripts/optimality_bounds.py` is the independent oracle. This module is written from +the document and never imports the oracle, so the property tests compare two +implementations rather than one implementation with itself -- the discipline set +for irregular items and restated here. + +What a bound is for. Every solver in this project is a heuristic: it returns an +arrangement and has no notion of what it did not try. A bound is the other half of that +sentence -- the best score the request *could* admit, computed by relaxing the problem +until it becomes arithmetic. Where the achieved score meets the bound, the search is over +whether or not it explored anything, and the engine knows it. + +What it is not. These bounds relax geometry away entirely. Attaining one certifies +optimality *of the relaxation*, never of the packing, and the difference between those two +claims is the difference between a true statement and a marketing one. + +Instances that occupy less than their box. The document states this rule for +`nesting_height`: such an instance occupies less than its nominal volume, so nominal volumes +stop summing and every capacity argument built on them stops being a bound. The volume terms +are *dropped* rather than scaled -- a bound that is sometimes wrong is not a bound. + +`convex_hull` and `compressible` have exactly the same property and the document does not +say so, because was written before the irregular shapes existed. A hull occupies +its hull rather than its bounding box, and a compressible item gives up height under load; +summing nominal box volumes over-states what a solution must carry, which over-states the +container count. +Measured on the golden corpus, that made the bound unsound on three fixtures -- +`feature-convex-hull-wedges-share-one-crate`, `feature-route-hull-volume-reserve` and +`feature-compressible-volume-reserve-recomputes-loads` -- each claiming two containers were +necessary where one sufficed. They are treated here by the document's own rule. + +That is the conservative repair, not the best one. A hull's exact volume is already computed +by `hull.shape_for`, and using it would keep the volume argument alive and tighter instead of +dropping it; a compressible item has a computable fully-compressed floor. Both are new +mathematics and belong to the design task, not to this implementation of it -- recorded in +`docs/OPTIMALITY-CERTIFICATES.md` rather than invented here. + +Objective key order. The bounds are keyed to the *default* objective, +`(unpacked_count, container_count, total_cost_minor, unused_volume_ppm, stack_height_ppm)`. +`lowest_cost` and `maximum_value` reorder those keys, so a bound vector compared against +their score vectors compares different quantities and means nothing. Callers pass the +objective they scored with. + +Complexity. `O(n log n + c log c)` for `n` instances and `c` container types -- one sort of +the instance volumes, one of the weights, one of the per-unit costs. No geometry is touched +and no candidate position is generated, which is why this can be computed at the root of a +search rather than inside it. +""" + +from __future__ import annotations + +from typing import Iterable, Sequence, Tuple + +from ._compat import dataclass +from .constraints import usable_volume +from .geometry import ShapeType +from .models import Container, Item, ItemInstance + +#: Parts per million, the fixed-point scale keys 3 and 4 of the objective are carried at. +PPM = 1_000_000 + +#: Every sum in the bound path must stay below this. +#: +#: Declared rather than inherited from the language. Python's integers are unbounded, PHP's +#: silently become doubles on overflow, JavaScript's `Number` stops being exact past 2^53 and +#: Rust's `i128` wraps -- so if each engine refused at its own limit the four would disagree +#: about which requests are answerable. The value comes from the widest intermediate the +#: formulas form: keys 3 and 4 multiply a summed volume by `PPM`, so `10^30 * 10^6 = 10^36` +#: sits about 170-fold inside `i128`. It never binds on a real request: `10^30` cubic ticks is +#: 244 million cubic metres, and a request that trips it has a data error in it. +MAX_BOUND_SUM = 10 ** 30 + +#: Largest integer every binding can return without changing its value. +#: +#: Intermediate arithmetic deliberately has the wider ``MAX_BOUND_SUM`` ceiling, but the +#: result crosses JSON and JavaScript's ``Number`` boundary. Keeping the five public result +#: keys inside ``2**53 - 1`` makes "byte-identical" mean numerically identical as well as +#: textually similar; a larger cost must be refused before one engine rounds it. +MAX_BOUND_VALUE = 2 ** 53 - 1 + + +class BoundOverflowError(ValueError): + """A sum in the bound path exceeded the declared ceiling. + + A structured refusal rather than a number, because the alternative is the failure + Baldacci et al. document for floating-point bin-packing solvers: a bound that is quietly + wrong and carries no signal that it is. Every engine refuses at the same declared + ceiling, so a request is either answerable everywhere or refused everywhere. + """ + + +class UnsoundBoundError(ValueError): + """An achieved score fell below its own lower bound. + + One of the two is wrong, and saying so is more useful than reporting a negative gap. + This is the assertion that fires first if a bound is ever made unsound, which is why it + raises rather than clamping. + """ + + +@dataclass(frozen=True, slots=True) +class Bounds: + """The five lower bounds, in the objective's own key order. + + Each is conditional on the keys before it: `container_count` is the least number of + containers *given* that `unpacked_count` items were left behind, and so on down. That + is what makes them comparable to a score vector key by key, and also why the gap stops + at the first key that misses. + """ + + unpacked_count: int + container_count: int + total_cost_minor: int + unused_volume_ppm: int + stack_height_ppm: int + + def as_tuple(self) -> Tuple[int, int, int, int, int]: + return (self.unpacked_count, self.container_count, self.total_cost_minor, + self.unused_volume_ppm, self.stack_height_ppm) + + +@dataclass(frozen=True, slots=True) +class Gap: + """How far an incumbent stands from the bound, on the first key that misses. + + `relative` is an exact rational pair `(numerator, denominator)` rather than a float: + the reference arithmetic stays exact and each binding formats its own percentage + without binary floating point becoming part of a certificate. It is `None` -- undefined, + not substituted -- when the bound on that key is zero, which is the common case for + `unpacked_count`. Dividing by a stand-in denominator would produce a number that looks + like a percentage and is not one. + """ + + #: Index of the first key where the incumbent exceeds its bound, or `None` when every + #: key is attained and the incumbent is optimal for this relaxation. + key: "int | None" + absolute: int + relative: "Tuple[int, int] | None" + + @property + def attained(self) -> bool: + return self.key is None + + +def _capacity_total(values: Iterable["int | None"], quantities: Iterable["int | None"], + unbounded_when_value_infinite: bool) -> "int | None": + """`Σc value · quantity`, or `None` for an unbounded total. + + `None` means infinity on the way in and on the way out. A limit nobody declared cannot + be summed, and a single unlimited type makes the whole capacity unbounded -- except for + volume, where a container with no usable volume adds nothing however many of it there + are, which is why the caller says which rule applies. + """ + total = 0 + for value, quantity in zip(values, quantities): + if value is None: + if unbounded_when_value_infinite: + return None + continue + if quantity is None: + if value > 0: + return None + continue + total += value * quantity + return _guard(total, "container capacity") + + +def _fit(ascending: Sequence[int], capacity: "int | None") -> int: + """The largest `n` such that the `n` smallest values sum to at most `capacity`. + + Smallest first, and that is the whole soundness argument: taking the cheapest units + maximises how many fit under one capacity, so this over-estimates what any real packing + achieves. Geometry, support ratios, stacking rules, incompatible tags and route order + can each make the real answer worse and none of them can make it better. + + The caller passes an already-ascending sequence. `compute` sorts the volumes and weights + once for the later keys, and sorting them again here would have doubled the only + superlinear work this module does. + """ + if capacity is None: + return len(ascending) + used = 0 + for taken, cost in enumerate(ascending): + used += cost + if used > capacity: + return taken + return len(ascending) + + +def _guard(total: int, quantity: str) -> int: + """Refuse a sum past the declared ceiling instead of carrying it further.""" + if total > MAX_BOUND_SUM: + raise BoundOverflowError( + f"{quantity} sums to {total}, above the {MAX_BOUND_SUM} ceiling the bound path " + "declares; refusing rather than returning a number no engine can agree on" + ) + return total + + +def _guard_output(value: int, quantity: str) -> int: + """Refuse a result that cannot cross every binding exactly.""" + if value > MAX_BOUND_VALUE: + raise BoundOverflowError( + f"{quantity} bound is {value}, above the {MAX_BOUND_VALUE} exact portable " + "result ceiling; refusing rather than rounding it in JavaScript" + ) + return value + + +def _ceil_div(numerator: int, denominator: int) -> int: + return -(-numerator // denominator) + + +def _finite_max(values: Sequence["int | None"]) -> "int | None": + """The largest declared limit, or `None` if any type declares none. + + One unlimited type makes the maximum unbounded and every term conditioned on it + vacuous, which is why this collapses to `None` rather than ignoring the gap. + """ + return None if any(value is None for value in values) else max(values) + + +def _occupies_less_than_its_box(item: Item) -> bool: + """Can this item take up less room than its declared dimensions? + + Three ways, and every one of them breaks the same argument -- that nominal volumes sum + to something a solution must carry. A nested item sinks into the one below it; a + `convex_hull` occupies its hull and leaves the rest of its bounding box free, which is + the entire reason that shape exists; a `compressible` item gives up height under load. + + The document names only the first because it predates the other two. Asking the question + once, here, is what keeps a future fourth shape from reintroducing the same unsoundness + silently -- a new `ShapeType` that is anything but a solid box has to pass this line. + """ + if item.nesting_height is not None: + return True + return item.shape_type in (ShapeType.CONVEX_HULL, ShapeType.COMPRESSIBLE) + + +def compute(instances: Sequence[ItemInstance], containers: Sequence[Container]) -> Bounds: + """Every bound for one request, at the root of a search. + + The instances are the quantity-expanded list the solver is about to place, and the + containers are the types it may open -- both exactly as the request declares them, + before any placement decision has been taken. + """ + volumes = sorted(instance.item.dimensions.volume for instance in instances) + weights = sorted(instance.weight.ticks for instance in instances) + _guard(sum(volumes), "instance volume") + _guard(sum(weights), "instance weight") + nests = any(_occupies_less_than_its_box(instance.item) for instance in instances) + count = len(instances) + + usable = [usable_volume(container) for container in containers] + inner = [container.inner_dimensions.volume for container in containers] + areas = [container.inner_dimensions.base_area for container in containers] + heights = [container.inner_dimensions.height.ticks for container in containers] + payloads = [None if c.max_payload is None else c.max_payload.ticks for c in containers] + slots = [c.max_items for c in containers] + quantities = [c.quantity for c in containers] + costs = [c.cost_minor for c in containers] + + unpacked = _unpacked_bound(volumes, weights, nests, count, usable, payloads, slots, + quantities) + placed = count - unpacked + opened = _container_bound(volumes, weights, nests, placed, containers, usable, payloads, + slots) + cost = _cost_bound(costs, quantities, opened) + unused = _unused_volume_bound(volumes, nests, placed, inner, opened) + height = _stack_height_bound(volumes, nests, placed, areas, heights, opened) + return Bounds( + _guard_output(unpacked, "unpacked count"), + _guard_output(opened, "container count"), + _guard_output(cost, "opening cost"), + _guard_output(unused, "unused volume"), + _guard_output(height, "stack height"), + ) + + +def _unpacked_bound(volumes, weights, nests, count, usable, payloads, slots, + quantities) -> int: + """`L0`: remove the geometry entirely and ask what the declared resources alone forbid. + + Every instance becomes freely selectable, and only the three additive resources the + request declares can stop one being placed. With two resources the minimum over each + separately is a genuine relaxation and can fall below the true two-resource optimum; it + remains a bound, which is all that is claimed. + """ + volume_capacity = _capacity_total(usable, quantities, unbounded_when_value_infinite=False) + payload_capacity = _capacity_total(payloads, quantities, unbounded_when_value_infinite=True) + slot_capacity = _capacity_total(slots, quantities, unbounded_when_value_infinite=True) + + placeable = count + if not nests: + placeable = min(placeable, _fit(volumes, volume_capacity)) + placeable = min(placeable, _fit(weights, payload_capacity)) + if slot_capacity is not None: + placeable = min(placeable, slot_capacity) + return count - placeable + + +def _container_bound(volumes, weights, nests, placed, containers, usable, payloads, + slots) -> int: + """`L1`: grant every container the largest capacity available, which can only understate + how many are needed.""" + if placed <= 0 or not containers: + return 0 + bound = 1 + if not nests: + largest_usable = max(usable) + if largest_usable > 0: + bound = max(bound, _ceil_div(sum(volumes[:placed]), largest_usable)) + largest_payload = _finite_max(payloads) + if largest_payload is not None and largest_payload > 0: + bound = max(bound, _ceil_div(sum(weights[:placed]), largest_payload)) + largest_slots = _finite_max(slots) + if largest_slots is not None and largest_slots > 0: + bound = max(bound, _ceil_div(placed, largest_slots)) + return bound + + +def _cost_bound(costs, quantities, opened) -> int: + """`L2`: at least `L1` containers open, each costing at least the cheapest the inventory + still holds. + + Inventory is respected rather than assumed unlimited. Charging the cheapest type `L1` + times would also be a bound, and a weaker one whenever that type is nearly exhausted; + the difference costs one sort. + """ + if opened <= 0: + return 0 + available: list[int] = [] + for cost, quantity in zip(costs, quantities): + available.extend([cost] * (opened if quantity is None else min(quantity, opened))) + available.sort() + return _guard(sum(available[:opened]), "opening cost") + + +def _unused_volume_bound(volumes, nests, placed, inner, opened) -> int: + """`L3`: the fill is largest when every container is the smallest available and holds the + greatest volume that could be placed at all. + + Key 3 sums a *per-container* ratio, so a tight bound would have to know which items went + where -- that is the packing problem, not a relaxation of it. The `L1 - 1` term is the + exact worst case by which summing `k` ceilings can exceed the ceiling of the sum. + """ + if nests or opened <= 0 or not inner: + return 0 + smallest_inner = min(inner) + if smallest_inner <= 0: + return 0 + largest_placed = sum(volumes[len(volumes) - placed:]) if placed > 0 else 0 + return max(0, opened * PPM - _ceil_div(largest_placed * PPM, smallest_inner) - (opened - 1)) + + +def _stack_height_bound(volumes, nests, placed, areas, heights, opened) -> int: + """`L4`: the volume that must be placed has to stand at least as tall as itself spread + across the widest floor available, in the tallest container available. + + The `L1 - 1` correction is not a cautionary fudge. The objective floors each container's + ratio *before* summing, and the sum of `k` floors can be one less than the floor of the + sum at each of the first `k - 1` boundaries. Without it, two one-tick loads in height-6 + containers score `166666 + 166666 = 333332` while flooring their aggregate claims + `333333` -- one ppm above a feasible solution, and therefore not a bound. + """ + if nests or opened <= 0 or not areas: + return 0 + widest = max(areas) + tallest = max(heights) + if widest <= 0 or tallest <= 0: + return 0 + required = _ceil_div(sum(volumes[:placed]), widest) if placed > 0 else 0 + return max(0, required * PPM // tallest - (opened - 1)) + + +def gap(score: Sequence[int], bound: Bounds) -> Gap: + """The distance from an incumbent score to its bound, on the first key that misses. + + One key, not five: the keys after the first miss were computed on an assumption that has + just been shown false -- that the earlier keys were attained -- so they say nothing and + are not reported. + """ + limits = bound.as_tuple() + for index, (achieved, least) in enumerate(zip(score, limits)): + if achieved < least: + raise UnsoundBoundError( + f"key {index} scored {achieved}, below its lower bound of {least}; " + "one of the two is wrong" + ) + if achieved > least: + absolute = achieved - least + return Gap(index, absolute, (absolute, least) if least > 0 else None) + return Gap(None, 0, None) diff --git a/src/packvium/compression.py b/src/packvium/compression.py new file mode 100644 index 0000000..933b00f --- /dev/null +++ b/src/packvium/compression.py @@ -0,0 +1,158 @@ +"""Occupied height of a `compressible` item under the load resting on it. + +The model is fixed by [docs/IRREGULAR-ITEMS.md](../../../docs/IRREGULAR-ITEMS.md) and is +reproduced here rather than imported: `scripts/irregular_items_model.py` is the independent +oracle those numbers are cross-checked against, and an engine that imported it would be +checking the oracle against itself. + +Every value on this path is an exact integer or a reduced rational. Pressure is carried as a +numerator/denominator pair rather than a `Fraction` for two reasons: the hard limit is a +comparison, which cross multiplication answers without dividing at all, and PHP, Rust and +JavaScript have no rational type to port a `Fraction` to ( through ). +""" + +from __future__ import annotations + +from math import gcd + +from ._compat import dataclass +from .units import Length, Weight + +#: Parts per million, the scale `compression_ratio` is carried at once parsed. Shared with +#: `constraints.SUPPORT_SCALE` by value rather than by import: the two ratios are unrelated +#: quantities that happen to use the same precision, and coupling them would make a change +#: to one silently redefine the other. +PPM = 1_000_000 + +#: Conventional standard gravity, exactly 9.80665 m/s^2. A decimal literal would put the +#: first float on a path the contract requires to be exact. +STANDARD_GRAVITY_NUMERATOR = 980_665 +STANDARD_GRAVITY_DENOMINATOR = 100_000 + +#: Pascals per kilopascal. +PASCALS_PER_KILOPASCAL = 1_000 + +_TICKS_PER_METRE = Length.TICKS_PER_MM * 1_000 + + +class CrushViolation(ValueError): + """Applied pressure is strictly above the item's declared limit. + + A hard boundary, not a warning: a placement that crushes its item is not a worse + placement, it is an invalid one, and no score derived after it may be returned. + """ + + +@dataclass(frozen=True, slots=True) +class Pressure: + """An exact pressure in kPa, held as a reduced non-negative rational.""" + + numerator: int + denominator: int + + def __post_init__(self) -> None: + if self.denominator <= 0: + raise ValueError("pressure denominator must be positive") + if self.numerator < 0: + raise ValueError("pressure cannot be negative") + + @classmethod + def zero(cls) -> "Pressure": + return cls(0, 1) + + @classmethod + def reduced(cls, numerator: int, denominator: int) -> "Pressure": + if denominator <= 0: + raise ValueError("pressure denominator must be positive") + divisor = gcd(numerator, denominator) + return cls(numerator // divisor, denominator // divisor) + + def exceeds_kpa(self, limit_kpa: int) -> bool: + """Cross multiplication, so the comparison never leaves the integers.""" + return self.numerator > limit_kpa * self.denominator + + def __str__(self) -> str: + return f"{self.numerator}/{self.denominator} kPa" + + +def applied_pressure(top_load: Weight, footprint_area_ticks2: int) -> Pressure: + """Pressure from the cumulative mass resting above an item, over its footprint. + + The item's own mass is excluded -- it is not a load on itself -- and the footprint is + the uncompressed one, which compression never changes. + """ + # No negative-load guard here: `Weight` already refuses one at construction, and a + # second copy of that invariant would be unreachable code claiming to protect something. + if footprint_area_ticks2 <= 0: + raise ValueError("footprint area must be positive") + numerator = top_load.ticks * STANDARD_GRAVITY_NUMERATOR * _TICKS_PER_METRE * _TICKS_PER_METRE + denominator = ( + Weight.TICKS_PER_KG + * STANDARD_GRAVITY_DENOMINATOR + * PASCALS_PER_KILOPASCAL + * footprint_area_ticks2 + ) + return Pressure.reduced(numerator, denominator) + + +def effective_height_ticks( + height_ticks: int, + compression_ratio_ppm: int, + max_pressure_kpa: int, + pressure: Pressure, +) -> int: + """Occupied height under load, rounded up, never below one tick. + + Rounding up is what keeps a discrete packer honest: it may never claim less space than + the continuous model allows. The one-tick floor stops a fully compressible item from + reaching zero height, where it would slip past collision and support invariants + entirely rather than merely occupying very little. + """ + if height_ticks <= 0: + raise ValueError("height must be positive") + if not 0 <= compression_ratio_ppm <= PPM: + raise ValueError("compression ratio must be between zero and one million ppm") + if max_pressure_kpa < 0: + raise ValueError("maximum pressure cannot be negative") + if pressure.exceeds_kpa(max_pressure_kpa): + raise CrushViolation( + f"applied pressure {pressure} exceeds the declared limit of {max_pressure_kpa} kPa" + ) + # With no headroom declared, the only admissible pressure is zero -- already proven by + # the guard above -- so the item is simply uncompressed. Returning here also keeps the + # divisor below non-zero. + if max_pressure_kpa == 0: + return height_ticks + divisor = max_pressure_kpa * PPM * pressure.denominator + # Non-negative: the crush guard above bounds `numerator` by `max_pressure_kpa * + # denominator`, and `compression_ratio_ppm` by `PPM`, so the product cannot exceed + # `divisor`. A fully compressible item at its exact limit retains zero and floors at one. + retained = divisor - compression_ratio_ppm * pressure.numerator + return max(1, (height_ticks * retained + divisor - 1) // divisor) + + +def effective_volume_ticks3( + length_ticks: int, + width_ticks: int, + height_ticks: int, + compression_ratio_ppm: int, + max_pressure_kpa: int, + pressure: Pressure, +) -> int: + """Occupied volume under load. Only the height compresses; the footprint is fixed.""" + if length_ticks <= 0 or width_ticks <= 0: + raise ValueError("footprint dimensions must be positive") + return length_ticks * width_ticks * effective_height_ticks( + height_ticks, compression_ratio_ppm, max_pressure_kpa, pressure + ) + + +def ratio_to_ppm(ratio: float) -> int: + """The existing public ratio rule, `floor(ratio * 1000000 + 0.5)`, applied once. + + Applied once and at the boundary, so the float a caller supplied never reaches the + geometry: everything downstream of this function is an integer. + """ + if not 0.0 <= ratio <= 1.0: + raise ValueError("compression_ratio must be between zero and one") + return int(ratio * PPM + 0.5) diff --git a/src/packvium/config.py b/src/packvium/config.py index d825b09..917e037 100644 --- a/src/packvium/config.py +++ b/src/packvium/config.py @@ -53,6 +53,16 @@ class PackingConfig: container_plan_beam_width: int = 1 #: Hard counted-work ceiling for container-plan nodes, independent of wall time. container_plan_node_limit: int = 1 + #: The container walls an item may be unloaded through, for the + #: stop-accessibility constraint. Empty (the default) disables the check entirely and + #: reproduces every existing result byte-for-byte. + #: + #: Programmatic only, and deliberately so: the request schema has no access-directions + #: field yet (docs/STOP-ACCESSIBILITY.md files it as deferred until a contract freeze), + #: and defaulting to all six walls would enforce a rule true of no real vehicle. So a + #: caller who wants the check states the doors in code, the same non-request path + #: `safe_route_removal_order` is driven through today. + access_directions: tuple[str, ...] = () def __post_init__(self) -> None: if (self.time_limit_ms <= 0 or self.top_k <= 0 or self.exact_item_limit <= 0 diff --git a/src/packvium/constraints.py b/src/packvium/constraints.py index 2c8d587..6d66a37 100644 --- a/src/packvium/constraints.py +++ b/src/packvium/constraints.py @@ -5,10 +5,12 @@ from .axle_load import axle_load_exceeded from .contact import ContactEdge, ContactGraph -from .geometry import AxisAlignedBox, Dimensions, Point, Rotation +from .geometry import (ALL_DIRECTIONS, AxisAlignedBox, Dimensions, InvalidDirectionError, + Point, Rotation, sweep_intersects, swept_volume) from .models import Container, ItemInstance, Placement from .support_polygon import contact_hull_points, convex_hull, doubled_centroid, point_in_hull -from .units import Length +from .compression import applied_pressure +from .units import Length, Weight # Support ratios arrive as floats from the public API but must never decide feasibility # in floating point. They are converted once to a scaled integer and every comparison @@ -111,6 +113,11 @@ class LoadUnit: label: str nesting_item_id: str | None = None nesting_height_ticks: int | None = None + # Set only for a `compressible` item. Load propagation already computes the + # cumulative mass above every unit, which is exactly the numerator the pressure model + # needs, so the crush check rides the graph that is built anyway rather than a second one. + compression_ratio_ppm: int | None = None + max_compression_pressure_kpa: int | None = None @dataclass(frozen=True, slots=True) @@ -222,10 +229,10 @@ class LoadSupportGraph: order, preserving ContactGraph's integer-remainder and traversal contract. """ - __slots__ = ("_supporters", "_children") + __slots__ = ("_supporters", "_children", "_face", "_units", "_nested") - def __init__(self, units: Sequence[LoadUnit]): - face = ContactGraph([unit.box for unit in units]) + def __init__(self, units: Sequence[LoadUnit], cell_hint: int = 1): + face = ContactGraph([unit.box for unit in units], cell_hint=cell_hint) nesting = sorted( ( unit.nesting_item_id, @@ -241,6 +248,9 @@ def __init__(self, units: Sequence[LoadUnit]): for index, unit in enumerate(units) if unit.nesting_item_id is not None and unit.nesting_height_ticks is not None ) + self._face = face + self._units = tuple(units) + self._nested = bool(nesting) if not nesting: self._supporters = tuple(face.supporters(index) for index in range(len(units))) self._children = tuple(face.children(index) for index in range(len(units))) @@ -279,6 +289,35 @@ def __init__(self, units: Sequence[LoadUnit]): self._supporters = tuple(tuple(edges) for edges in supporters) self._children = tuple(tuple(indices) for indices in children) + def with_unit(self, unit: LoadUnit, cell_hint: int = 1) -> "LoadSupportGraph": + """This graph plus one more unit, appended at the next index. + + The search evaluates many candidates against one unchanged set of placements, and + rebuilding the whole support graph for each of them was the cost exists to + remove. Adding a box cannot change contact between two boxes already placed, so + the face graph only needs its two planes queried -- see `ContactGraph.with_box`. + + Nesting is the exception and falls back to a full rebuild. A nesting predecessor + *replaces* the face edges of everything in its column, so one new unit can rewrite + edges arbitrarily far from itself and the delta is no longer local. Nesting is an + opt-in field on a minority of requests; correctness there is worth more than the + speed, and the fallback keeps this method total. + """ + if self._nested or unit.nesting_item_id is not None: + return LoadSupportGraph(self._units + (unit,), cell_hint=cell_hint) + index = len(self._units) + face = self._face.with_box(unit.box) + # Without nesting this graph *is* the face graph, so read the edges straight off + # it rather than patching a copy of the old ones. Re-deriving them by hand would + # be a second implementation of the same rule, free to drift from the first. + graph = LoadSupportGraph.__new__(LoadSupportGraph) + graph._supporters = tuple(face.supporters(i) for i in range(index + 1)) + graph._children = tuple(face.children(i) for i in range(index + 1)) + graph._face = face + graph._units = self._units + (unit,) + graph._nested = False + return graph + def supporters(self, index: int) -> tuple[ContactEdge, ...]: return self._supporters[index] @@ -307,7 +346,9 @@ def load_units(placements: Sequence[Placement], extra: LoadUnit | None = None) - p.instance.item.max_stacked_items, p.instance.id, None if p.instance.item.nesting_height is None else p.instance.item.id, - None if p.instance.item.nesting_height is None else p.instance.item.nesting_height.ticks) + None if p.instance.item.nesting_height is None else p.instance.item.nesting_height.ticks, + p.instance.item.compression_ratio_ppm, + p.instance.item.max_compression_pressure_kpa) for p in placements ] if extra is not None: units.append(extra) @@ -346,6 +387,30 @@ def overloaded(units: Sequence[LoadUnit], graph: LoadSupportGraph | None = None) return None +def crushed(units: Sequence[LoadUnit], graph: LoadSupportGraph | None = None) -> tuple[str, str] | None: + """First compressible unit carrying more pressure than it declared it can take. + + Deliberately shaped like `overloaded`, and reading the same `top_loads` result, because + they answer the same question in two currencies: `max_top_load` is a mass the box below + must bear, and `max_compression_pressure_kpa` is a pressure the item itself must survive. + An item can pass one and fail the other, so both are asked. + + A crush is a hard boundary, not a worse score. The caller gets a refusal rather than a + plan in which something arrived flattened. + """ + if all(unit.max_compression_pressure_kpa is None for unit in units): + return None + for unit, load in zip(units, top_loads(units, graph)): + limit = unit.max_compression_pressure_kpa + if limit is None: + continue + box = unit.box + footprint = (box.x2 - box.origin.x) * (box.y2 - box.origin.y) + if applied_pressure(Weight(load), footprint).exceeds_kpa(limit): + return ("crush_violation", unit.label) + return None + + def resting_above(units: Sequence[LoadUnit], graph: LoadSupportGraph | None = None) -> list[frozenset[int]]: """Everything resting anywhere above each unit, following the support graph upward. @@ -546,6 +611,17 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: # above cannot see. Checked only once a minimum ratio is already being # enforced, so a caller who never asked for support checking sees no new # rejection code and no behaviour change. + # + # Above half the base the answer is already decided, so the hull is not built + #. A footprint is centrally symmetric, so every line through its centre + # bisects its area; a centroid outside the contact hull would put the whole + # contact region in one open half-plane through that centre, and therefore under + # half the base. Contact above half the base thus cannot leave the centroid + # outside -- whatever the number of supporters. Measured before it was proved: the + # shipped conjunction refused exactly what the ratio refused across the pinned + # corpus at ratio 0.6, and refused strictly more at 0.3 and 0.45. + if supporting_area * 2 > base_area: + return ConstraintResult.allow() hull = convex_hull(contact_hull_points(candidate, supporters)) if not point_in_hull(doubled_centroid(candidate), hull): return ConstraintResult.reject("centre_of_gravity_unsupported", f"{supporting_area}/{base_area} met but centroid outside the {len(hull)}-point support hull") @@ -557,21 +633,59 @@ class TopLoadConstraint: Bearing limits are checked against the cumulative load of the whole stack, not only the box directly underneath, so a tower of light items cannot crush its base. + + The support graph over the *placed* boxes is the same for every candidate evaluated + against one search state, and rebuilding it per candidate was the cost + removes. One base per placement tuple is kept here and each candidate is appended to + it. The cache is deliberately a single entry compared by identity: search evaluates a + run of candidates against one state before moving on, so a one-entry cache captures + the whole run, and holding the tuple keeps `is` sound because the object cannot be + collected and its identity reused while the cache refers to it. """ + __slots__ = ("_placements", "_base", "_base_units", "_hint") + + def __init__(self) -> None: + self._placements: tuple[Placement, ...] | None = None + self._base: LoadSupportGraph | None = None + self._base_units: tuple[LoadUnit, ...] = () + self._hint = 1 + + def _base_for(self, placements: tuple[Placement, ...], footprint: int): + """The support graph over `placements` alone, rebuilt only when it cannot serve. + + The cell hint has to cover every candidate that will be appended to this base, + and the widest of them is not known in advance -- a candidate is a *new* item and + may be the widest in the request. So the hint grows to fit the first candidate + that needs it and the base is rebuilt that once; after that the run is served from + cache. Sizing it from the container instead would always be safe and always + coarse, and a cell far larger than the boxes collapses the spatial hash back into + the all-pairs scan it exists to avoid. + """ + if self._placements is placements and footprint <= self._hint: + return self._base, self._base_units + hint = max(self._hint if self._placements is placements else 1, footprint) + units = load_units(placements) + self._placements = placements + self._hint = hint + self._base_units = units + self._base = LoadSupportGraph(units, cell_hint=hint) + return self._base, units + def evaluate(self, context: ConstraintContext) -> ConstraintResult: if not context.stack_sensitive: return ConstraintResult.allow() candidate = context.envelope_box item = context.item.item - units = load_units( - context.placements, - LoadUnit(candidate, context.item.weight.ticks, - None if item.max_top_load is None else item.max_top_load.ticks, - item.max_stacked_items, context.item.id, - None if item.nesting_height is None else item.id, - None if item.nesting_height is None else item.nesting_height.ticks), - ) - graph = LoadSupportGraph(units) + unit = LoadUnit(candidate, context.item.weight.ticks, + None if item.max_top_load is None else item.max_top_load.ticks, + item.max_stacked_items, context.item.id, + None if item.nesting_height is None else item.id, + None if item.nesting_height is None else item.nesting_height.ticks, + item.compression_ratio_ppm, item.max_compression_pressure_kpa) + footprint = max(candidate.x2 - candidate.origin.x, candidate.y2 - candidate.origin.y) + base, base_units = self._base_for(context.placements, footprint) + units = base_units + (unit,) + graph = base.with_unit(unit, cell_hint=self._hint) failure = non_stackable_failure( context.placements, context.item, graph, len(units) - 1 ) @@ -580,6 +694,7 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: density_limit = None if context.container.max_stack_density is None else context.container.max_stack_density.ticks failure = ( overloaded(units, graph) + or crushed(units, graph) or stack_limit_exceeded(units, graph) or stack_density_exceeded(units, density_limit, graph) ) @@ -618,6 +733,139 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: return ConstraintResult.allow() +def _stop_of(placement: Placement) -> int | float: + """A placement's stop as an ordering value, with the absent case as `inf`. + + An item with no `stop_index` rides the whole route, so it is never removed and blocks + every stop -- which is exactly what `inf` gives when the blocker test is `s(q) > s(p)`. + Making it a sentinel value rather than a separate branch is what lets one comparison + cover both a late-stop blocker and permanent cargo. + + A present stop stays an `int` and is never widened to `float`. The schema puts no + ceiling on `stop_index`, and past 2**53 a float cannot tell two consecutive stops + apart -- which would silently merge them into one and hand the same-stop exclusion an + item it must not excuse. Mixed int/inf comparison is exact in Python, so the sentinel + costs nothing here. + """ + stop = placement.instance.item.stop_index + return RIDES_THE_WHOLE_ROUTE if stop is None else stop + + +class StopAccessibilityConstraint: + """Rejects a placement that walls an earlier-stop item away from every door. + + `RouteOrderConstraint` above enforces the vertical half of route order -- nothing due + later may rest *above* something due earlier. This is the horizontal half: nothing due + later may stand *between* an earlier item and the way out. Both are necessary and + neither implies the other; docs/STOP-ACCESSIBILITY.md derives the rule and the + post-validator's whole-scene replay remains the sufficient check. + + Opt-in twice over, and both are load-bearing. It is inert unless the caller supplies + exit directions, because the request schema has no field for them: assuming all six + walls open would enforce a rule that is true of no real vehicle and nearly vacuous + besides, since a box is almost always free through *some* face. And it is inert unless + two distinct stops are in play, which is what keeps a caller who never populates + `stop_index` paying nothing. + + The blocker set is `{q : s(q) > s(p)}` -- strictly later. Items due at the *same* stop + are excluded because the order within a stop is free: whichever is in the way comes off + first. Using `>=` would refuse two same-stop pallets standing one behind the other, + which is an ordinary load. + """ + + __slots__ = ("_directions", "_placements", "_container", "_clear", "_stops") + + def __init__(self, directions: Sequence[str] = ()) -> None: + for direction in directions: + if direction not in ALL_DIRECTIONS: + raise InvalidDirectionError(direction) + # Deduplicated in the canonical order rather than as given: two callers passing the + # same doors in different orders must search identically. + self._directions = tuple(d for d in ALL_DIRECTIONS if d in set(directions)) + self._placements: tuple[Placement, ...] | None = None + self._container: Dimensions | None = None + self._clear: tuple[frozenset[str], ...] = () + self._stops: tuple[float, ...] = () + + def _base_for(self, placements: tuple[Placement, ...], container: Dimensions): + """Per placed box, the doors still open to it against the already-placed boxes. + + Cached by tuple identity for the same reason `TopLoadConstraint` does it: the + search evaluates a run of candidates against one state, so a single entry covers + the whole run, and holding the tuple keeps `is` sound. + """ + # Keyed on the container as well as the placements, because a corridor runs to a + # *wall*: the same boxes have different exits in a longer container, and reusing + # the answer across two would silently accept a placement that walls an item in. + if self._placements is placements and self._container == container: + return self._clear, self._stops + stops = tuple(_stop_of(p) for p in placements) + boxes = [p.envelope_box for p in placements] + clear = [] + for index, box in enumerate(boxes): + if stops[index] == RIDES_THE_WHOLE_ROUTE: + # Never unloaded, so it needs no door of its own -- it only ever blocks. + clear.append(frozenset(self._directions)) + continue + open_doors = frozenset( + direction for direction in self._directions + if not any(other != index and stops[other] > stops[index] + and sweep_intersects(swept_volume(box, container, direction), boxes[other]) + for other in range(len(boxes))) + ) + clear.append(open_doors) + self._placements = placements + self._container = container + self._clear = tuple(clear) + self._stops = stops + return self._clear, self._stops + + def evaluate(self, context: ConstraintContext) -> ConstraintResult: + if not self._directions: return ConstraintResult.allow() + if not context.route_sensitive: return ConstraintResult.allow() + + candidate_stop = context.item.item.stop_index + if candidate_stop is None: candidate_stop = RIDES_THE_WHOLE_ROUTE + inner = context.container.inner_dimensions + clear, stops = self._base_for(context.placements, inner) + + # One distinct stop means nothing can be due before anything else, so no corridor + # can be blocked by a later item. Checked over the candidate too, or the first + # placement into an empty container would skip a check it should make. + if len({*stops, candidate_stop}) < 2: + return ConstraintResult.allow() + + candidate = context.envelope_box + for index, placement in enumerate(context.placements): + if not (candidate_stop > stops[index]): continue + if not clear[index]: continue + still_open = any( + not sweep_intersects( + swept_volume(placement.envelope_box, inner, direction), candidate) + for direction in clear[index] + ) + if not still_open: + return ConstraintResult.reject( + "stop_accessibility_violation", + f"{placement.instance.id} due at stop {stops[index]} " + f"loses its last exit to {context.item.id}") + + if candidate_stop == RIDES_THE_WHOLE_ROUTE: + return ConstraintResult.allow() + + boxes = [p.envelope_box for p in context.placements] + if not any( + not any(stops[other] > candidate_stop + and sweep_intersects(swept_volume(candidate, inner, direction), boxes[other]) + for other in range(len(boxes))) + for direction in self._directions + ): + return ConstraintResult.reject( + "stop_accessibility_violation", + f"{context.item.id} due at stop {candidate_stop} would have no exit") + return ConstraintResult.allow() + + class AxleLoadConstraint: """Rejects a placement that would push either of a two-axle container's axles over its own limit. diff --git a/src/packvium/contact.py b/src/packvium/contact.py index a75b661..c5008bd 100644 --- a/src/packvium/contact.py +++ b/src/packvium/contact.py @@ -75,19 +75,38 @@ class ContactGraph: forced them to share the definition. """ - __slots__ = ("_supporters", "_children") - - def __init__(self, boxes: Sequence[AxisAlignedBox]): + __slots__ = ("_supporters", "_children", "_boxes", "_cell", "_by_top", "_by_bottom", + "_top_indexes", "_bottom_indexes") + + def __init__(self, boxes: Sequence[AxisAlignedBox], cell_hint: int = 1): + """`cell_hint` is an upper bound on the footprint of any box that may later be + appended with `with_box`. + + Without it the cell is sized from the boxes present now, and appending anything + wider has to fall back to a full rebuild -- which is correct but 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 thing 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 + is impossible to get wrong, because the fallback covers it. + """ + boxes = tuple(boxes) by_top: dict[int, list[tuple[int, AxisAlignedBox]]] = {} + by_bottom: dict[int, list[tuple[int, AxisAlignedBox]]] = {} for index, box in enumerate(boxes): by_top.setdefault(box.z2, []).append((index, box)) + by_bottom.setdefault(box.origin.z, []).append((index, box)) supporters: list[list[ContactEdge]] = [[] for _ in boxes] children: list[list[int]] = [[] for _ in boxes] # A single global cell size, not one derived per level from that level's own # candidates: a querying box can be any size in this scene, and `_LevelIndex` # is only correct when its cell is at least as large as every box it will ever # index or be queried with. - cell = max((max(box.x2 - box.origin.x, box.y2 - box.origin.y) for box in boxes), default=1) + cell = max( + (max(box.x2 - box.origin.x, box.y2 - box.origin.y) for box in boxes), + default=1, + ) + cell = max(cell, cell_hint) indexes: dict[int, _LevelIndex] = {} for index, box in enumerate(boxes): candidates = by_top.get(box.origin.z) @@ -114,6 +133,114 @@ def __init__(self, boxes: Sequence[AxisAlignedBox]): children[other_index].append(index) self._supporters = tuple(tuple(s) for s in supporters) self._children = tuple(tuple(c) for c in children) + self._boxes = boxes + self._cell = cell + self._by_top = by_top + self._by_bottom = by_bottom + # Only the top-plane indexes are populated by the build above; the bottom-plane + # ones are built on demand, because a from-scratch build never needs them and + # paying for them here would slow the common path to speed up the incremental one. + self._top_indexes = indexes + self._bottom_indexes: dict[int, _LevelIndex] = {} + + def with_box(self, box: AxisAlignedBox) -> "ContactGraph": + """This graph plus one more box, appended at the next index. + + Adding a box cannot create or destroy contact between two boxes that were + already here: 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 this + returns a new graph that shares the base's edge tuples instead of recomputing + them -- only the new box's own two planes are queried. + + The result is required to be identical to `ContactGraph(list(boxes) + [box])`, + not merely equivalent: `top_loads` splits a conserved integer across the + supporter tuple and hands the rounding remainder to its last edge, so edge + *order* is contract, not presentation. Appending the new box's index keeps every + existing tuple ascending because the new index is the largest one. + """ + index = len(self._boxes) + footprint = max(box.x2 - box.origin.x, box.y2 - box.origin.y) + if footprint > self._cell: + # `_LevelIndex` is only correct while its cell is at least as large as every + # box indexed in or queried against it. A larger box could step over cells + # in the middle of its own footprint and miss a real overlap, so this is a + # correctness fallback, not an optimisation choice. + return ContactGraph(self._boxes + (box,), cell_hint=footprint) + + supporters = list(self._supporters) + children = list(self._children) + + # What the new box rests on: boxes whose top plane is its bottom plane. + own_supporters: list[ContactEdge] = [] + for other_index, area in sorted( + (other_index, other.overlap_area_xy(box)) + for other_index, other in self._near(self._by_top, self._top_indexes, box.origin.z, box) + ): + if area > 0: + own_supporters.append(ContactEdge(other_index, area)) + children[other_index] = children[other_index] + (index,) + + # What now rests on it: boxes whose bottom plane is its top plane. Their + # supporter tuples gain the new index, which is larger than every index already + # in them, so ascending order is preserved by appending. + own_children: list[int] = [] + for other_index, area in sorted( + (other_index, other.overlap_area_xy(box)) + for other_index, other in self._near(self._by_bottom, self._bottom_indexes, box.z2, box) + ): + if area > 0: + own_children.append(other_index) + supporters[other_index] = supporters[other_index] + (ContactEdge(index, area),) + + supporters.append(tuple(own_supporters)) + children.append(tuple(own_children)) + + # The by-plane buckets are carried forward rather than rederived: one box joins + # exactly two planes, so copying the outer dict (one entry per distinct plane, + # not per box) and rewriting those two buckets is all that changed. Rebuilding + # both dicts from `boxes` would put an O(n) dict-insert pass on a path whose + # whole purpose is to avoid touching the boxes that did not move. + by_top = dict(self._by_top) + by_top[box.z2] = by_top.get(box.z2, []) + [(index, box)] + by_bottom = dict(self._by_bottom) + by_bottom[box.origin.z] = by_bottom.get(box.origin.z, []) + [(index, box)] + + # `_LevelIndex` is immutable once built, so every cached one may be shared with + # the base -- except on the two planes whose bucket just gained a member, where + # the cached index no longer describes its bucket and must be rebuilt on demand. + top_indexes = dict(self._top_indexes) + top_indexes.pop(box.z2, None) + bottom_indexes = dict(self._bottom_indexes) + bottom_indexes.pop(box.origin.z, None) + + return ContactGraph._from_parts( + self._boxes + (box,), self._cell, tuple(supporters), tuple(children), + by_top, by_bottom, top_indexes, bottom_indexes) + + @classmethod + def _from_parts(cls, boxes, cell, supporters, children, + by_top, by_bottom, top_indexes, bottom_indexes) -> "ContactGraph": + graph = cls.__new__(cls) + graph._boxes = boxes + graph._cell = cell + graph._supporters = supporters + graph._children = children + graph._by_top = by_top + graph._by_bottom = by_bottom + graph._top_indexes = top_indexes + graph._bottom_indexes = bottom_indexes + return graph + + def _near(self, buckets, cache, plane: int, box: AxisAlignedBox): + """Every box on `plane` that could overlap `box` in XY, deduped, via the hash.""" + entries = buckets.get(plane) + if not entries: + return () + level = cache.get(plane) + if level is None: + level = _LevelIndex(entries, self._cell) + cache[plane] = level + return level.near(box) def supporters(self, index: int) -> tuple[ContactEdge, ...]: """What `index` directly rests on, each with the contact area.""" diff --git a/src/packvium/geometry.py b/src/packvium/geometry.py index be5436a..65629e7 100644 --- a/src/packvium/geometry.py +++ b/src/packvium/geometry.py @@ -25,6 +25,22 @@ def upright(cls) -> tuple["Rotation", ...]: return (cls.LWH, cls.WLH) +class ShapeType(str, Enum): + """How much of an item's declared box the item actually occupies. + + `RIGID_CUBOID` is the default and the whole of the contract before this epic: the item + is its box. The other two narrow that in one dimension each -- `CONVEX_HULL` in space, + `COMPRESSIBLE` in height under load -- and neither may be inferred. An engine that + packed a hull as its bounding box would return a plan that validates and does not + physically fit, which is why the value is refused rather than approximated until the + engine implements it. + """ + + RIGID_CUBOID = "rigid_cuboid" + CONVEX_HULL = "convex_hull" + COMPRESSIBLE = "compressible" + + @dataclass(frozen=True, slots=True) class Dimensions: length: Length @@ -157,3 +173,72 @@ def overlap_area_xy(self, other: "AxisAlignedBox") -> int: dx = max(0, min(self.x2, other.x2) - max(self.origin.x, other.origin.x)) dy = max(0, min(self.y2, other.y2) - max(self.origin.y, other.origin.y)) return dx * dy + + +#: The six axis-aligned faces a box can leave a container through, in a fixed order. +#: Fixed because callers iterate it to pick the *first* clear direction, and a set would +#: make which one they pick depend on hash order. +ALL_DIRECTIONS = ("+x", "-x", "+y", "-y", "+z", "-z") + + +class InvalidDirectionError(ValueError): + """A direction outside the six-value vocabulary was supplied. Rejected rather than + silently treated as one of the six -- `-z` in particular, since that was the sequence + module's own previous (wrong) default for anything unrecognised.""" + + code = "invalid_direction" + + def __init__(self, direction: str): + self.direction = direction + super().__init__(f"unknown movement direction {direction!r}; expected one of {ALL_DIRECTIONS}") + + def to_dict(self) -> dict: + return {"code": self.code, "direction": self.direction} + + +def swept_volume(box: AxisAlignedBox, container: Dimensions, + direction: str) -> tuple[int, int, int, int, int, int]: + """The region between `box`'s own face and the matching container wall along + `direction`. + + Identical whether a box leaves through that wall (unloading) or arrives through it + (loading), which is why one primitive serves both. It lives here rather than beside + either caller because both the constraint layer and the sequence layer need it, and + the sequence layer already depends on the constraint layer -- putting it there would + invert the dependency direction the architecture fixes as one-way. + """ + x1, y1, z1, x2, y2, z2 = box.origin.x, box.origin.y, box.origin.z, box.x2, box.y2, box.z2 + if direction == "+x": + x1 = x2 + x2 = container.length.ticks + elif direction == "-x": + x2 = x1 + x1 = 0 + elif direction == "+y": + y1 = y2 + y2 = container.width.ticks + elif direction == "-y": + y2 = y1 + y1 = 0 + elif direction == "+z": + z1 = z2 + z2 = container.height.ticks + elif direction == "-z": + z2 = z1 + z1 = 0 + else: + raise InvalidDirectionError(direction) + return x1, y1, z1, x2, y2, z2 + + +def sweep_intersects(sweep: tuple[int, int, int, int, int, int], box: AxisAlignedBox) -> bool: + """Does `box` stand anywhere inside a swept region? + + Half-open on every axis, so boxes that merely touch a corridor's face do not block + it -- the same convention `AxisAlignedBox.intersects` uses, and the reason a box + flush against another's exit face is not treated as being in its way. + """ + sx1, sy1, sz1, sx2, sy2, sz2 = sweep + return (sx1 < box.x2 and box.origin.x < sx2 + and sy1 < box.y2 and box.origin.y < sy2 + and sz1 < box.z2 and box.origin.z < sz2) diff --git a/src/packvium/hull.py b/src/packvium/hull.py new file mode 100644 index 0000000..5d21dea --- /dev/null +++ b/src/packvium/hull.py @@ -0,0 +1,433 @@ +"""Exact separating-axis collision for `convex_hull` items. + +The rule is fixed by [docs/IRREGULAR-ITEMS.md](../../../docs/IRREGULAR-ITEMS.md). +`scripts/irregular_items_model.py` is the independent oracle; this module is written from +the document and never imports it, so the property tests compare two implementations rather +than one implementation with itself. + +The oracle takes every vertex triple as a candidate face normal because it is optimising for +being obviously right. That is the wrong trade in a solver: the axis set is the inner loop. +This module keeps the document's licence to "precompute canonical faces and edges and use +the smaller standard axis set" and does two things with it. + +*Only supporting planes survive.* A triple whose plane cuts through the hull is not a face, +and its normal cannot separate anything the real face normals do not. On a cube this turns +13 triple normals into 3. On its own that did not make the *predicate* cheaper: while edge +directions were every vertex pair, the edge-against-edge product regenerated the discarded +directions anyway, leaving 25 axes for a cube against itself. It took narrowing the edges too +-- see below -- to collapse that to 3. + +*The axes belong to the shape, not to the placement.* This is where the predicate actually +gets cheap. A hull's face normals and edge directions depend on its vertices in the rotated +local frame and not at all on where the candidate sits, so `HullShape` is built once per +item-and-rotation and reused across every candidate position. Translation enters only as +`origin . axis` added to a projection interval, so no translated vertex tuple is ever +materialised. + +*Edge directions are the hull's real edges.* The supporting planes are already wound into +faces to compute the volume, and the consecutive corners of a wound face are exactly the +polyhedron's edges -- so the narrower set the separating-axis theorem actually asks for costs +nothing beyond the walk that was happening anyway. Taking every vertex pair instead was never +wrong, only a superset, and an expensive one: `3v - 6` edges against `v(v - 1) / 2` pairs, in +a predicate whose axis set is the *product* of the two hulls' sets, so the gap squares. On a +20-vertex hull that is 1351 candidate axes rather than 15616. + +Complexity. Building a shape is `O(v^4)`: `O(v^3)` triples, each checked against `O(v)` +vertices. It is paid once per item and rotation and no longer once per collision test -- +`shape_for` memoises it, which is what turned a two-item hull request from 78 shape builds +into 4. The predicate is `O((f_a + f_b + e_a * e_b) * (a + b))` for `f` face axes and `e` edge +directions, inside the document's `O(a^2 * b^2)` bound and reached only after the axis-aligned +broad phase has already said the envelopes overlap. +""" + +from __future__ import annotations + +from functools import lru_cache +from itertools import combinations +from math import gcd +from typing import Iterable, Sequence, Tuple + +from ._compat import dataclass + +#: A point in an item's local tick frame. Deliberately not `geometry.Point`, which forbids +#: negative coordinates: a hull is authored around whatever origin its author chose, and is +#: only moved into container coordinates at placement time. +Vertex = Tuple[int, int, int] +Axis = Tuple[int, int, int] + +#: Smallest number of vertices that can enclose a volume. +MINIMUM_HULL_VERTICES = 4 + +#: Largest absolute vertex coordinate a hull may carry, in ticks (6.25 m). +#: +#: Shared verbatim with PHP, Rust and JavaScript. Besides keeping admission identical across +#: engines, the bound keeps their exact cross products and projections inside the arithmetic +#: ranges documented in ``docs/IRREGULAR-ITEMS.md``. +MAX_COORDINATE = 100_000_000 + +#: Sorted, canonical, and both the face normals and the edge directions of any axis-aligned +#: box -- which is what makes `HullShape.box` constant time. +UNIT_AXES: Tuple[Axis, ...] = ((0, 0, 1), (0, 1, 0), (1, 0, 0)) + + +class DegenerateHullError(ValueError): + """Hull vertices do not enclose a three-dimensional volume. + + Rejected rather than repaired. A flat or duplicated vertex set has no interior, so every + separating-axis answer about it would be vacuously "no collision" -- an item that passes + through everything, which reads as a successful pack. + """ + + +def _subtract(left: Vertex, right: Vertex) -> Vertex: + return (left[0] - right[0], left[1] - right[1], left[2] - right[2]) + + +def _cross(left: Vertex, right: Vertex) -> Axis: + return ( + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ) + + +def _dot(point: Vertex, axis: Axis) -> int: + return point[0] * axis[0] + point[1] * axis[1] + point[2] * axis[2] + + +def _reduce(axis: Axis) -> Axis: + """Divide out the gcd and fix the sign, so parallel axes collapse to one entry. + + A canonical direction is what makes the axis set a set. Sign is irrelevant to separation + -- negating an axis mirrors both projection intervals -- so the sign of the first + non-zero component is fixed positive and opposite normals stop being counted twice. + + The caller guarantees a non-zero vector; `_primitive` is the variant that decides. + """ + divisor = gcd(gcd(abs(axis[0]), abs(axis[1])), abs(axis[2])) + reduced = (axis[0] // divisor, axis[1] // divisor, axis[2] // divisor) + leading = next(value for value in reduced if value) + return reduced if leading > 0 else (-reduced[0], -reduced[1], -reduced[2]) + + +def _primitive(axis: Axis) -> "Axis | None": + """`_reduce`, plus the one case that legitimately has no direction. + + A cross product of two parallel directions is the zero vector and names no axis. That + happens constantly among face candidates and edge pairs, so it is an ordinary outcome + here rather than an error. + """ + return None if axis == (0, 0, 0) else _reduce(axis) + + +def _is_supporting(vertices: Sequence[Vertex], origin: Vertex, axis: Axis) -> bool: + """Does the plane through `origin` with normal `axis` leave every vertex on one side?""" + seen_above = seen_below = False + offset = _dot(origin, axis) + for vertex in vertices: + side = _dot(vertex, axis) - offset + if side > 0: + seen_above = True + elif side < 0: + seen_below = True + if seen_above and seen_below: + return False + return True + + +def _ordered_face(face: Sequence[Vertex], outward: Axis) -> Tuple[Vertex, ...]: + """Corners of one planar convex face, in cyclic order seen from outside. + + The vertices at a supporting plane are *not* all corners of the polygon they lie on: one + can sit inside the face, or part-way along one of its edges. Fanning over the raw extreme + set therefore triangulates the wrong region, and the resulting surface does not close -- + which is exactly how this was found, by a closed-surface residual on a random hull rather + than by any test of the volume itself. + + So the face is gift-wrapped, which keeps only the corners. Starting from the + lexicographically smallest vertex -- extreme in any linear order, therefore a corner -- + each step takes the vertex that leaves every other on one side. Collinear candidates + resolve to the farthest, which is what skips a vertex lying on an edge instead of + doubling back through it. + + Only the *sign* of a turn is ever needed, never its size, and the winding is consistent + with `outward` for every face, so the signed volumes below add rather than cancel. + """ + start = min(face) + ordered = [start] + current = start + for _ in range(len(face)): + following: "Vertex | None" = None + for candidate in face: + if candidate == current: + continue + if following is None: + following = candidate + continue + turn = _dot( + _cross(_subtract(following, current), _subtract(candidate, current)), outward + ) + if turn < 0 or (turn == 0 and _square_length(_subtract(candidate, current)) + > _square_length(_subtract(following, current))): + following = candidate + if following is None or following == start: + break + ordered.append(following) + current = following + return tuple(ordered) + + +def _square_length(vector: Vertex) -> int: + return vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2] + + +def _wound_faces( + vertices: Sequence[Vertex], face_axes: Sequence[Axis] +) -> Tuple[Tuple[Vertex, ...], ...]: + """Every face of the hull, each as its own corners in outward cyclic order. + + One walk, because the faces answer two questions at once. The volume needs them wound + consistently; the edge directions are the consecutive corner pairs of the same walk. They + were computed separately before -- the volume from here, the edges from every vertex pair + -- which made the edge set quadratically larger than the hull actually has. + + Each canonical axis stands for up to two opposite faces, so both the maximal and the + minimal supporting plane along it are collected. A plane carrying fewer than three + vertices is an edge or a corner of the hull, not a face; it contributes no area and no + edge that its two adjoining faces do not already carry. + """ + faces = [] + for axis in face_axes: + for outward in (axis, (-axis[0], -axis[1], -axis[2])): + extreme = max(_dot(vertex, outward) for vertex in vertices) + face = [vertex for vertex in vertices if _dot(vertex, outward) == extreme] + if len(face) < 3: + continue + faces.append(_ordered_face(face, outward)) + return tuple(faces) + + +def _volume_of(faces: Sequence[Sequence[Vertex]]) -> int: + """Exact volume in cubic ticks, by the divergence theorem over the hull's own faces. + + `6V = sum over outward-oriented surface triangles of a . (b x c)`, which is an integer for + integer vertices and therefore exact -- no tolerance decides whether a wedge is half a + cube. + """ + six_volumes = 0 + for ordered in faces: + apex = ordered[0] + for second, third in zip(ordered[1:], ordered[2:]): + six_volumes += _dot(apex, _cross(second, third)) + return abs(six_volumes) // 6 + + +def _edge_directions(faces: Sequence[Sequence[Vertex]]) -> Tuple[Axis, ...]: + """Directions of the hull's real edges, deduplicated and canonical. + + Every edge of a convex polyhedron is shared by exactly two faces, so walking each wound + face and taking its consecutive corner pairs -- closing the cycle -- reaches all of them. + The separating-axis theorem asks for exactly these: cross products of true edge + directions, not of every vertex pair. + + The distinction is the whole cost of the predicate. A hull has at most `3v - 6` edges but + `v(v - 1) / 2` vertex pairs, and the axis set is the *product* of the two hulls' sets, so + the gap squares. Measured on a 20-vertex hull: 190 pair directions against 54 real edge + directions, and 15616 candidate axes against 1351 -- the same verdict for a twelfth of the + work. Vertex pairs were never wrong, only a superset: a pair that is not an edge names a + direction no face can separate along, so it can add an axis but never remove one. + """ + edges = set() + for ordered in faces: + for index, start in enumerate(ordered): + end = ordered[(index + 1) % len(ordered)] + edges.add(_reduce(_subtract(end, start))) + return tuple(sorted(edges)) + + +@dataclass(frozen=True, slots=True) +class HullShape: + """A convex hull's separating axes in its own local frame, computed once. + + Immutable like every other value object here, and safe to share between candidates: it + describes the shape, and a placement contributes only an offset. + """ + + vertices: Tuple[Vertex, ...] + face_axes: Tuple[Axis, ...] + edge_directions: Tuple[Axis, ...] + #: Exact occupied volume in cubic ticks. Computed once with the axes, because a hull's + #: volume is a property of the shape and utilisation would otherwise be reported from the + #: bounding box -- two interlocking wedges in one crate reading as 200% full. + volume: int + + @classmethod + def of(cls, vertices: Iterable[Vertex]) -> "HullShape": + points = validate(vertices) + normals = set() + for first, second, third in combinations(points, 3): + axis = _primitive(_cross(_subtract(second, first), _subtract(third, first))) + if axis is not None and _is_supporting(points, first, axis): + normals.add(axis) + face_axes = tuple(sorted(normals)) + faces = _wound_faces(points, face_axes) + return cls(points, face_axes, _edge_directions(faces), _volume_of(faces)) + + @classmethod + def box(cls, length: int, width: int, height: int) -> "HullShape": + """A cuboid, built without searching for its own faces. + + A box's face normals and edge directions are both exactly the three unit axes, so the + `O(v^4)` supporting-plane search would spend 56 triple tests rediscovering what is + already known. Naming them directly is what lets a hull be tested against an ordinary + item without a cache anywhere: building this is `O(1)`. + + No positivity guard: every extent reaching here comes from a `Dimensions`, which + already refuses a non-positive side at construction. A second copy of that invariant + would be unreachable code claiming to protect something. + """ + vertices = tuple( + (x * length, y * width, z * height) + for x in (0, 1) for y in (0, 1) for z in (0, 1) + ) + return cls(vertices, UNIT_AXES, UNIT_AXES, length * width * height) + + def projection(self, axis: Axis) -> Tuple[int, int]: + """Closed projection interval on `axis`, in local coordinates.""" + values = [_dot(vertex, axis) for vertex in self.vertices] + return min(values), max(values) + + +#: How many rotated hulls stay resident. A request is bounded by its distinct hull items times +#: the six orientations, so this holds far more than any single request; the bound is what +#: stops a long-lived process packing many catalogues from accumulating shapes forever. +#: +#: Measured full, the ceiling it buys: 3.8 MB at six vertices per hull, 5.2 MB at eight, 13.6 +#: MB at twenty. Eviction order depends on call order and nothing observable does -- the +#: function is pure, so a hit and a miss return equal shapes. +SHAPE_CACHE_ENTRIES = 1024 + + +@lru_cache(maxsize=SHAPE_CACHE_ENTRIES) +def shape_for(vertices: Tuple[Vertex, ...], rotation: str) -> HullShape: + """The rotated hull of one item in one orientation, built at most once. + + A hull depends on the item and the orientation and on nothing about where a candidate + sits, but `Placement.hull_shape` was recomputing it inside the collision predicate -- + which is `O(v^4)` work in an `O(n^2)` loop. Measured on the two-wedge fixture: 78 builds + for two items, where four are needed. + + Memoisation is safe here in the way it is not in general: `HullShape` is frozen, the + inputs are the whole of what determines the output, and the shape is read through + projections that never mutate it. Determinism is untouched -- this changes how often the + answer is computed, never what it is. + """ + return HullShape.of(rotate(vertices, rotation)) + + +def validate(vertices: Iterable[Vertex]) -> Tuple[Vertex, ...]: + """Canonicalise an authored vertex list or refuse a hull with no interior.""" + points = tuple(vertices) + if len(points) < MINIMUM_HULL_VERTICES: + raise DegenerateHullError( + f"a convex hull needs at least {MINIMUM_HULL_VERTICES} vertices, got {len(points)}" + ) + if len(set(points)) != len(points): + raise DegenerateHullError("convex hull vertices must be unique") + if any(abs(component) > MAX_COORDINATE for point in points for component in point): + raise DegenerateHullError( + f"convex hull coordinates must stay within {MAX_COORDINATE} ticks" + ) + for first, second, third, fourth in combinations(points, 4): + volume = _dot( + _subtract(fourth, first), + _cross(_subtract(second, first), _subtract(third, first)), + ) + if volume != 0: + return points + raise DegenerateHullError("convex hull vertices are coplanar and enclose no volume") + + +def separating_axes(left: HullShape, right: HullShape) -> Tuple[Axis, ...]: + """Both hulls' face normals plus every edge-against-edge direction, deduplicated.""" + axes = set(left.face_axes) + axes.update(right.face_axes) + for left_edge in left.edge_directions: + for right_edge in right.edge_directions: + axis = _primitive(_cross(left_edge, right_edge)) + if axis is not None: + axes.add(axis) + return tuple(sorted(axes)) + + +def collide(left: HullShape, left_origin: Vertex, right: HullShape, right_origin: Vertex) -> bool: + """Do two placed hulls overlap with positive volume? + + Touching is contact, not collision: the comparison is `<=`, which keeps hulls consistent + with the half-open convention `AxisAlignedBox.intersects` already uses for cuboids, so a + hull resting exactly on a box is supported rather than colliding with it. + """ + for axis in separating_axes(left, right): + left_low, left_high = left.projection(axis) + right_low, right_high = right.projection(axis) + left_shift = _dot(left_origin, axis) + right_shift = _dot(right_origin, axis) + if (left_high + left_shift <= right_low + right_shift + or right_high + right_shift <= left_low + left_shift): + return False + return True + + +#: Which local axis each container axis takes its extent from, per `Rotation`. Read off +#: `Dimensions.rotated`, which is the definition every other part of the engine already +#: follows: `LHW` means x spans the item's length, y its height, z its width. +_SOURCE_AXES = { + "LWH": (0, 1, 2), "LHW": (0, 2, 1), + "WLH": (1, 0, 2), "WHL": (1, 2, 0), + "HLW": (2, 0, 1), "HWL": (2, 1, 0), +} + + +def _permutation_is_odd(axes: Tuple[int, int, int]) -> bool: + return sum( + 1 for first in range(3) for second in range(first + 1, 3) if axes[first] > axes[second] + ) % 2 == 1 + + +def rotate(vertices: Sequence[Vertex], rotation) -> Tuple[Vertex, ...]: + """Reorient a hull the way `Dimensions.rotated` reorients its box. + + Three of the six rotations are odd permutations of the coordinate axes. On a cuboid that + is invisible -- a mirrored box is the same box. On a hull it is not: a bare permutation + would hand back the item's mirror image, a shape the caller does not own. So the sign of + one axis is flipped whenever the permutation is odd, which makes every one of the six a + proper rotation with determinant +1 and never a reflection. + + The scope limit that follows is worth stating rather than discovering: `allowed_rotations` + distinguishes six orientations because six is all a box has, and a hull has 24. These six + are pinned, deterministic and physically real; the other 18 are simply not expressible in + today's vocabulary, and widening it is a contract change rather than a fix here. + + Vertices come back translated so the rotated hull's own bounding box starts at the origin, + which is the frame every placement position is expressed in. + """ + axes = _SOURCE_AXES[rotation.value if hasattr(rotation, "value") else rotation] + sign = -1 if _permutation_is_odd(axes) else 1 + turned = [ + (sign * vertex[axes[0]], vertex[axes[1]], vertex[axes[2]]) for vertex in vertices + ] + lower, _ = bounding_extent(turned) + return tuple( + (x - lower[0], y - lower[1], z - lower[2]) for x, y, z in turned + ) + + +def bounding_extent(vertices: Sequence[Vertex]) -> Tuple[Vertex, Vertex]: + """Inclusive lower and upper corners of a hull's axis-aligned envelope. + + The broad phase stays mandatory, so every hull still needs the box that encloses it. + """ + xs = [vertex[0] for vertex in vertices] + ys = [vertex[1] for vertex in vertices] + zs = [vertex[2] for vertex in vertices] + return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)) diff --git a/src/packvium/models.py b/src/packvium/models.py index ba9868f..46f7100 100644 --- a/src/packvium/models.py +++ b/src/packvium/models.py @@ -6,7 +6,8 @@ from typing import Any, Iterable, Mapping from .centre_of_mass import centre_of_mass_offset_ppm -from .geometry import AxisAlignedBox, Dimensions, Point, Rotation +from . import compression, hull +from .geometry import AxisAlignedBox, Dimensions, Point, Rotation, ShapeType from .lattice_summary import LatticeSummary from .nesting import used_volume as nesting_used_volume from .units import Length, Weight @@ -44,6 +45,16 @@ class Axle: max_load: Weight | None = None +#: The largest `stop_index` every engine can carry identically (/KI defect found +#: under ). Route order is decided by comparing stop indices, and JavaScript holds +#: numbers as doubles: `JSON.parse` already collapses 2**53 + 1 to 2**53 before any +#: constraint sees it, so two consecutive stops above this bound become one number there +#: and one engine silently disagrees with the other three. Since the value cannot cross +#: the wire identically, it is refused rather than accepted and mis-ordered -- the same +#: choice `InvalidDirectionError` makes for a direction outside its six. +MAX_EXACT_STOP_INDEX = 2 ** 53 - 1 + + @dataclass(frozen=True, slots=True) class Item: id: str @@ -77,6 +88,16 @@ class Item: # existed. Only order between items matters, not any absolute stop identity, so # this stays a bare non-negative integer rather than a richer stop object. stop_index: int | None = None + # How much of `dimensions` this item actually occupies. The default is the + # contract as it stood before this epic -- the item is its box -- and the three fields + # below are the data the two narrower shapes need. Each belongs to exactly one shape; + # setting one against the wrong shape is refused rather than ignored, because a + # `compression_ratio` silently dropped on a `convex_hull` reads as an item that was + # packed to its declared limits when it never was. + shape_type: "ShapeType" = ShapeType.RIGID_CUBOID + hull_vertices: tuple[tuple[int, int, int], ...] | None = None + compression_ratio_ppm: int | None = None + max_compression_pressure_kpa: int | None = None # Exact, unit-less economic worth for the `maximum_value` objective, # which ranks by the total value of unpacked items rather than treating every # item as equally worth leaving behind. `None` (the default) never affects @@ -90,14 +111,15 @@ def __post_init__(self) -> None: if not 0 <= self.minimum_support_ratio <= 1: raise ValueError("minimum_support_ratio must be between 0 and 1") if self.max_stacked_items is not None and self.max_stacked_items < 1: raise ValueError("max_stacked_items must be at least 1") - if self.stop_index is not None and self.stop_index < 0: - raise ValueError("stop_index must be non-negative") + if self.stop_index is not None and not 0 <= self.stop_index <= MAX_EXACT_STOP_INDEX: + raise ValueError("stop_index must be a non-negative safe integer") if self.value is not None and self.value < 0: raise ValueError("value must be non-negative") if self.nesting_height is not None and not 0 <= self.nesting_height.ticks < self.dimensions.height.ticks: raise ValueError("nesting_height must be at least zero and strictly less than the item's own height") if self.ground_contact_rule is not None and self.ground_contact_rule not in GROUND_CONTACT_RULES: raise ValueError(f"ground_contact_rule must be one of {sorted(GROUND_CONTACT_RULES)}") + self._validate_shape() rotations = tuple(r for r in self.allowed_rotations if not self.keep_upright or r in Rotation.upright()) if not rotations: raise ValueError("at least one rotation must be allowed") object.__setattr__(self, "allowed_rotations", rotations) @@ -110,6 +132,67 @@ def __post_init__(self) -> None: if self.max_top_load is not None: object.__setattr__(self, "max_top_load", Weight.parse(self.max_top_load)) + def _validate_shape(self) -> None: + """Admit an item's shape, or refuse it with the reason. + + Kept out of `__post_init__` because it is the only rule here that spans four fields + at once: which of them are required, which are forbidden, and what the survivors + have to be consistent with. + """ + object.__setattr__(self, "shape_type", ShapeType(self.shape_type)) + for name, value in self._fields_foreign_to_shape(): + if value is not None: + raise ValueError(f"{name} is not part of a {self.shape_type.value} item") + if self.nesting_height is not None and self.shape_type is not ShapeType.RIGID_CUBOID: + # Both rewrite occupied height. Picking an order silently would give four + # engines four contracts; the interaction gets its own task before it is allowed. + raise ValueError( + f"nesting_height with shape_type {self.shape_type.value} is not supported yet" + ) + if self.shape_type is ShapeType.CONVEX_HULL: + self._validate_hull() + elif self.shape_type is ShapeType.COMPRESSIBLE: + self._validate_compression() + + def _fields_foreign_to_shape(self) -> tuple[tuple[str, Any], ...]: + compression = ( + ("compression_ratio_ppm", self.compression_ratio_ppm), + ("max_compression_pressure_kpa", self.max_compression_pressure_kpa), + ) + vertices = (("hull_vertices", self.hull_vertices),) + if self.shape_type is ShapeType.CONVEX_HULL: + return compression + if self.shape_type is ShapeType.COMPRESSIBLE: + return vertices + return vertices + compression + + def _validate_hull(self) -> None: + if self.hull_vertices is None: + raise ValueError("a convex_hull item requires hull_vertices") + object.__setattr__(self, "hull_vertices", hull.validate(self.hull_vertices)) + lower, upper = hull.bounding_extent(self.hull_vertices) + extent = tuple(high - low for high, low in zip(upper, lower)) + declared = (self.dimensions.length.ticks, self.dimensions.width.ticks, + self.dimensions.height.ticks) + if any(span > limit for span, limit in zip(extent, declared)): + # `dimensions` stays the broad phase and the candidate-generation envelope, so a + # hull poking out of it would be tested for collision against space the solver + # never reserved. + raise ValueError( + f"hull_vertices span {extent} does not fit inside dimensions {declared}" + ) + + def _validate_compression(self) -> None: + if self.compression_ratio_ppm is None or self.max_compression_pressure_kpa is None: + raise ValueError( + "a compressible item requires both compression_ratio and " + "max_compression_pressure_kpa" + ) + if not 0 <= self.compression_ratio_ppm <= compression.PPM: + raise ValueError("compression_ratio must be between zero and one") + if self.max_compression_pressure_kpa < 0: + raise ValueError("max_compression_pressure_kpa cannot be negative") + @classmethod def create(cls, id: str, dimensions: Dimensions, weight=0, **kwargs) -> "Item": return cls(id=id, dimensions=dimensions, weight=Weight.parse(weight), **kwargs) @@ -257,6 +340,41 @@ def create(cls, id: str, inner_dimensions: Dimensions, tare_weight=0, max_payloa return cls(id=id, inner_dimensions=inner_dimensions, tare_weight=Weight.parse(tare_weight), max_payload=None if max_payload is None else Weight.parse(max_payload), **kwargs) +def is_stack_sensitive(item: "Item") -> bool: + """Whether what rests on this item can change a verdict. + + The three original reasons are about the item refusing load. The fourth is about the + item *yielding* to it: a compressible item needs the cumulative mass above it computed + before its occupied height -- or its crush limit -- means anything, and that mass only + exists once the support graph is built. + """ + return (not item.stackable + or item.max_top_load is not None + or item.max_stacked_items is not None + or item.max_compression_pressure_kpa is not None) + + +def hull_collision_is_exact(item: "Item", envelope_matches_physical: bool) -> bool: + """Whether this item's collisions may be decided by its hull rather than by its box. + + Three conditions, and each falls back to the box for its own reason: + + * not a `convex_hull` -- there is no hull to be exact about; + * a clearance has inflated the envelope past the physical box -- a margin around a hull + is not a hull, and refining here would hand back space the caller asked to keep empty; + * the item is on a route -- `packing_sequence` reasons about reachability with box sweeps + only, and a solver that packed hulls tighter than the sequence replay can verify would + produce arrangements it then reported as unloadable. Better one conservative answer in + both places than two that disagree. Lifting this needs a hull-aware sweep, which is a + task of its own rather than a line here. + + Every fallback over-reserves space, which is the only safe direction. + """ + return (item.shape_type is ShapeType.CONVEX_HULL + and envelope_matches_physical + and item.stop_index is None) + + @dataclass(frozen=True, slots=True) class Placement: instance: ItemInstance @@ -273,6 +391,64 @@ def box(self) -> AxisAlignedBox: return AxisAlignedBox(self.position, self.dimen @property def envelope_box(self) -> AxisAlignedBox: return AxisAlignedBox(self.envelope_origin, self.envelope_dimensions) + @property + def hull_shape(self) -> "hull.HullShape | None": + """This placement's rotated hull, or `None` when its box is the honest answer. + + `None` for every `rigid_cuboid`, and also whenever a clearance has inflated the + envelope past the physical box: a clearance is a margin around whatever the item is, + and the margin around a hull is not a hull. Falling back to the envelope over-reserves + space, which is the only safe direction to be wrong in. + """ + item = self.instance.item + if not hull_collision_is_exact(item, self.envelope_dimensions == self.dimensions): + return None + return hull.shape_for(item.hull_vertices, self.rotation.value) + + +def placements_collide(left: Placement, right: Placement) -> bool: + """Do two placed items actually overlap? + + The axis-aligned envelope test is the broad phase and stays mandatory; this only refines + its answer when a hull is one of the two solids, so a request of ordinary boxes reaches + the same verdict by the same route it always did. One definition, so the solver, the + sequence check and the final validation cannot disagree about what "collides" means. + """ + left_box, right_box = left.envelope_box, right.envelope_box + if not left_box.intersects(right_box): + return False + left_shape, right_shape = left.hull_shape, right.hull_shape + if left_shape is None and right_shape is None: + return True + return hull.collide( + left_shape if left_shape is not None else _box_shape(left_box), + (left_box.origin.x, left_box.origin.y, left_box.origin.z), + right_shape if right_shape is not None else _box_shape(right_box), + (right_box.origin.x, right_box.origin.y, right_box.origin.z), + ) + + +def placement_hits_box(placement: Placement, box: AxisAlignedBox) -> bool: + """Whether a placed item overlaps a plain box -- an obstacle, or any other fixed solid. + + Same two-phase rule as `placements_collide`, with the second solid known to be a cuboid. + """ + envelope = placement.envelope_box + if not envelope.intersects(box): + return False + shape = placement.hull_shape + if shape is None: + return True + return hull.collide( + shape, (envelope.origin.x, envelope.origin.y, envelope.origin.z), + _box_shape(box), (box.origin.x, box.origin.y, box.origin.z), + ) + + +def _box_shape(box: AxisAlignedBox) -> "hull.HullShape": + return hull.HullShape.box(box.dimensions.length.ticks, box.dimensions.width.ticks, + box.dimensions.height.ticks) + @dataclass(frozen=True, slots=True) class PackedContainer: diff --git a/src/packvium/nesting.py b/src/packvium/nesting.py index 95bc6b9..ed4bca0 100644 --- a/src/packvium/nesting.py +++ b/src/packvium/nesting.py @@ -2,6 +2,10 @@ from typing import TYPE_CHECKING, Sequence +from . import hull +from .compression import applied_pressure, effective_height_ticks +from .geometry import ShapeType + if TYPE_CHECKING: from .models import Placement @@ -54,9 +58,44 @@ def _nesting_overlap_volume(placements: Sequence["Placement"]) -> int: return overlap +def occupied_volume(placement: "Placement") -> int: + """How much space a placement actually takes, which is its box only if it is one. + + A `convex_hull` item occupies its hull. Counting its bounding box instead is not a + conservative approximation of utilisation, it is a wrong number: two interlocking wedges + filling one crate would report it 200% full, and the independent validator refuses that + before any caller sees it. + """ + item = placement.instance.item + if item.shape_type is ShapeType.CONVEX_HULL: + # Collision may conservatively fall back to the envelope for route-bound items or a + # clearance margin. That does not turn the physical item into a box: utilisation and + # reserve accounting always use the authored solid. + assert item.hull_vertices is not None + return hull.shape_for(item.hull_vertices, placement.rotation.value).volume + if item.max_compression_pressure_kpa is None: + return placement.dimensions.volume + # Compression follows the item's rotated height axis, and `dimensions` is already rotated. + # The load is the one the placement reports, so this needs no support graph and stays in + # this layer; `top_load` is resolved before a result is ever built. + dimensions = placement.dimensions + footprint = dimensions.length.ticks * dimensions.width.ticks + pressure = applied_pressure(placement.top_load, footprint) + if pressure.exceeds_kpa(item.max_compression_pressure_kpa): + # A crushed item has no meaningful occupied volume, and the arrangement is already + # invalid -- `crushed` refuses it in the solver and the validator reports it. Reporting + # the uncompressed figure here keeps that a reported issue rather than an exception + # thrown out of a volume property. + return dimensions.volume + return footprint * effective_height_ticks( + dimensions.height.ticks, item.compression_ratio_ppm, + item.max_compression_pressure_kpa, pressure, + ) + + def used_volume(placements: Sequence["Placement"]) -> int: """Physical volume actually occupied by `placements`, nesting overlap removed.""" - return sum(p.dimensions.volume for p in placements) - _nesting_overlap_volume(placements) + return sum(occupied_volume(p) for p in placements) - _nesting_overlap_volume(placements) def used_volume_delta(placements: Sequence["Placement"], placement: "Placement") -> int: @@ -69,7 +108,7 @@ def used_volume_delta(placements: Sequence["Placement"], placement: "Placement") """ nesting = placement.instance.item.nesting_height if nesting is None: - return placement.dimensions.volume + return occupied_volume(placement) overlap = 0 for existing in placements: if is_valid_nesting(existing, placement): diff --git a/src/packvium/packing_sequence.py b/src/packvium/packing_sequence.py index 630236a..a35ae84 100644 --- a/src/packvium/packing_sequence.py +++ b/src/packvium/packing_sequence.py @@ -40,25 +40,10 @@ from .constraints import (_touches_corners, direct_support_view, load_units, overloaded, stack_density_exceeded, stack_limit_exceeded, stacked_counts) from .contact import ContactGraph -from .geometry import AxisAlignedBox, Dimensions +from .geometry import (ALL_DIRECTIONS, AxisAlignedBox, Dimensions, + InvalidDirectionError, sweep_intersects, swept_volume) from .models import Container, Placement -ALL_DIRECTIONS = ("+x", "-x", "+y", "-y", "+z", "-z") - - -class InvalidDirectionError(ValueError): - """A direction outside the six-value vocabulary was supplied. Rejected rather than - silently treated as one of the six -- `-z` in particular, since that was this - module's own previous (wrong) default for anything unrecognised.""" - - code = "invalid_direction" - - def __init__(self, direction: str): - self.direction = direction - super().__init__(f"unknown movement direction {direction!r}; expected one of {ALL_DIRECTIONS}") - - def to_dict(self) -> dict: - return {"code": self.code, "direction": self.direction} def _validated(directions: Sequence[str]) -> Sequence[str]: @@ -98,45 +83,20 @@ def __init__(self, stop: int, stuck: frozenset[int]): super().__init__(f"stop {stop}: placements {sorted(stuck)} cannot be unloaded there") -def _swept_volume(box: AxisAlignedBox, container: Dimensions, direction: str) -> tuple[int, int, int, int, int, int]: - """The region between `box`'s own face and the matching container wall along - `direction` -- identical whether a box leaves through that wall (unloading) or - arrives through it (loading).""" - x1, y1, z1, x2, y2, z2 = box.origin.x, box.origin.y, box.origin.z, box.x2, box.y2, box.z2 - if direction == "+x": - x1 = x2 - x2 = container.length.ticks - elif direction == "-x": - x2 = x1 - x1 = 0 - elif direction == "+y": - y1 = y2 - y2 = container.width.ticks - elif direction == "-y": - y2 = y1 - y1 = 0 - elif direction == "+z": - z1 = z2 - z2 = container.height.ticks - elif direction == "-z": - z2 = z1 - z1 = 0 - else: - raise InvalidDirectionError(direction) - return x1, y1, z1, x2, y2, z2 +#: Kept as a module-private alias: this module's own callers and tests reach for the +#: private name, while the definition now lives in `geometry` so the constraint layer can +#: share it without the sequence layer having to be imported downwards. +_swept_volume = swept_volume def _blocking_indices(index: int, box: AxisAlignedBox, boxes: Sequence[AxisAlignedBox], present: frozenset[int], container: Dimensions, direction: str) -> frozenset[int]: """Every other currently-present box whose envelope intersects `box`'s `direction` sweep -- the evidence `_blocked` reduces to a bare boolean.""" - sx1, sy1, sz1, sx2, sy2, sz2 = _swept_volume(box, container, direction) + sweep = swept_volume(box, container, direction) return frozenset( other_index for other_index in present - if other_index != index - and sx1 < boxes[other_index].x2 and boxes[other_index].origin.x < sx2 - and sy1 < boxes[other_index].y2 and boxes[other_index].origin.y < sy2 - and sz1 < boxes[other_index].z2 and boxes[other_index].origin.z < sz2 + if other_index != index and sweep_intersects(sweep, boxes[other_index]) ) @@ -148,12 +108,9 @@ def _blocked(index: int, box: AxisAlignedBox, boxes: Sequence[AxisAlignedBox], boolean callers (the safe-order search and reachability sweeps) ask it O(n*d) times per step and never read the set, which only the evidence paths need. """ - sx1, sy1, sz1, sx2, sy2, sz2 = _swept_volume(box, container, direction) + sweep = swept_volume(box, container, direction) return any( - other_index != index - and sx1 < boxes[other_index].x2 and boxes[other_index].origin.x < sx2 - and sy1 < boxes[other_index].y2 and boxes[other_index].origin.y < sy2 - and sz1 < boxes[other_index].z2 and boxes[other_index].origin.z < sz2 + other_index != index and sweep_intersects(sweep, boxes[other_index]) for other_index in present ) diff --git a/src/packvium/serialization.py b/src/packvium/serialization.py index 4e4f1dc..2c53192 100644 --- a/src/packvium/serialization.py +++ b/src/packvium/serialization.py @@ -4,7 +4,8 @@ from .config import PackingConfig, SolverProfile from .effort import EffortBudget -from .geometry import AxisAlignedBox, Dimensions, Point, Rotation +from .compression import ratio_to_ppm +from .geometry import AxisAlignedBox, Dimensions, Point, Rotation, ShapeType from .models import Axle, Container, Item, Obstacle, RateTable from .extensions import ExtensionRegistry from .packer import Packer @@ -31,6 +32,25 @@ def _effort_budget(raw: dict | None) -> EffortBudget | None: ) +def _hull_vertices(raw, unit: str) -> "tuple[tuple[int, int, int], ...] | None": + """Parse `hull_vertices` into the integer tick frame before any geometry runs. + + Coordinates go through `Length`, which refuses a negative value, so a hull crossing the + wire is authored as non-negative offsets from the corner of its own bounding box. A + library caller may still centre a hull wherever it likes -- `hull.rotate` normalises + either way -- but the wire keeps one convention so four engines cannot disagree about + where an item's frame starts. + """ + if raw is None: + return None + return tuple( + (Length.parse(vertex["x"], unit).ticks, + Length.parse(vertex["y"], unit).ticks, + Length.parse(vertex["z"], unit).ticks) + for vertex in raw + ) + + def _item(raw: dict, unit: str) -> Item: rotations = tuple(Rotation(v) for v in raw.get("allowed_rotations", [r.value for r in Rotation.all()])) return Item( @@ -46,6 +66,11 @@ def _item(raw: dict, unit: str) -> Item: nesting_height=None if raw.get("nesting_height") is None else Length.parse(raw["nesting_height"], unit), stop_index=raw.get("stop_index"), value=raw.get("value"), + shape_type=ShapeType(raw.get("shape_type", ShapeType.RIGID_CUBOID.value)), + hull_vertices=_hull_vertices(raw.get("hull_vertices"), unit), + compression_ratio_ppm=(None if raw.get("compression_ratio") is None + else ratio_to_ppm(float(raw["compression_ratio"]))), + max_compression_pressure_kpa=raw.get("max_compression_pressure_kpa"), ) @@ -108,12 +133,31 @@ class UnsupportedFeatureError(ValueError): UNSUPPORTED_FIELDS: dict[str, tuple[str, ...]] = { "request": (), "configuration": (), + # `hull_vertices`, `compression_ratio` and `max_compression_pressure_kpa` left this list + # in , when Python gained both the solver behaviour and the independent validation + # the staged rollout requires. PHP, Rust and the JavaScript fallback still carry them. "item": (), "container": (), } - -def reject_unsupported(data: dict, unsupported: dict[str, tuple[str, ...]] | None = None) -> None: +#: `item.shape_type` values this engine does not implement. +#: +#: Presence is the wrong test for this one field: `rigid_cuboid` is the default and is +#: implemented, so a caller that spells the default out must be served, not refused. What +#: is unimplemented is a *value*, and the refusal has to name it -- an engine that packed a +#: `convex_hull` item as its bounding box would return a plan that looks valid and does not +#: physically fit. +#: Empty since : this engine implements every value the schema defines. The guard +#: stays because the next reserved value will need it, and because `reject_unsupported` takes +#: its lists as parameters precisely so it remains testable when they are empty. +UNSUPPORTED_SHAPE_TYPES: tuple[str, ...] = () + + +def reject_unsupported( + data: dict, + unsupported: dict[str, tuple[str, ...]] | None = None, + shape_types: tuple[str, ...] | None = None, +) -> None: """Refuse a request that uses a field this engine has not implemented. The lists are a parameter rather than read from the module constant directly so the @@ -122,6 +166,7 @@ def reject_unsupported(data: dict, unsupported: dict[str, tuple[str, ...]] | Non equally true of a guard that does nothing at all. """ unsupported = UNSUPPORTED_FIELDS if unsupported is None else unsupported + shape_types = UNSUPPORTED_SHAPE_TYPES if shape_types is None else shape_types # Keyed by name, not appended per occurrence: fifty containers carrying one # unimplemented field are one complaint, not fifty. found: set[str] = set() @@ -135,6 +180,9 @@ def reject_unsupported(data: dict, unsupported: dict[str, tuple[str, ...]] | Non if not isinstance(entry, dict): continue found.update(f"{scope}.{key}" for key in unsupported.get(scope, ()) if key in entry) + for entry in data.get("items") or (): + if isinstance(entry, dict) and entry.get("shape_type") in shape_types: + found.add(f"item.shape_type={entry['shape_type']}") if not found: return raise UnsupportedFeatureError( diff --git a/src/packvium/solvers.py b/src/packvium/solvers.py index 1e569d9..0f3e123 100644 --- a/src/packvium/solvers.py +++ b/src/packvium/solvers.py @@ -16,13 +16,18 @@ from .extensions import UNPRICEABLE_MINOR, _grams from .constraints import (AxleLoadConstraint, CompatibilityConstraint, ConstraintContext, ContainerEligibilityConstraint, FloorConstraint, LoadUnit, PlacementConstraint, - RIDES_THE_WHOLE_ROUTE, RouteOrderConstraint, SupportConstraint, + RIDES_THE_WHOLE_ROUTE, RouteOrderConstraint, + StopAccessibilityConstraint, SupportConstraint, TagCountConstraint, TopLoadConstraint, direct_support_view, load_units, top_loads, usable_volume) -from .geometry import AxisAlignedBox, Dimensions, Point, Rotation, dimensional_weight +from . import bounds, hull +from .geometry import (AxisAlignedBox, Dimensions, Point, Rotation, ShapeType, + dimensional_weight) from .lattice_summary import LatticeSummary -from .models import Container, ItemInstance, PackedContainer, Placement, UnpackedItem -from .nesting import is_valid_nesting, used_volume as nesting_used_volume, used_volume_delta +from .models import (Container, ItemInstance, PackedContainer, Placement, UnpackedItem, + hull_collision_is_exact, is_stack_sensitive) +from .nesting import (is_valid_nesting, occupied_volume, + used_volume as nesting_used_volume, used_volume_delta) from .result import SolverMetrics, StartRecord from .spatial_index import SpatialIndex from . import trace @@ -45,6 +50,20 @@ class SearchStats: support_checks: int = 0 space_partitions: int = 0 search_nodes_expanded: int = 0 + # Times the exact hull test overruled an axis-aligned collision. Deliberately + # absent from `to_metrics`: `algorithm.metrics` is serialised into every result, so a new + # key there changes the bytes of every existing golden and has to land in all four engines + # at once. This one stays internal, where tests can prove the refinement actually fired + # rather than infer it from a placement that might have succeeded anyway. + hull_refinements: int = 0 + # The request-level lower bound on the objective vector, computed once at the root of an + # `exact_small` or global-beam solve. Internal for the same reason as + # `hull_refinements` above, and for one more: reporting a gap to a caller is a new public + # result field, and this project reserves and rejects such a field before a contract + # freeze rather than adding it mid-line ('s precedent, restated by ). + # `None` when no bound was computed -- a non-default objective keys its score vector + # differently, so a bound compared against it would compare different quantities. + objective_lower_bound: "tuple[int, ...] | None" = None def to_metrics(self) -> SolverMetrics: return SolverMetrics( @@ -154,6 +173,21 @@ def _extent(box: AxisAlignedBox) -> tuple[int, int, int, int, int, int]: return (box.origin.x, box.origin.y, box.origin.z, box.x2, box.y2, box.z2) +def _solids_collide(left: "hull.HullShape | None", left_origin: tuple[int, int, int], + left_extent: tuple[int, int, int], + right: "hull.HullShape | None", right_origin: tuple[int, int, int], + right_extent: tuple[int, int, int]) -> bool: + """Exact overlap between two solids of which at least one is a hull. + + Reached only after the axis-aligned test has already said their envelopes overlap, so the + cost is paid on the small set of pairs where a box answer would have been wrong. + """ + return hull.collide( + left if left is not None else hull.HullShape.box(*left_extent), left_origin, + right if right is not None else hull.HullShape.box(*right_extent), right_origin, + ) + + class ContainerState: """Placed boxes plus the candidate points they expose. @@ -165,7 +199,7 @@ class ContainerState: """ __slots__ = ("container", "sequence", "placements", "points", "ordered_points", "payload_ticks", "used_volume_ticks", - "stack_sensitive", "route_sensitive", "max_z", "occupied", "bounds", "index", + "stack_sensitive", "route_sensitive", "compression_sensitive", "max_z", "occupied", "bounds", "index", "hull_shapes", "lattice_summary", "lattice_items") def __init__(self, container: Container, sequence: int): @@ -176,6 +210,7 @@ def __init__(self, container: Container, sequence: int): self.used_volume_ticks = 0 self.stack_sensitive = False self.route_sensitive = False + self.compression_sensitive = False self.max_z = 0 # Set instead of appending to `placements` when GridSolver's quantity- # compression fast path applies -- see `lattice_summary.py`. @@ -185,6 +220,10 @@ def __init__(self, container: Container, sequence: int): # Plain integer extents of everything solid. The candidate scan tests these # millions of times, and recomputing box properties there dominated the search. self.bounds: list[tuple[int, int, int, int, int, int]] = [_extent(b) for b in self.occupied] + # Parallel to `bounds`: the rotated hull of that solid, or `None` where the solid is + # an ordinary box. Obstacles are always boxes, so every entry starts `None` and the + # cuboid-only request never allocates anything beyond this list. + self.hull_shapes: list["hull.HullShape | None"] = [None] * len(self.occupied) dims = container.inner_dimensions self.index = SpatialIndex(dims.length.ticks, dims.width.ticks, dims.height.ticks) for position, bound in enumerate(self.bounds): @@ -206,9 +245,11 @@ def copy(self) -> "ContainerState": other.used_volume_ticks = self.used_volume_ticks other.stack_sensitive = self.stack_sensitive other.route_sensitive = self.route_sensitive + other.compression_sensitive = self.compression_sensitive other.max_z = self.max_z other.occupied = list(self.occupied) other.bounds = list(self.bounds) + other.hull_shapes = list(self.hull_shapes) other.index = self.index.copy() other.lattice_summary = self.lattice_summary other.lattice_items = self.lattice_items @@ -230,27 +271,42 @@ def add_lattice(self, summary: "LatticeSummary", items: tuple[ItemInstance, ...] self.payload_ticks += summary.total_weight_ticks self.used_volume_ticks += summary.used_volume_ticks self.stack_sensitive = self.stack_sensitive or any( - not item.item.stackable or item.item.max_top_load is not None - or item.item.max_stacked_items is not None for item in items + is_stack_sensitive(item.item) for item in items ) self.route_sensitive = self.route_sensitive or any(item.item.stop_index is not None for item in items) if summary.max_z_ticks > self.max_z: self.max_z = summary.max_z_ticks def add(self, placement: Placement) -> None: box = placement.envelope_box - self.used_volume_ticks += used_volume_delta(self.placements, placement) + compression_sensitive = ( + self.compression_sensitive + or placement.instance.item.shape_type is ShapeType.COMPRESSIBLE + ) + if compression_sensitive: + self.used_volume_ticks = _used_volume_with_current_loads((*self.placements, placement)) + else: + self.used_volume_ticks += used_volume_delta(self.placements, placement) self.placements.append(placement) self.payload_ticks += placement.instance.weight.ticks item = placement.instance.item - self.stack_sensitive = (self.stack_sensitive or not item.stackable or item.max_top_load is not None - or item.max_stacked_items is not None) + self.stack_sensitive = self.stack_sensitive or is_stack_sensitive(item) self.route_sensitive = self.route_sensitive or item.stop_index is not None + self.compression_sensitive = compression_sensitive if box.z2 > self.max_z: self.max_z = box.z2 self.occupied.append(box) bound = _extent(box) self.index.add(len(self.bounds), bound) self.bounds.append(bound) - retired = {key for key, point in self.points.items() if box.contains_point(point)} + shape = placement.hull_shape + self.hull_shapes.append(shape) + # Retiring a point because it falls inside a solid's box assumes the box *is* the + # solid. For a hull it is not: a placement origin is a corner of a bounding box, and + # a hull leaves most of that box -- including, for a wedge, the origin itself -- + # available to the next item. Keeping those points alive is what lets the exact + # collision test below actually decide something; pruning them first would mean the + # engine could describe an interlocking pack it could never propose. + retired = ({key for key, point in self.points.items() if box.contains_point(point)} + if shape is None else set()) for key in retired: del self.points[key] if retired: @@ -266,13 +322,20 @@ def add_direct(self, placement: Placement) -> None: an otherwise linear placement loop into quadratic work. """ box = placement.envelope_box - self.used_volume_ticks += used_volume_delta(self.placements, placement) + compression_sensitive = ( + self.compression_sensitive + or placement.instance.item.shape_type is ShapeType.COMPRESSIBLE + ) + if compression_sensitive: + self.used_volume_ticks = _used_volume_with_current_loads((*self.placements, placement)) + else: + self.used_volume_ticks += used_volume_delta(self.placements, placement) self.placements.append(placement) self.payload_ticks += placement.instance.weight.ticks item = placement.instance.item - self.stack_sensitive = (self.stack_sensitive or not item.stackable or item.max_top_load is not None - or item.max_stacked_items is not None) + self.stack_sensitive = self.stack_sensitive or is_stack_sensitive(item) self.route_sensitive = self.route_sensitive or item.stop_index is not None + self.compression_sensitive = compression_sensitive if box.z2 > self.max_z: self.max_z = box.z2 @@ -345,7 +408,8 @@ def pack_one(self, container: Container, sequence: int, items: Sequence[ItemInst def default_constraints(config: PackingConfig, custom: Sequence[PlacementConstraint] = ()) -> tuple[PlacementConstraint, ...]: return (FloorConstraint(), ContainerEligibilityConstraint(), CompatibilityConstraint(), TagCountConstraint(), SupportConstraint(config.minimum_support_ratio), TopLoadConstraint(), - RouteOrderConstraint(), AxleLoadConstraint(), *custom) + RouteOrderConstraint(), StopAccessibilityConstraint(config.access_directions), + AxleLoadConstraint(), *custom) def _candidate_score(state: ContainerState, point: Point, dims: Dimensions) -> tuple[int, ...]: @@ -373,7 +437,7 @@ def _axle_balanced_points(state: ContainerState, item: ItemInstance, forms: Sequ tare_doubled_x = container.inner_dimensions.length.ticks floor_ys = sorted({point.y for point in state.points.values() if point.z == 0}) or [0] points: list[Point] = [] - for _, _, _, dx, _, _ in forms: + for _, _, _, dx, _, _, _ in forms: for x1 in axle_balanced_origins(container.axles, other_units, tare_ticks, tare_doubled_x, item.weight.ticks, dx): if 0 <= x1 <= limit_x - dx: points.extend(Point(x1, y, 0) for y in floor_ys) @@ -404,7 +468,15 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo container = state.container if container.max_items is not None and len(state.placements) >= container.max_items: return [] if container.max_payload is not None and state.payload_ticks + item.weight.ticks > container.max_payload.ticks: return [] - if container.void_fill_reserve_ratio > 0 and item.item.nesting_height is None: + compression_sensitive = ( + state.compression_sensitive or item.item.shape_type is ShapeType.COMPRESSIBLE + ) + reserve_needs_candidate = ( + item.item.nesting_height is not None + or item.item.shape_type is ShapeType.CONVEX_HULL + or compression_sensitive + ) + if container.void_fill_reserve_ratio > 0 and not reserve_needs_candidate: if state.used_volume_ticks + item.dimensions.volume > usable_volume(container): return [] inner = container.inner_dimensions limit_x, limit_y, limit_z = inner.length.ticks, inner.width.ticks, inner.height.ticks @@ -412,18 +484,31 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo # Envelope and extents depend only on the rotation, so they are built once instead # of once per (point, rotation) pair. forms = [] - for rotation, physical in item.dimensions.unique_rotations(item.item.allowed_rotations): + # A hull is not the same solid under two rotations that happen to give the same box, so + # `unique_rotations` -- which keys on the box -- would silently drop orientations that + # differ. Cuboids keep the deduplication they have always had. + is_hull = item.item.shape_type is ShapeType.CONVEX_HULL + exact_hull = hull_collision_is_exact(item.item, not clearance) + rotation_forms = ( + tuple((rotation, item.dimensions.rotated(rotation)) for rotation in item.item.allowed_rotations) + if is_hull else item.dimensions.unique_rotations(item.item.allowed_rotations) + ) + for rotation, physical in rotation_forms: envelope = physical.expand(config.clearance) if clearance else physical - forms.append((rotation, physical, envelope, envelope.length.ticks, envelope.width.ticks, envelope.height.ticks)) + # A clearance margin around a hull is not a hull, so the refined test is dropped and + # the envelope stands -- over-reserving, which is the safe direction. + shape = (hull.shape_for(item.item.hull_vertices, rotation.value) + if exact_hull else None) + forms.append((rotation, physical, envelope, envelope.length.ticks, envelope.width.ticks, + envelope.height.ticks, shape)) placed = tuple(state.placements) stack_sensitive = (state.stack_sensitive - or not item.item.stackable - or item.item.max_top_load is not None - or item.item.max_stacked_items is not None + or is_stack_sensitive(item.item) or container.max_stack_density is not None) route_sensitive = (item.item.stop_index is not None or state.route_sensitive) bounds = state.bounds + hull_shapes = state.hull_shapes index = state.index if points is None: if item.item.nesting_height is None: @@ -442,7 +527,7 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo deadline.check() stats.candidate_points_considered += 1 x1, y1, z1 = point.x, point.y, point.z - for rotation, physical, envelope, dx, dy, dz in forms: + for rotation, physical, envelope, dx, dy, dz, shape in forms: stats.placements_attempted += 1 x2, y2, z2 = x1 + dx, y1 + dy, z1 + dz if x2 > limit_x or y2 > limit_y or z2 > limit_z: @@ -462,6 +547,15 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo placement_index = candidate_index - placement_offset if tentative is not None and placement_index >= 0 and is_valid_nesting(placed[placement_index], tentative): continue + blocker = hull_shapes[candidate_index] + # The axis-aligned test is the broad phase and stays mandatory. Only when + # a hull is one of the two solids does the exact test get to overrule it, + # so a request of ordinary boxes never reaches this branch at all. + if (shape is not None or blocker is not None) and not _solids_collide( + shape, (x1, y1, z1), (dx, dy, dz), + blocker, (bx1, by1, bz1), (bx2 - bx1, by2 - by1, bz2 - bz1)): + stats.hull_refinements += 1 + continue blocked = True break if blocked: @@ -483,9 +577,27 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo position = (tentative.position if tentative is not None else Point(x1 + clearance, y1 + clearance, z1 + clearance)) candidate = Candidate(point, position, rotation, physical, envelope, _candidate_score(state, point, envelope)) - if container.void_fill_reserve_ratio > 0 and item.item.nesting_height is not None: - assert tentative is not None - if state.used_volume_ticks + used_volume_delta(placed, tentative) > usable_volume(container): + if container.void_fill_reserve_ratio > 0 and reserve_needs_candidate: + reserve_placement = tentative or Placement( + item, position, rotation, physical, point, envelope + ) + if compression_sensitive: + # Zero load gives the candidate its largest possible physical volume, + # while appending it can only compress existing supports. Therefore this + # is a safe upper bound: when it fits, the exact support-graph refresh + # cannot turn the candidate into a reserve violation. Only candidates + # close to the boundary pay the non-local calculation. + upper_bound = state.used_volume_ticks + occupied_volume(reserve_placement) + projected_volume = ( + upper_bound + if upper_bound <= usable_volume(container) + else _used_volume_with_current_loads((*placed, reserve_placement)) + ) + else: + projected_volume = state.used_volume_ticks + used_volume_delta( + placed, reserve_placement + ) + if projected_volume > usable_volume(container): continue stats.candidates_evaluated += 1 if trace.active(): @@ -858,6 +970,14 @@ def pack_one(self, container, sequence, items, config, stats, deadline): # exact gross axle constraint per candidate, so use it instead of ever # constructing a packing that only the post-validator can reject. return ExtremePointSolver().pack_one(container, sequence, items, config, stats, deadline) + if prototype.shape_type is not ShapeType.RIGID_CUBOID: + # The lattice is closed-form over boxes: it counts cells from envelope extents + # and caps a column from `max_top_load` arithmetic alone. Neither step can see a + # hull -- it would tile bounding boxes and call the result exact -- and neither + # can see pressure, so a compressible column would be sized without ever asking + # whether its bottom item survives. The general solver checks both per candidate + #. + return ExtremePointSolver().pack_one(container, sequence, items, config, stats, deadline) state = ContainerState(container, sequence) # Every non-floor lattice cell has one full-area direct supporter, including # an exact nesting predecessor. That satisfies ratio=1, covered and single; @@ -1549,15 +1669,26 @@ def _with_top_loads(placements: Sequence[Placement]) -> tuple[Placement, ...]: ) +def _used_volume_with_current_loads(placements: Sequence[Placement]) -> int: + """Physical volume after this scene's support loads have been propagated. + + Compression makes appending one placement non-local: a new upper item changes the + occupied height of existing supports. The rigid-item path keeps its O(1) delta; this + composite path rebuilds the support graph and nesting total in + O(n log n + q + e) time and O(n + e) graph space, where q is broad-phase work and e + is the contact-edge count; both become O(n^2) in a physically dense worst case. This + is the bound of one refresh, not one solve. The whole-solve sum over candidates and + search nodes is stated in docs/ALGORITHMS-AND-COMPLEXITY.md. + """ + return nesting_used_volume(_with_top_loads(placements)) + + class DefaultContainerSelector: """Prefers the container that holds the most items, then the cheapest, then the tightest.""" def score(self, container: Container, solution: SingleContainerSolution) -> tuple: state = solution.state - if state.lattice_summary is not None: - used = state.lattice_summary.used_volume_ticks - else: - used = nesting_used_volume(state.placements) + used = state.used_volume_ticks return (-state.placement_count, container.cost_minor, container.inner_dimensions.volume - used, container.id) @@ -2064,10 +2195,12 @@ def _support_is_the_blocker(self, instance, packed, config): return False def _across_containers(self, solver, items, containers, config, stats, deadline): - if ( + beam = ( config.container_plan_beam_width > 1 and isinstance(self.container_selector, DefaultContainerSelector) - ): + ) + self._record_root_bound(solver, beam, items, containers, config, stats) + if beam: return self._across_containers_beam( solver, items, containers, config, stats, deadline ) @@ -2139,6 +2272,40 @@ def _plan_bound_key(self, plan, containers, config) -> tuple: packing_signature = tuple(container.id for container in plan.packed) return (*partial, remaining_signature, packing_signature) + @staticmethod + def _record_root_bound(solver, beam, items, containers, config, stats) -> None: + """Compute the request-level lower bound once, at the root. + + The plan beam already computes the same relaxation *per node* to prune with -- the + request-level bound is that formula with the state empty, which is why this costs one + sort rather than a second search. `exact_small` gets it for the opposite reason: it + exhausts its candidate set and can say the incumbent is optimal over that set, and a + bound is what turns "I stopped looking" into a statement about the request. + + Only for the default objective. `lowest_cost` and `maximum_value` order their score + keys differently, so a bound vector compared against them would line up cost against + container count -- not a weaker claim, a meaningless one. + + Nothing downstream reads this yet, and that is deliberate: the number is visible to + the engines and to nothing else until a contract freeze decides whether a caller ever + sees a gap. + """ + if stats.objective_lower_bound is not None: + return + if not (beam or getattr(solver, "name", None) == "exact_small"): + return + if config.objective != "default": + return + try: + stats.objective_lower_bound = bounds.compute(items, containers).as_tuple() + except bounds.BoundOverflowError: + # The bound refuses past its declared ceiling. Nothing reads it yet, so + # a refusal leaves it unset rather than failing a pack that is otherwise fine -- + # the refusal is the bound declining to answer, not the request being invalid. + # When a caller-facing gap field exists, that field carries the refusal instead. + stats.objective_lower_bound = None + + def _across_containers_beam(self, solver, items, containers, config, stats, deadline): """Bounded deterministic beam over partial multi-container plans. diff --git a/src/packvium/support_polygon.py b/src/packvium/support_polygon.py index b990c1e..6241b95 100644 --- a/src/packvium/support_polygon.py +++ b/src/packvium/support_polygon.py @@ -75,3 +75,29 @@ def contact_hull_points(candidate: AxisAlignedBox, supporters: Sequence[AxisAlig def doubled_centroid(box: AxisAlignedBox) -> Point2D: """Twice the box's own footprint centroid, exact for odd as well as even extents.""" return (box.origin.x + box.x2, box.origin.y + box.y2) + + +def eight_times_area(hull: Sequence[Point2D]) -> int: + """Eight times the true area of a hull returned by `convex_hull`. + + Exact integers, no division, and the factor of eight is not arbitrary. The shoelace + sum is twice a polygon's area, and `contact_hull_points` doubles every coordinate -- + which scales area by four. So the sum this returns is `2 * 4 = 8` times the real + contact area, and a caller compares it against eight times whatever it wants rather + than dividing here and losing exactness on the first odd number. + + `O(m)` in the hull's vertex count, which the Euler bound keeps small: a hull over `4n` + intersection corners has at most `4n` vertices and in practice far fewer. + + Degenerate hulls -- a single contact point, a razor-thin strip -- return zero, which is + the right answer rather than an error. They are exactly the placements a support-area + rule should refuse. + """ + if len(hull) < 3: + return 0 + total = 0 + for index in range(len(hull)): + x1, y1 = hull[index] + x2, y2 = hull[(index + 1) % len(hull)] + total += x1 * y2 - x2 * y1 + return abs(total) diff --git a/src/packvium/validation.py b/src/packvium/validation.py index ddebdde..ba1df1d 100644 --- a/src/packvium/validation.py +++ b/src/packvium/validation.py @@ -5,10 +5,11 @@ from .axle_load import axle_load_exceeded from .constraints import (CompatibilityConstraint, ConstraintContext, LoadSupportGraph, SupportConstraint, TagCountConstraint, load_units, - non_stackable_failure, overloaded, stack_density_exceeded, + crushed, non_stackable_failure, overloaded, stack_density_exceeded, stack_limit_exceeded) from .geometry import AxisAlignedBox, Point -from .models import PackedContainer, PackingRequest, UnpackedItem +from .models import (PackedContainer, PackingRequest, UnpackedItem, is_stack_sensitive, + placement_hits_box, placements_collide) from .nesting import is_valid_nesting as _is_valid_nesting from .packing_sequence import RouteSequenceError, safe_route_removal_order from .units import Length @@ -52,7 +53,7 @@ def validate( or any(p.instance.item.minimum_support_ratio > 0 or p.instance.item.ground_contact_rule not in (None, "free") for p in placements)) - stack_sensitive = any(not p.instance.item.stackable for p in placements) + stack_sensitive = any(is_stack_sensitive(p.instance.item) for p in placements) stack_graph = LoadSupportGraph(load_units(placements)) if stack_sensitive else None for left, right in self._collision_pairs(placements): issues.append(ValidationIssue( @@ -67,7 +68,7 @@ def validate( if placement.rotation not in placement.instance.item.allowed_rotations: issues.append(ValidationIssue("forbidden_rotation", item_id)) if placement.dimensions != placement.instance.item.dimensions.rotated(placement.rotation): issues.append(ValidationIssue("dimension_mismatch", item_id)) if not self._envelope_matches(placement, clearance_ticks): issues.append(ValidationIssue("clearance_mismatch", item_id)) - if any(placement.envelope_box.intersects(box) for o in packed.container.obstacles for box in o.boxes): issues.append(ValidationIssue("obstacle_collision", item_id)) + if any(placement_hits_box(placement, box) for o in packed.container.obstacles for box in o.boxes): issues.append(ValidationIssue("obstacle_collision", item_id)) if placement.instance.item.must_be_on_floor and placement.envelope_origin.z != 0: issues.append(ValidationIssue("must_be_on_floor", f"{item_id}: ")) eligible_tags = placement.instance.item.eligible_container_tags @@ -100,7 +101,8 @@ def validate( # reports the first offender it meets, this one is anchored on the container. units = load_units(packed.placements) density_limit = None if packed.container.max_stack_density is None else packed.container.max_stack_density.ticks - failure = overloaded(units) or stack_limit_exceeded(units) or stack_density_exceeded(units, density_limit) + failure = (overloaded(units) or crushed(units) or stack_limit_exceeded(units) + or stack_density_exceeded(units, density_limit)) if failure is not None: issues.append(ValidationIssue(failure[0], f"{packed.id}: {failure[1]}")) if packed.container.axles is not None: axle_failure = axle_load_exceeded( @@ -166,9 +168,8 @@ def _collision_pairs(placements) -> list[tuple[int, int]]: active: list[tuple[int, int]] = [] for x1, x2, index in ordered: active = [(right, other) for right, other in active if right > x1] - box = placements[index].envelope_box for _, other in active: - if box.intersects(placements[other].envelope_box) and not _is_valid_nesting(placements[index], placements[other]): + if placements_collide(placements[index], placements[other]) and not _is_valid_nesting(placements[index], placements[other]): pairs.add((min(index, other), max(index, other))) active.append((x2, index)) return sorted(pairs) diff --git a/tests/test_constraints.py b/tests/test_constraints.py index 5f202ae..82a3819 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -8,8 +8,10 @@ class of bug the fixed-point design exists to prevent. from __future__ import annotations +import json import random from fractions import Fraction +from pathlib import Path import pytest @@ -398,6 +400,287 @@ def test_supporters_stay_ordered_by_index_for_the_remainder_split(): assert [edge.index for edge in graph.supporters(0)] == [1, 2, 3] +# ------------------------------------------------- incremental append + +def _touching_scene(rng: random.Random, count: int) -> list[AxisAlignedBox]: + """A scene whose boxes actually touch each other. + + `_random_box` draws from a range wide enough that most of its scenes have no + contact at all, which is fine for the brute-force agreement property above -- an + empty edge set is still an edge set both implementations must agree on. It is not + fine here: what is under test is that a delta reproduces edges, so a corpus where + most scenes have no edges would pass with the delta returning nothing. Snapping + every coordinate and extent to one coarse lattice makes shared planes the norm. + """ + return [ + AxisAlignedBox( + Point(rng.randrange(0, 60, 10), rng.randrange(0, 60, 10), rng.choice([0, 10, 20, 30])), + Dimensions(*(Length(rng.choice([10, 20, 30])) for _ in range(3))), + ) + for _ in range(count) + ] + + +def _widest_footprint(boxes) -> int: + return max(max(box.x2 - box.origin.x, box.y2 - box.origin.y) for box in boxes) + + +def _edges(graph, count: int): + """Both edge directions as ordered tuples. + + Compared as sequences, never as sets: `top_loads` hands the integer rounding + remainder to whichever supporter is *last*, so two graphs holding the same edges in + a different order are two different answers. + """ + return ( + [tuple((edge.index, edge.area) for edge in graph.supporters(i)) for i in range(count)], + [tuple(graph.children(i)) for i in range(count)], + ) + + +def _count_full_builds(monkeypatch, target) -> list[int]: + """Record every from-scratch build of `target`, so a test can tell the delta path + from the fallback. Without this an assertion that the two graphs match is satisfied + by a `with_box` that quietly rebuilds everything -- correct, and none of the point.""" + builds: list[int] = [] + original = target.__init__ + + def counting(self, units, cell_hint=1): + builds.append(len(units)) + original(self, units, cell_hint) + + monkeypatch.setattr(target, "__init__", counting) + return builds + + +@pytest.mark.parametrize("seed", range(40)) +def test_appending_a_box_matches_building_the_whole_scene_at_once(seed, monkeypatch): + """The delta is required to be *identical* to the full build, not merely equivalent. + + The base is built with the widest footprint in the scene as its hint, which is what + a solver knows before it starts placing: the candidate about to be appended may be + larger than anything already placed, and sizing the spatial hash from the placed + boxes alone would send every append into the fallback. + """ + rng = random.Random(2000 + seed) + boxes = _touching_scene(rng, rng.randint(2, 14)) + split = max(1, len(boxes) // 2) + hint = _widest_footprint(boxes) + + builds = _count_full_builds(monkeypatch, ContactGraph) + graph = ContactGraph(boxes[:split], cell_hint=hint) + for box in boxes[split:]: + graph = graph.with_box(box) + + assert builds == [split], "an append fell back to a full rebuild" + assert _edges(graph, len(boxes)) == _edges(ContactGraph(boxes), len(boxes)) + + +@pytest.mark.parametrize("coordinates,extents,hint_mode", [ + ((0, 1, 2, 5, 10), (1, 2, 3), "exact"), + ((0, 1, 2, 5, 10), (1, 2, 3), "one"), + ((0, 1, 2, 5, 10), (1, 5, 10, 40), "one"), + ((0, 1, 2, 5, 10), (1, 5, 10, 40), "huge"), + ((0, 10, 100, 10 ** 9), (1, 5, 10, 40), "exact"), + ((0, 10, 100, 10 ** 9), (1, 5, 10, 40), "one"), + ((0, 10, 100, 10 ** 9), (1, 2, 3), "huge"), +]) +def test_the_delta_matches_a_rebuild_across_scene_shapes(coordinates, extents, hint_mode): + """The same invariant as the property test above, over shapes it deliberately excludes. + + That test asserts *zero* fallbacks, because proving the delta ran was the thing at + stake. The consequence is that nothing exercised a run where the fallback and the delta + interleave -- and a hint of one forces exactly that, several times per scene. + + The three axes vary independently on purpose. Tight coordinates make shared planes and + zero-area edge contacts the norm; coordinates at 10^9 push the spatial hash's cell + arithmetic somewhere a lattice never goes; a huge hint collapses every box into one + cell, which is the degenerate case the hash exists to avoid and therefore the one most + likely to be wrong. + + Written after an unsound optimality bound in a neighbouring module survived 183 tests + that all shared one shape. Coverage there was complete; variety was not. + """ + rng = random.Random(hash((coordinates, extents, hint_mode)) & 0xFFFF) + for _ in range(60): + count = rng.randint(1, 10) + boxes = [ + AxisAlignedBox( + Point(rng.choice(coordinates), rng.choice(coordinates), rng.choice(coordinates)), + Dimensions(*(Length(rng.choice(extents)) for _ in range(3))), + ) + for _ in range(count) + ] + widest = _widest_footprint(boxes) + hint = {"exact": widest, "one": 1, "huge": widest * 100}[hint_mode] + split = max(1, count // 2) + graph = ContactGraph(boxes[:split], cell_hint=hint) + for box in boxes[split:]: + graph = graph.with_box(box) + assert _edges(graph, count) == _edges(ContactGraph(boxes), count) + + +def test_a_box_wider_than_the_hint_rebuilds_and_is_still_correct(monkeypatch): + """The hint is an optimisation; being wrong about it may cost time, never an answer. + + `_LevelIndex` is only sound while its cell is at least the largest footprint it + indexes or is queried with, so a box that exceeds the cell has to be met with a + rebuild -- this asserts both halves: that the rebuild happens, and that the result + is the one the full build gives. + """ + small = [ + AxisAlignedBox(Point(0, 0, 0), Dimensions(Length(10), Length(10), Length(10))), + AxisAlignedBox(Point(10, 0, 0), Dimensions(Length(10), Length(10), Length(10))), + ] + wide = AxisAlignedBox(Point(0, 0, 10), Dimensions(Length(40), Length(10), Length(10))) + + builds = _count_full_builds(monkeypatch, ContactGraph) + graph = ContactGraph(small).with_box(wide) + + assert builds == [2, 3], "a box exceeding the cell must not use the delta path" + assert [edge.index for edge in graph.supporters(2)] == [0, 1] + assert _edges(graph, 3) == _edges(ContactGraph([*small, wide]), 3) + + +def test_an_appended_box_lands_last_in_the_tuples_it_joins(): + """The remainder-split contract, on the delta path specifically. + + The new box always takes the highest index, so appending it to an existing + supporter tuple keeps that tuple ascending -- but only because it is appended and + not inserted, which is the kind of detail a from-scratch comparison on random + scenes can miss when no scene happens to produce the collision. + """ + below = [ + AxisAlignedBox(Point(0, 0, 0), Dimensions(Length(10), Length(10), Length(10))), + AxisAlignedBox(Point(10, 0, 0), Dimensions(Length(10), Length(10), Length(10))), + ] + resting = AxisAlignedBox(Point(0, 0, 10), Dimensions(Length(20), Length(10), Length(10))) + graph = ContactGraph([below[0], resting, below[1]], cell_hint=20).with_box( + AxisAlignedBox(Point(0, 0, 20), Dimensions(Length(10), Length(10), Length(10))) + ) + assert [edge.index for edge in graph.supporters(1)] == [0, 2] + assert graph.children(1) == (3,) + + +@pytest.mark.parametrize("seed", range(40)) +def test_appending_a_unit_matches_building_the_support_graph_at_once(seed, monkeypatch): + rng = random.Random(4000 + seed) + boxes = _touching_scene(rng, rng.randint(2, 12)) + units = [LoadUnit(box, 100, None, None, f"u{i}") for i, box in enumerate(boxes)] + hint = _widest_footprint(boxes) + + builds = _count_full_builds(monkeypatch, LoadSupportGraph) + graph = LoadSupportGraph(units[:1], cell_hint=hint) + for unit in units[1:]: + graph = graph.with_unit(unit, cell_hint=hint) + + assert builds == [1], "an append fell back to a full rebuild" + assert _edges(graph, len(units)) == _edges(LoadSupportGraph(units), len(units)) + + +def test_the_adversarial_dense_scene_costs_only_the_edges_it_reports(monkeypatch): + """The bound the delta is actually claimed to meet, on the shape that is worst for it. + + Two layers of thin strips laid at right angles -- k running along x below, k along y + above -- so every upper strip crosses every lower one and the graph really holds + k*k edges. It is an ordinary criss-crossed dunnage stack, not a contrivance, and it is + the shape any exact contact representation is quadratic on: the edges are there, and + reporting them is the work. + + So the delta is not claimed to be cheap here. It is claimed to cost the edges it + reports and nothing else: appending one strip touches k boxes, and the probe count + must stay within a constant factor of k rather than climbing towards the k*k a + from-scratch build performs -- which is what a delta being local *means*. + """ + k = 40 + span = k * 10 + lower = [ + AxisAlignedBox(Point(0, index * 10, 0), Dimensions(Length(span), Length(10), Length(10))) + for index in range(k) + ] + upper = [ + AxisAlignedBox(Point(index * 10, 0, 10), Dimensions(Length(10), Length(span), Length(10))) + for index in range(k - 1) + ] + arriving = AxisAlignedBox( + Point((k - 1) * 10, 0, 10), Dimensions(Length(10), Length(span), Length(10))) + + base = ContactGraph(lower + upper, cell_hint=span) + assert sum(len(base.supporters(i)) for i in range(len(lower) + len(upper))) == k * (k - 1), ( + "the scene is meant to be quadratically dense; if it is not, the bound is untested" + ) + + probes = 0 + original = AxisAlignedBox.overlap_area_xy + + def counting(self, other): + nonlocal probes + probes += 1 + return original(self, other) + + monkeypatch.setattr(AxisAlignedBox, "overlap_area_xy", counting) + graph = base.with_box(arriving) + + assert len(graph.supporters(len(lower) + len(upper))) == k + assert probes <= 4 * k, f"{probes} probes to report {k} edges is not a local delta" + + +@pytest.mark.parametrize("coordinates,extents,hint_mode", [ + ((0, 1, 2, 5, 10), (1, 2, 3), "exact"), + ((0, 1, 2, 5, 10), (1, 2, 3), "one"), + ((0, 1, 2, 5, 10), (1, 5, 10, 40), "one"), + ((0, 10, 100, 10 ** 9), (1, 5, 10, 40), "exact"), + ((0, 10, 100, 10 ** 9), (1, 2, 3), "huge"), +]) +def test_appending_a_unit_matches_a_rebuild_across_scene_shapes(coordinates, extents, hint_mode): + """The support graph gets the same treatment as the contact graph beneath it. + + `LoadSupportGraph.with_unit` reads its edges straight off the face graph, so in the + non-nesting case it is only as correct as `ContactGraph.with_box` -- but "only as + correct as" is an argument, and an argument is what the unsound optimality bound in a + neighbouring module also had. The shapes are varied here too rather than reasoned about. + """ + rng = random.Random(hash((coordinates, extents, hint_mode)) & 0xFFFF) + for _ in range(40): + count = rng.randint(1, 9) + units = [ + LoadUnit( + AxisAlignedBox( + Point(rng.choice(coordinates), rng.choice(coordinates), rng.choice(coordinates)), + Dimensions(*(Length(rng.choice(extents)) for _ in range(3))), + ), + rng.choice((0, 100, 5000)), None, None, f"u{index}", + ) + for index in range(count) + ] + widest = _widest_footprint([unit.box for unit in units]) + hint = {"exact": widest, "one": 1, "huge": widest * 100}[hint_mode] + graph = LoadSupportGraph(units[:1], cell_hint=hint) + for unit in units[1:]: + graph = graph.with_unit(unit, cell_hint=hint) + assert _edges(graph, count) == _edges(LoadSupportGraph(units), count) + + +def test_a_nesting_unit_is_met_with_a_full_rebuild(monkeypatch): + """Nesting is deliberately excluded from the delta, and the exclusion is load-bearing. + + A nesting predecessor replaces the face edges of its whole column, so one new unit + can rewrite edges arbitrarily far from itself -- the locality the delta rests on is + simply not there. The rebuild is the correct answer, so assert it is taken. + """ + stack = ( + unit(0, 0, 0, 10, 10, 10, label="a", nesting_item_id="tray", nesting_height_ticks=4), + unit(0, 0, 6, 10, 10, 10, label="b", nesting_item_id="tray", nesting_height_ticks=4), + ) + arriving = unit(0, 0, 12, 10, 10, 10, label="c", nesting_item_id="tray", nesting_height_ticks=4) + + builds = _count_full_builds(monkeypatch, LoadSupportGraph) + graph = LoadSupportGraph(stack[:1]).with_unit(stack[1]).with_unit(arriving) + + assert builds == [1, 2, 3] + assert _edges(graph, 3) == _edges(LoadSupportGraph((*stack, arriving)), 3) + + # --------------------------------------------------------- stacked-item counting def test_a_three_high_column_counts_transitively_not_just_the_neighbour(): @@ -975,3 +1258,375 @@ def test_the_blocked_item_and_its_blocker_are_both_named(): code, detail = route_order_violated(column, [0.0, 1.0]) assert code == "unloading_order_violation" assert "base" in detail and "top" in detail + + +# ------------------------------------------------- stop accessibility +# +# The worked examples in docs/STOP-ACCESSIBILITY.md were derived by hand and confirmed +# against the shipped whole-scene replay, and they are the acceptance criterion for this +# constraint. They live in `conformance/scene/stop-accessibility-fixtures.json` and are +# read from there below; the helpers here serve the degenerate and hostile inputs further +# down, which are specific to this engine and have no cross-language counterpart. + +DOOR_AT_MINUS_X = ("-x",) +ALL_DIRECTIONS_TUPLE = ("+x", "-x", "+y", "-y", "+z", "-z") + + +def _mm(millimetres: int) -> int: + """The examples are written in millimetres and positions are taken in ticks, so the + conversion is stated once here rather than at every call site.""" + return Length.mm(millimetres).ticks + + +def _wide(id: str, x: int, length: int, stop=None): + """A box spanning the container's full width and height, so a corridor it stands in is + completely filled -- the examples turn on which *stop* blocks which, not on squeezing + past.""" + instance = instance_of(id, length=length, width=100, height=100, stop_index=stop) + return instance, _mm(x) + + +def _scene(first, second, directions=DOOR_AT_MINUS_X): + """Place `first`, then offer `second` as the candidate.""" + (placed_instance, placed_x), (candidate, candidate_x) = first, second + constraint = constraints.StopAccessibilityConstraint(directions) + return constraint.evaluate(context( + candidate, x=candidate_x, placements=(placed(placed_instance, x=placed_x),), + dimensions=candidate.item.dimensions, + )) + + +#: The worked examples, held once for all four engines instead of transcribed into each. +#: Four copies of nine scenes is four chances for a verdict to drift in one engine and stay +#: green in the other three. A published copy of the package does not carry the corpus, so +#: the test that reads it skips rather than failing for everyone who installed the package. +STOP_SCENES = Path(__file__).parents[2] / "conformance/scene/stop-accessibility-fixtures.json" +requires_stop_scenes = pytest.mark.skipif( + not STOP_SCENES.is_file(), + reason="the shared cross-language scene corpus is not part of this package", +) + + +def _fixture_dimensions(raw) -> Dimensions: + """The corpus is in ticks, so it is read straight rather than through `Dimensions.mm`: + all four engines assert the same integers instead of each scaling by its own factor.""" + return Dimensions(*(Length(raw[axis]) for axis in ("length", "width", "height"))) + + +def _fixture_placement(raw) -> Placement: + one, = Item.create(raw["id"], _fixture_dimensions(raw["dimensions"]), + stop_index=raw["stop_index"]).instances() + return placed(one, *(raw["origin"][axis] for axis in ("x", "y", "z"))) + + +@requires_stop_scenes +def test_shared_four_language_stop_accessibility_scenes(): + """Every scene in the shared corpus, with the verdict every engine must reach. + + `accessible` is asserted by all four engines. `code` is asserted here and in PHP, whose + constraint returns a reason rather than a boolean, and `route_order_allowed` here, in + PHP and in Rust -- the corpus records that asymmetry so it is not rediscovered. + """ + payload = json.loads(STOP_SCENES.read_text()) + assert payload["scenes"], "an empty corpus would pass this loop without asserting anything" + for scene in payload["scenes"]: + raw = scene["candidate"] + one, = Item.create(raw["id"], _fixture_dimensions(raw["dimensions"]), + stop_index=raw["stop_index"]).instances() + evaluated = context( + one, + *(raw["origin"][axis] for axis in ("x", "y", "z")), + placements=tuple(_fixture_placement(each) for each in scene["placements"]), + container=Container.create("fixture", _fixture_dimensions(scene["container"])), + ) + result = constraints.StopAccessibilityConstraint(tuple(scene["directions"])).evaluate(evaluated) + + assert result.allowed == scene["accessible"], scene["id"] + if "code" in scene: + assert result.code == scene["code"], scene["id"] + if "route_order_allowed" in scene: + assert (RouteOrderConstraint().evaluate(evaluated).allowed + == scene["route_order_allowed"]), scene["id"] + + +def test_a_request_with_no_route_is_untouched(): + """`route_sensitive` is False whenever no item in play declares a stop, which is the + same opt-in gate `RouteOrderConstraint` uses.""" + instance = instance_of("plain", length=60, width=100, height=100) + scene = context(instance, x=0, placements=(placed(instance_of( + "other", length=40, width=100, height=100), x=_mm(60)),), route_sensitive=False) + assert constraints.StopAccessibilityConstraint(DOOR_AT_MINUS_X).evaluate(scene).allowed + + +def test_directions_are_canonicalised_so_two_callers_search_identically(): + """Order and duplicates in the caller's list must not reach the search: which door is + tried first decides which of several legal answers comes back.""" + assert (constraints.StopAccessibilityConstraint(("+z", "-x", "-x"))._directions + == constraints.StopAccessibilityConstraint(("-x", "+z"))._directions) + + +def test_swept_volume_refuses_an_unknown_direction_at_the_primitive(): + """The constraint validates its doors at construction, but the primitive is public and + reachable on its own -- `packing_sequence` calls it -- so it owes the same refusal.""" + from packvium.geometry import InvalidDirectionError, swept_volume + + box = AxisAlignedBox(Point(0, 0, 0), Dimensions(Length(10), Length(10), Length(10))) + with pytest.raises(InvalidDirectionError): + swept_volume(box, Dimensions(Length(100), Length(100), Length(100)), "sideways") + + +def test_the_corridor_base_is_reused_for_a_second_candidate_on_the_same_state(): + """The cache is why a candidate costs `O(m * |D|)` rather than `O(m^2 * |D|)`: search + asks a run of candidates against one immutable state, so one entry covers the run. + + It is keyed on the placements *and* the container. The container half is a guard rather + than something a legal scene can demonstrate -- a placement always lies inside its own + container, so widening a wall only lengthens a sweep into empty space. What it protects + against is one placement tuple being asked about two different containers, which a + multi-container solve can do; the key makes the second question rebuild instead of + inheriting the first answer. + """ + constraint = constraints.StopAccessibilityConstraint(DOOR_AT_MINUS_X) + early, early_x = _wide("early", 60, 40, stop=0) + late, late_x = _wide("late", 0, 60, stop=1) + placements = (placed(early, x=early_x),) + scene = context(late, x=late_x, placements=placements) + + first = constraint.evaluate(scene) + built = constraint._base_for(placements, BOX.inner_dimensions) + # Asking again on the same state must take the cached path and answer identically. + assert constraint._base_for(placements, BOX.inner_dimensions) == built + assert constraint.evaluate(scene).allowed == first.allowed + + longer = Container.create("longer", Dimensions.mm(300, 100, 100)) + constraint._base_for(placements, longer.inner_dimensions) + assert constraint._container == longer.inner_dimensions, ( + "a different container must rebuild the base rather than inherit it") + + +def test_permanent_cargo_that_blocks_nobody_is_allowed(): + """The `rides the whole route` short-circuit after the placed-item loop. An item with no + stop needs no door of its own, so once it has taken nobody else's it is simply legal -- + the counterpart to example D, where it took one.""" + fixture = instance_of("fixture", length=40, width=100, height=100, stop_index=None) + early = instance_of("early", length=60, width=100, height=100, stop_index=0) + # The stop-0 item stands at the door; the permanent one sits behind it and blocks + # nothing, because a corridor to `-x` never crosses it. + scene = context(fixture, x=_mm(60), placements=(placed(early, x=0),)) + assert constraints.StopAccessibilityConstraint(DOOR_AT_MINUS_X).evaluate(scene).allowed + + +def test_an_unknown_direction_is_refused_rather_than_guessed(): + with pytest.raises(constraints.InvalidDirectionError): + constraints.StopAccessibilityConstraint(("north",)) + + +# ------------------------------------- stop accessibility: degenerate and hostile input + + +def test_a_corridor_runs_to_a_wall_so_the_container_is_part_of_the_question(): + """The cached exit sets must not outlive the container they were computed for. + + `+x` ends at the container's far wall, so the same two boxes have different exits in a + short container than in a long one: in the short one `a` is flush against the wall and + free, in the long one a later-stop box already stands in its corridor. A cache keyed on + the placements alone answered the second question with the first one's answer and + accepted a placement that walls `a` in. + """ + long_box = Container.create("long", Dimensions.mm(400, 100, 100)) + short_box = Container.create("short", Dimensions.mm(100, 100, 100)) + near = instance_of("a", length=40, width=100, height=100, stop_index=0) + far = instance_of("b", length=50, width=100, height=100, stop_index=1) + placements = (placed(near, x=0), placed(far, x=_mm(150))) + candidate = instance_of("c", length=30, width=100, height=100, stop_index=1) + constraint = constraints.StopAccessibilityConstraint(("+x",)) + + def verdict(container): + return constraint.evaluate(ConstraintContext( + container, placements, candidate, Point(_mm(50), 0, 0), Rotation.LWH, + candidate.item.dimensions, candidate.item.dimensions)).allowed + + assert verdict(long_box), "a had already lost its corridor before the candidate" + assert not verdict(short_box), "the candidate fills a's only corridor here" + + +def test_the_largest_admissible_stops_stay_distinct(): + """Exactness at the top of the range the wire contract admits. + + An earlier version of this test used 2**53 and 2**53 + 1 to show the constraint kept + them apart where a float would merge them. Those values are now refused at + construction, because JavaScript cannot parse them without collapsing them and the + four engines would order such a load differently. The hazard is gone at its source, so + what is left to prove is that nothing widens a stop *inside* the admissible range -- + the two largest neighbours it contains are still two. + """ + from packvium.models import MAX_EXACT_STOP_INDEX + + early = instance_of("early", length=40, width=100, height=100, + stop_index=MAX_EXACT_STOP_INDEX - 1) + late = instance_of("late", length=60, width=100, height=100, + stop_index=MAX_EXACT_STOP_INDEX) + + result = constraints.StopAccessibilityConstraint(DOOR_AT_MINUS_X).evaluate(context( + late, x=0, placements=(placed(early, x=_mm(60)),))) + + assert not result.allowed + assert str(MAX_EXACT_STOP_INDEX - 1) in result.detail + + +def test_two_permanent_items_do_not_block_each_other(): + """Neither is ever unloaded, so neither needs a corridor and neither is the other's + problem. `inf > inf` being false is what gives that for free -- an ordering sentinel + that compared greater-or-equal to itself would refuse every pair of fixtures.""" + assert _scene(_wide("fixture-a", 60, 40, stop=None), + _wide("fixture-b", 0, 60, stop=None)).allowed + + +def test_a_permanent_item_needs_no_exit_of_its_own(): + """Nothing is due later than "never", so its blocker set is empty by construction. + + The fixture sits at the far end with a stop-1 item between it and the door, which for + any ordinary item would be a refusal. It is not one here: the fixture is not coming + out at stop 1, or at any stop. + """ + assert _scene(_wide("fixture", 60, 40, stop=None), _wide("late", 0, 60, stop=1)).allowed + + +def test_permanent_cargo_still_walls_in_an_item_that_does_have_to_come_out(): + """The converse, and the asymmetry is the point. + + An item due at stop 5 behind permanent cargo is refused, because "never" outranks + every stop. Pairing this with the test above pins the sentinel's direction: it is the + latest possible stop, not a value excused from the ordering. + """ + result = _scene(_wide("fixture", 0, 60, stop=None), _wide("late", 60, 40, stop=5)) + assert not result.allowed + assert "no exit" in result.detail + + +def test_the_first_box_into_an_empty_container_is_never_refused(): + """There is nothing to be blocked by and nothing to block, whatever its stop.""" + instance = instance_of("only", length=60, width=100, height=100, stop_index=3) + assert constraints.StopAccessibilityConstraint(DOOR_AT_MINUS_X).evaluate( + context(instance, x=0)).allowed + + +def test_a_box_flush_against_a_corridor_wall_does_not_stand_in_it(): + """Half-open on every axis, matching `AxisAlignedBox.intersects`. + + Two boxes side by side across the width: the `-x` corridor of one spans only its own + `y` band, so its neighbour merely touching that band's edge is not in the way. Treating + a shared face as an obstruction would refuse most ordinary side-by-side loads. + """ + left = instance_of("left", length=40, width=50, height=100, stop_index=0) + right = instance_of("right", length=40, width=50, height=100, stop_index=1) + scene = ConstraintContext( + BOX, (placed(left, x=_mm(60), y=0),), right, Point(_mm(60), _mm(50), 0), + Rotation.LWH, right.item.dimensions, right.item.dimensions) + assert constraints.StopAccessibilityConstraint(DOOR_AT_MINUS_X).evaluate(scene).allowed + + +def test_a_box_filling_the_container_has_an_empty_corridor_in_every_direction(): + """Its faces are the walls, so no sweep has room for anything -- including the sweep + of the item that fills it. A rule that measured the corridor from the container's + centre, or that treated an empty region as blocked, would refuse a single-item load.""" + whole = instance_of("whole", length=100, width=100, height=100, stop_index=0) + for directions in (("-x",), ("+x",), ALL_DIRECTIONS_TUPLE): + assert constraints.StopAccessibilityConstraint(directions).evaluate( + context(whole, x=0)).allowed + + +def test_stop_zero_is_a_stop_and_not_an_absent_value(): + """`0` is falsy, and a presence test written as `if stop:` would quietly turn the + first stop on the route into permanent cargo -- which reverses the rule for it.""" + result = _scene(_wide("early", 60, 40, stop=0), _wide("late", 0, 60, stop=1)) + assert not result.allowed + assert "stop 0" in result.detail + + +def test_all_six_doors_accept_what_one_door_refuses(): + """The vacuity the design warns about, pinned so the default cannot drift into it. + + With every wall open a box is almost always free through some face, which is precisely + why `access_directions` defaults to empty rather than to all six. + """ + assert _scene(_wide("early", 60, 40, stop=0), _wide("late", 0, 60, stop=1), + directions=ALL_DIRECTIONS_TUPLE).allowed + + +# ------------------------------- the support-polygon redundancy boundary + + +@pytest.mark.parametrize("seed", range(80)) +def test_a_single_supporter_covering_over_half_the_base_always_contains_the_centroid(seed): + """Why `SupportConstraint`'s polygon test cannot fire above a 0.5 area ratio. + + On one rectangular supporter the contact region is a rectangle inside the base. If it + covers more than half of each axis it must straddle the midpoint of that axis, so the + centroid is inside it and the polygon test is decided before it is asked. Measured + behaviour matches: at ratio 0.6 the conjunction refuses exactly what the area rule + refuses, and at 0.3 and 0.45 it refuses more (benchmarks/results/support-predicates.json). + + This is the boundary, not a bug -- but it means the hull is built per candidate for a + verdict that a cheaper comparison already fixed, which is the finding records. + """ + rng = random.Random(6000 + seed) + length, width = rng.randint(20, 60), rng.randint(20, 60) + lower = instance_of("lower", length=100, width=100, height=10) + upper = instance_of("upper", length=length, width=width, height=10) + + # A contact rectangle covering strictly more than half of the candidate on both axes. + overlap_l = rng.randint(length // 2 + 1, length) + overlap_w = rng.randint(width // 2 + 1, width) + offset_x = rng.randint(0, length - overlap_l) + offset_y = rng.randint(0, width - overlap_w) + + candidate = AxisAlignedBox(Point(_mm(offset_x), _mm(offset_y), _mm(10)), + Dimensions.mm(length, width, 10)) + supporter = AxisAlignedBox(Point(_mm(offset_x), _mm(offset_y), 0), + Dimensions.mm(overlap_l, overlap_w, 10)) + del lower, upper # built only to mirror the shapes the constraint sees + + hull = constraints.convex_hull(constraints.contact_hull_points(candidate, [supporter])) + assert constraints.point_in_hull(constraints.doubled_centroid(candidate), hull), ( + f"seed {seed}: {overlap_l}x{overlap_w} of {length}x{width} left the centroid outside" + ) + + +# -------------------------------------------- the representable stop range + + +def test_a_stop_index_past_double_precision_is_refused_rather_than_mis_ordered(): + """The bound exists because one engine cannot hold the number, not because of a limit. + + Route order is decided by comparing stop indices. JavaScript keeps numbers as doubles, + and `JSON.parse` collapses 2**53 + 1 to 2**53 before any constraint sees it -- so two + consecutive stops above the safe range become one number there while Python, PHP and + Rust keep them apart, and the four engines order the same load differently. The + JavaScript engine already refused unsafe integers; this is the other three agreeing. + + Refusing is the only honest option: the value cannot cross the wire identically, and + accepting it would mean returning a load plan that another engine would contradict. + """ + from packvium.models import MAX_EXACT_STOP_INDEX + + assert MAX_EXACT_STOP_INDEX == 2 ** 53 - 1 + Item.create("ok", Dimensions.mm(10, 10, 10), stop_index=MAX_EXACT_STOP_INDEX) + + for refused in (MAX_EXACT_STOP_INDEX + 1, 2 ** 53 + 1, -1): + with pytest.raises(ValueError, match="non-negative safe integer"): + Item.create("bad", Dimensions.mm(10, 10, 10), stop_index=refused) + + +def test_the_two_stops_that_collapse_into_one_are_exactly_the_pair_the_bound_excludes(): + """Names the failure the bound prevents, so the reason cannot be edited away. + + `float(2**53) == float(2**53 + 1)`, and the route rule compares stops. With both + admitted, an item due at 2**53 + 1 resting on one due at 2**53 would be read as the + same stop and allowed -- a later item burying an earlier one, which is the exact + violation `RouteOrderConstraint` exists to catch. + """ + assert float(2 ** 53) == float(2 ** 53 + 1) + assert 2 ** 53 != 2 ** 53 + 1 + with pytest.raises(ValueError, match="non-negative safe integer"): + Item.create("collapses", Dimensions.mm(10, 10, 10), stop_index=2 ** 53) diff --git a/tests/test_irregular_items.py b/tests/test_irregular_items.py new file mode 100644 index 0000000..adfc940 --- /dev/null +++ b/tests/test_irregular_items.py @@ -0,0 +1,371 @@ +"""The worked boundaries docs/IRREGULAR-ITEMS.md pins, asserted on the engine. + +These are the numbers the document commits to in prose. Pinning them here means a later +optimisation of the axis set or the compression arithmetic has to keep answering the +published examples, and it means PHP, Rust and JavaScript ( through ) have a +concrete target rather than a paragraph to interpret. + +The cross-implementation property tests live in `conformance/tests/test_irregular_items.py`, +where the independent oracle is. +""" + +from __future__ import annotations + +import pytest + +from packvium.compression import (PPM, CrushViolation, Pressure, applied_pressure, + effective_height_ticks, effective_volume_ticks3, + ratio_to_ppm) +from packvium.hull import MAX_COORDINATE, DegenerateHullError, HullShape, collide, validate +from packvium.geometry import Dimensions, ShapeType +from packvium.models import Item +from packvium.units import Length, Weight + + +def cube(side: int) -> HullShape: + return HullShape.of( + (x * side, y * side, z * side) + for x in (0, 1) for y in (0, 1) for z in (0, 1) + ) + + +TETRAHEDRON = ((0, 0, 0), (10, 0, 0), (0, 10, 0), (0, 0, 10)) + + +# ------------------------------------------------------------------ hull admission + +def test_a_hull_needs_four_vertices(): + with pytest.raises(DegenerateHullError, match="at least 4 vertices"): + validate(((0, 0, 0), (1, 0, 0), (0, 1, 0))) + + +def test_a_hull_may_not_repeat_a_vertex(): + """Four vertices of which two coincide describe a triangle, not a solid, and the + coplanarity test below would pass it by accident on some inputs.""" + with pytest.raises(DegenerateHullError, match="unique"): + validate(((0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 1, 0))) + + +def test_a_flat_hull_is_refused_rather_than_packed(): + """The failure this guards is not a crash. A zero-volume hull is separated on its own + normal from everything, so it would pass through every other item and still be + reported as a valid placement.""" + with pytest.raises(DegenerateHullError, match="coplanar"): + validate(((0, 0, 0), (10, 0, 0), (0, 10, 0), (10, 10, 0))) + + +def test_a_hull_authored_around_a_negative_origin_is_admitted(): + """Local frames are the author's choice; only placement moves a hull into container + coordinates, where the non-negative rule applies.""" + assert HullShape.of(((-5, -5, -5), (5, -5, -5), (-5, 5, -5), (-5, -5, 5))).vertices + + +def test_a_hull_coordinate_must_stay_inside_the_shared_exact_arithmetic_bound(): + """Python used to accept this while PHP, Rust and JavaScript refused it. + + The cap is a cross-engine input contract, not a limitation of Python's unbounded integer, + so accepting a wider domain here would make results depend on which language binding answered. + Both signs are checked because direct library callers may author a centred local frame. + """ + for outside in (MAX_COORDINATE + 1, -MAX_COORDINATE - 1): + with pytest.raises(DegenerateHullError, match="within 100000000 ticks"): + validate(((0, 0, 0), (outside, 0, 0), (0, 1, 0), (0, 0, 1))) + + +# ------------------------------------------------------------------ the worked SAT boundary + +@pytest.mark.parametrize("offset,expected", [(9, True), (10, False), (11, False)]) +def test_the_documented_ten_tick_cube_boundary(offset, expected): + """Offset 9 overlaps, 10 touches, 11 is clear -- touching is contact, not collision, + which is what lets a hull rest on a surface instead of colliding with it.""" + unit = cube(10) + assert collide(unit, (0, 0, 0), unit, (offset, 0, 0)) is expected + + +def test_a_tetrahedron_misses_a_cube_that_its_bounding_box_overlaps(): + """The case the whole SAT path exists for. The tetrahedron's envelope covers the cube's + corner, so the broad phase says maybe; the diagonal face says no.""" + tetra = HullShape.of(TETRAHEDRON) + unit = cube(4) + assert tetra.projection((1, 1, 1))[1] == 10 + assert collide(tetra, (0, 0, 0), unit, (7, 7, 7)) is False + + +def test_collision_does_not_depend_on_argument_order(): + tetra = HullShape.of(TETRAHEDRON) + unit = cube(6) + assert (collide(tetra, (0, 0, 0), unit, (3, 3, 0)) + == collide(unit, (3, 3, 0), tetra, (0, 0, 0))) + + +def test_face_axes_exclude_planes_that_cut_through_the_hull(): + """The optimisation that separates this module from the oracle. A cube has three + distinct face normals once opposite faces collapse onto one canonical direction; every + other vertex triple spans a plane that slices the solid.""" + assert cube(10).face_axes == ((0, 0, 1), (0, 1, 0), (1, 0, 0)) + + +# ------------------------------------------------------------------ pressure and compression + +def test_the_documented_compression_examples(): + height, ratio, limit = 100, 250_000, 100 + assert effective_height_ticks(height, ratio, limit, Pressure.zero()) == 100 + assert effective_height_ticks(height, ratio, limit, Pressure(50, 1)) == 88 + assert effective_height_ticks(height, ratio, limit, Pressure(100, 1)) == 75 + + +def test_the_crush_boundary_is_inclusive_and_the_next_step_is_a_violation(): + """`100.000001 kPa` in the document, expressed exactly: one part in a million above the + limit. A float would have to round this somewhere, which is why the contract is a + rational.""" + assert effective_height_ticks(100, 250_000, 100, Pressure(100, 1)) == 75 + with pytest.raises(CrushViolation, match="exceeds the declared limit"): + effective_height_ticks(100, 250_000, 100, Pressure(100_000_001, 1_000_000)) + + +def test_a_zero_limit_admits_only_zero_pressure(): + assert effective_height_ticks(40, PPM, 0, Pressure.zero()) == 40 + with pytest.raises(CrushViolation): + effective_height_ticks(40, PPM, 0, Pressure(1, 1_000_000)) + + +def test_a_fully_compressible_item_still_occupies_one_tick(): + """Zero height would let an item escape collision and support invariants rather than + merely occupy very little, so the floor is part of the contract, not a rounding guard.""" + assert effective_height_ticks(100, PPM, 10, Pressure(10, 1)) == 1 + + +def test_compression_never_claims_less_space_than_the_continuous_model(): + """87.5 rounds to 88, not 87: a discrete packer rounds occupied space up.""" + assert effective_height_ticks(100, 250_000, 100, Pressure(50, 1)) == 88 + + +def test_only_the_height_compresses(): + volume = effective_volume_ticks3(20, 30, 100, 250_000, 100, Pressure(100, 1)) + assert volume == 20 * 30 * 75 + + +def test_pressure_is_exact_under_standard_gravity(): + """One kilogram over one square metre is 9.80665 Pa, so 980665/100000000 kPa, which + reduces to 196133/20000000. Held reduced, so two engines that agree on the value cannot + disagree on the representation.""" + metre_ticks = Length.TICKS_PER_MM * 1_000 + pressure = applied_pressure(Weight(Weight.TICKS_PER_KG), metre_ticks * metre_ticks) + assert (pressure.numerator, pressure.denominator) == (196_133, 20_000_000) + + +def test_pressure_compares_without_leaving_the_integers(): + assert Pressure(100_000_001, 1_000_000).exceeds_kpa(100) + assert not Pressure(100_000_000, 1_000_000).exceeds_kpa(100) + + +def test_a_negative_load_is_an_error_rather_than_a_lifted_item(): + with pytest.raises(ValueError, match="cannot be negative"): + Pressure(-1, 1) + + +def test_the_public_ratio_rule_is_applied_once_at_the_boundary(): + assert ratio_to_ppm(0.25) == 250_000 + assert ratio_to_ppm(0.0) == 0 + assert ratio_to_ppm(1.0) == PPM + with pytest.raises(ValueError, match="between zero and one"): + ratio_to_ppm(1.5) + + +# ------------------------------------------------------------------ item admission + +def wedge_item(**kwargs) -> Item: + """A hull that fills half its declared box: the case an AABB would over-claim.""" + return Item.create( + "wedge", Dimensions.mm(10, 10, 10), shape_type=ShapeType.CONVEX_HULL, + hull_vertices=((0, 0, 0), (160_000, 0, 0), (0, 160_000, 0), (0, 0, 160_000)), + **kwargs, + ) + + +def cushion_item(**kwargs) -> Item: + return Item.create( + "cushion", Dimensions.mm(10, 10, 10), shape_type=ShapeType.COMPRESSIBLE, + compression_ratio_ppm=250_000, max_compression_pressure_kpa=100, **kwargs, + ) + + +def test_an_item_is_a_rigid_cuboid_unless_it_says_otherwise(): + """The default is what keeps every existing caller byte-identical.""" + item = Item.create("plain", Dimensions.mm(10, 10, 10)) + assert item.shape_type is ShapeType.RIGID_CUBOID + assert item.hull_vertices is None + assert item.compression_ratio_ppm is None + + +def test_the_wire_spelling_of_a_shape_is_accepted_and_normalised(): + assert wedge_item().shape_type is ShapeType.CONVEX_HULL + assert Item.create("plain", Dimensions.mm(1, 1, 1), shape_type="rigid_cuboid").shape_type \ + is ShapeType.RIGID_CUBOID + + +@pytest.mark.parametrize("field,value", [ + ("hull_vertices", ((0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1))), + ("compression_ratio_ppm", 250_000), + ("max_compression_pressure_kpa", 100), +]) +def test_a_rigid_cuboid_refuses_data_belonging_to_another_shape(field, value): + """Refused, not ignored: a dropped `compression_ratio` reads back as an item packed to + its declared limits when nothing ever applied them.""" + with pytest.raises(ValueError, match="not part of a rigid_cuboid item"): + Item.create("plain", Dimensions.mm(10, 10, 10), **{field: value}) + + +def test_a_hull_item_refuses_compression_data(): + with pytest.raises(ValueError, match="not part of a convex_hull item"): + wedge_item(compression_ratio_ppm=250_000) + + +def test_a_compressible_item_refuses_hull_vertices(): + with pytest.raises(ValueError, match="not part of a compressible item"): + cushion_item(hull_vertices=((0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1))) + + +def test_a_hull_item_without_vertices_is_refused(): + with pytest.raises(ValueError, match="requires hull_vertices"): + Item.create("wedge", Dimensions.mm(10, 10, 10), shape_type=ShapeType.CONVEX_HULL) + + +def test_a_compressible_item_needs_both_of_its_numbers(): + with pytest.raises(ValueError, match="requires both"): + Item.create("cushion", Dimensions.mm(10, 10, 10), + shape_type=ShapeType.COMPRESSIBLE, compression_ratio_ppm=250_000) + + +def test_a_hull_that_pokes_out_of_its_declared_box_is_refused(): + """`dimensions` stays the broad phase and the candidate envelope, so a hull larger than + it would be collision-tested against space the solver never reserved.""" + with pytest.raises(ValueError, match="does not fit inside dimensions"): + Item.create( + "spike", Dimensions.mm(10, 10, 10), shape_type=ShapeType.CONVEX_HULL, + hull_vertices=((0, 0, 0), (160_001, 0, 0), (0, 160_000, 0), (0, 0, 160_000)), + ) + + +def test_a_hull_exactly_filling_its_box_is_admitted(): + assert Item.create( + "block", Dimensions.mm(10, 10, 10), shape_type=ShapeType.CONVEX_HULL, + hull_vertices=tuple( + (x * 160_000, y * 160_000, z * 160_000) + for x in (0, 1) for y in (0, 1) for z in (0, 1) + ), + ).hull_vertices + + +@pytest.mark.parametrize("build", [wedge_item, cushion_item]) +def test_nesting_stays_unsupported_against_either_new_shape(build): + """Both rewrite occupied height. Choosing an order quietly would give four engines four + contracts, so the interaction is refused until a task defines it.""" + with pytest.raises(ValueError, match="not supported yet"): + build(nesting_height=Length.mm(2)) + + +# ------------------------------------------------------------------ refusals on bad input + +@pytest.mark.parametrize("call,message", [ + (lambda: Pressure(1, 0), "denominator must be positive"), + (lambda: Pressure.reduced(1, -2), "denominator must be positive"), + (lambda: applied_pressure(Weight(1), 0), "footprint area must be positive"), + (lambda: effective_height_ticks(0, 0, 1, Pressure.zero()), "height must be positive"), + (lambda: effective_height_ticks(1, PPM + 1, 1, Pressure.zero()), "one million ppm"), + (lambda: effective_height_ticks(1, 0, -1, Pressure.zero()), "cannot be negative"), + (lambda: effective_volume_ticks3(0, 1, 1, 0, 1, Pressure.zero()), "footprint dimensions"), +]) +def test_the_arithmetic_refuses_input_it_cannot_answer_for(call, message): + """Each of these would otherwise divide by zero, loop past a bound, or return a number + with no meaning. They are cheap to state and each one is a silent wrong answer avoided.""" + with pytest.raises(ValueError, match=message): + call() + + +@pytest.mark.parametrize("field,value,message", [ + ("compression_ratio_ppm", -1, "between zero and one"), + ("compression_ratio_ppm", PPM + 1, "between zero and one"), + ("max_compression_pressure_kpa", -1, "cannot be negative"), +]) +def test_a_compressible_item_refuses_a_number_outside_its_range(field, value, message): + numbers = {"compression_ratio_ppm": 250_000, "max_compression_pressure_kpa": 100} + numbers[field] = value + with pytest.raises(ValueError, match=message): + Item.create("cushion", Dimensions.mm(10, 10, 10), + shape_type=ShapeType.COMPRESSIBLE, **numbers) + + +def test_the_shape_memo_returns_what_a_fresh_build_would(): + """The memo may change how often a shape is built and never what it is. + + Asserted rather than assumed: a cache is the classic place for a determinism regression to + hide, because a wrong entry is invisible on the first call and only shows on the second. + Every rotation is asked for by name, because a memo keyed on the vertices alone would pass + any test that packs one orientation and hand a hull its neighbour's shape on the second. + """ + from packvium.hull import HullShape, rotate, shape_for + from packvium.models import Rotation + + vertices = ((0, 0, 0), (12, 0, 0), (0, 9, 0), (0, 0, 7), (12, 9, 0), (4, 3, 7)) + for rotation in Rotation: + fresh = HullShape.of(rotate(vertices, rotation.value)) + first = shape_for(vertices, rotation.value) + assert first == fresh + assert shape_for(vertices, rotation.value) is first + + +def test_the_memo_is_bounded_rather_than_growing_for_the_life_of_the_process(): + """Memory is part of the contract too: the memo drops rather than accumulating. + + A cache that never evicts turns a long-lived process packing many distinct catalogues into + a slow leak -- which would be trading one resource for another rather than saving anything. + """ + from packvium.hull import SHAPE_CACHE_ENTRIES, shape_for + + shape_for.cache_clear() + for offset in range(SHAPE_CACHE_ENTRIES + 50): + shape_for(((0, 0, 0), (10, 0, 0), (0, 10, 0), (0, 0, 10 + offset)), "LWH") + assert shape_for.cache_info().currsize <= SHAPE_CACHE_ENTRIES + + +def test_a_face_carrying_a_non_corner_vertex_is_wound_past_it(): + """A vertex sitting part-way along a face's edge must be walked past, not doubled back + through. + + This is the case the gift-wrap's collinear tie-break exists for: among candidates that + leave every other vertex on one side, it takes the farthest, which skips an edge-interior + point instead of turning the face into a degenerate spur. Getting it wrong does not raise + -- it produces a surface that fails to close and a volume that is quietly too small, which + is how the defect fixed in survived a review cycle. + + A pyramid on a square base, with a redundant vertex at the midpoint of one base edge. The + base is five coplanar points and only four of them are corners; the volume is a third of + the enclosing box either way, so the number is decided by whether the walk skipped it. + """ + from packvium.hull import HullShape, _cross, _dot, _ordered_face, _subtract + + shape = HullShape.of(((0, 0, 0), (10, 0, 0), (10, 10, 0), (0, 10, 0), (5, 0, 0), (5, 5, 10))) + assert shape.volume == 10 * 10 * 10 // 3 + + # The base face wound as four corners, with the midpoint dropped. + downward = (0, 0, -1) + extreme = max(_dot(vertex, downward) for vertex in shape.vertices) + base = [vertex for vertex in shape.vertices if _dot(vertex, downward) == extreme] + assert len(base) == 5, "the midpoint is still a vertex of the hull" + assert len(_ordered_face(base, downward)) == 4, "but it is not a corner of the face" + + # And the surface closes, which is the property the volume actually rests on. + residual = [0, 0, 0] + for axis in shape.face_axes: + for outward in (axis, (-axis[0], -axis[1], -axis[2])): + reach = max(_dot(vertex, outward) for vertex in shape.vertices) + face = [vertex for vertex in shape.vertices if _dot(vertex, outward) == reach] + if len(face) < 3: + continue + ordered = _ordered_face(face, outward) + apex = ordered[0] + for second, third in zip(ordered[1:], ordered[2:]): + normal = _cross(_subtract(second, apex), _subtract(third, apex)) + residual = [residual[i] + normal[i] for i in range(3)] + assert residual == [0, 0, 0] diff --git a/tests/test_irregular_packing.py b/tests/test_irregular_packing.py new file mode 100644 index 0000000..d12fd43 --- /dev/null +++ b/tests/test_irregular_packing.py @@ -0,0 +1,332 @@ +"""What the exact hull test buys, measured end to end through `Packer`. + +The unit tests in `test_irregular_items.py` prove the predicate; these prove the solver +actually consults it. The scene is two complementary wedges -- a box sliced along its +diagonal -- whose bounding boxes are identical and whose solids merely touch. A box-only +engine fits one of them in a box-sized container. An engine that asks the hull fits both. + +Every scenario is re-checked against the independent validator, so a wrong verdict cannot +pass here on a placement count while the layout underneath it is impossible. +""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from packvium import (AxisAlignedBox, Dimensions, Item, Length, Obstacle, PackingConfig, + Point, Rotation) +from packvium.geometry import ShapeType +from packvium.models import Placement, placements_collide +from packvium.nesting import occupied_volume +from packvium.serialization import pack_from_dict +from packvium.units import Weight +from support import assert_sound, container, item, pack + +MM = 16_000 +SIDE = 10 * MM + + +def lower_wedge(id: str, **kwargs) -> Item: + """The half of a 10mm cube below the diagonal `x/L + y/W <= 1`.""" + return Item.create( + id, Dimensions.mm(10, 10, 10), shape_type=ShapeType.CONVEX_HULL, + hull_vertices=((0, 0, 0), (SIDE, 0, 0), (0, SIDE, 0), + (0, 0, SIDE), (SIDE, 0, SIDE), (0, SIDE, SIDE)), + **kwargs, + ) + + +def upper_wedge(id: str, **kwargs) -> Item: + """Its complement, above the same diagonal.""" + return Item.create( + id, Dimensions.mm(10, 10, 10), shape_type=ShapeType.CONVEX_HULL, + hull_vertices=((SIDE, SIDE, 0), (SIDE, 0, 0), (0, SIDE, 0), + (SIDE, SIDE, SIDE), (SIDE, 0, SIDE), (0, SIDE, SIDE)), + **kwargs, + ) + + +def _placement(one: Item, x: int = 0, y: int = 0, z: int = 0) -> Placement: + instance, = one.instances() + point = Point(x, y, z) + return Placement(instance, point, Rotation.LWH, one.dimensions, point, one.dimensions) + + +#: One container, so "did it fit" is a question about space rather than about how many +#: containers the packer was willing to open. +ONE_CONTAINER = dict(max_containers=1) + + +def test_two_complementary_wedges_share_one_box_sized_space(): + """The whole point of the epic, stated as a packing outcome rather than a predicate.""" + items = [lower_wedge("a"), upper_wedge("b")] + box = container("cube", 10, 10, 10) + result = pack(items, [box], PackingConfig.balanced(**ONE_CONTAINER)) + placed = sorted(p.instance.id for c in result.containers for p in c.placements) + assert placed == ["a#1", "b#1"] + assert_sound(result, items, [box]) + + +def test_the_same_two_items_as_cuboids_do_not_fit(): + """The control. Without it the test above proves only that two items fit somewhere, not + that the hull is what made room for them.""" + items = [item("a", 10, 10, 10), item("b", 10, 10, 10)] + box = container("cube", 10, 10, 10) + result = pack(items, [box], PackingConfig.balanced(**ONE_CONTAINER)) + placed = [p.instance.id for c in result.containers for p in c.placements] + assert len(placed) == 1 + assert len(result.unpacked) == 1 + assert_sound(result, items, [box]) + + +def test_touching_wedges_are_contact_and_not_collision(): + """Directly on the shared predicate the solver, the validator and the obstacle check all + go through, so the three cannot drift apart on what "collides" means.""" + assert not placements_collide(_placement(lower_wedge("a")), _placement(upper_wedge("b"))) + assert placements_collide(_placement(lower_wedge("a")), _placement(lower_wedge("b"))) + + +def test_a_route_bound_hull_falls_back_to_its_box(): + """`packing_sequence` reasons with box sweeps, so a hull on a route is deliberately packed + as its box: one conservative answer in both places rather than two that disagree. + + Asserted on the predicate rather than on a pack outcome, because it is a rule about what + the engine is allowed to know, not about how many items happen to fit.""" + routed = _placement(lower_wedge("a", stop_index=0)) + assert placements_collide(routed, _placement(upper_wedge("b", stop_index=1))) + assert routed.hull_shape is None + assert occupied_volume(routed) == SIDE ** 3 // 2 + + +def test_a_clearance_makes_the_hull_fall_back_to_its_envelope(): + """A margin around a hull is not a hull; refining under clearance would hand back space + the caller asked to keep empty, so the envelope decides and the hull is not consulted.""" + one, = lower_wedge("a").instances() + physical = one.item.dimensions + inflated = physical.expand(Length.mm(1)) + origin = Point(0, 0, 0) + assert Placement(one, origin, Rotation.LWH, physical, origin, inflated).hull_shape is None + assert Placement(one, origin, Rotation.LWH, physical, origin, physical).hull_shape is not None + assert occupied_volume(Placement(one, origin, Rotation.LWH, physical, origin, inflated)) == SIDE ** 3 // 2 + + +@pytest.mark.parametrize("rotation", list(Rotation)) +def test_every_rotation_keeps_the_wedge_a_wedge(rotation): + """A bare coordinate permutation would mirror the shape for three of the six rotations. + Volume is the cheapest witness that none of them does: a reflected wedge has the same + bounding box and the opposite handedness.""" + from packvium.hull import _cross, _dot, _subtract, rotate + + vertices = rotate(lower_wedge("a").hull_vertices, rotation) + a, b, c, d = vertices[0], vertices[1], vertices[2], vertices[3] + assert _dot(_subtract(d, a), _cross(_subtract(b, a), _subtract(c, a))) != 0 + + +# ------------------------------------------------------------------ compressible items + +def cushion(id: str, **kwargs) -> Item: + """A 100mm cube that gives up a quarter of its height and fails above 100 kPa. + + Its footprint is exactly 0.01 m², which puts the crush boundary between 101 kg and + 102 kg of load -- close enough to state, far enough from a round number that a + floating-point shortcut would land on the wrong side of it. + """ + return Item.create( + id, Dimensions.mm(100, 100, 100), shape_type=ShapeType.COMPRESSIBLE, + compression_ratio_ppm=250_000, max_compression_pressure_kpa=100, **kwargs, + ) + + +def _stack(base: Item, topper: Item) -> list[Placement]: + base_instance, = base.instances() + top_instance, = topper.instances() + floor, above = Point(0, 0, 0), Point(0, 0, base.dimensions.height.ticks) + return [ + Placement(base_instance, floor, Rotation.LWH, base.dimensions, floor, base.dimensions), + Placement(top_instance, above, Rotation.LWH, topper.dimensions, above, topper.dimensions), + ] + + +@pytest.mark.parametrize("kilograms,crushes", [(100, False), (101, False), (102, True)]) +def test_the_crush_boundary_decides_whether_a_load_may_rest_on_a_cushion(kilograms, crushes): + """Asserted through the whole load-propagation path, not on the arithmetic alone, so a + footprint or a top-load taken from the wrong box would show up here.""" + from packvium.constraints import crushed, load_units + + failure = crushed(load_units(_stack(cushion("soft"), item("brick", 100, 100, 100, + weight=f"{kilograms}kg")))) + assert (failure is not None) == crushes + if crushes: + assert failure == ("crush_violation", "soft#1") + + +def test_a_crushing_stack_is_refused_rather_than_packed(): + """A crush is a hard boundary: the heavy item is left unpacked rather than arriving on + top of a flattened cushion. + + The cushion is pinned to the floor and the crate is exactly one footprint wide, so "on + top of the cushion" is the only place the brick could go. Without that the solver simply + puts the brick underneath -- a legal answer, and one that would have made this test pass + while proving nothing about crushing. + """ + items = [cushion("soft", must_be_on_floor=True), item("brick", 100, 100, 100, weight="102kg")] + box = container("crate", 100, 100, 200) + result = pack(items, [box], PackingConfig.balanced(**ONE_CONTAINER)) + placed = [p.instance.id for c in result.containers for p in c.placements] + assert placed == ["soft#1"] + assert [u.instance.id for u in result.unpacked] == ["brick#1"] + assert_sound(result, items, [box]) + + +def test_a_load_inside_the_limit_still_packs(): + """The control: without it the test above would also pass if a compressible item simply + refused every neighbour.""" + items = [cushion("soft", must_be_on_floor=True), item("brick", 100, 100, 100, weight="101kg")] + box = container("crate", 100, 100, 200) + result = pack(items, [box], PackingConfig.balanced(**ONE_CONTAINER)) + placed = sorted(p.instance.id for c in result.containers for p in c.placements) + assert placed == ["brick#1", "soft#1"] + assert_sound(result, items, [box]) + + +def test_the_final_validation_catches_a_crush_the_solver_never_produced(): + """The whole-container bearing pass runs on every result, stack-sensitive or not, so a + hand-built or externally-supplied plan cannot smuggle a crushed item past it.""" + from packvium.models import PackedContainer + from support import issues_for + + items = [cushion("soft"), item("brick", 100, 100, 100, weight="500kg")] + box = container("crate", 100, 100, 200) + packed = [PackedContainer(box, 0, tuple(_stack(items[0], items[1])))] + assert "crush_violation" in issues_for(items, [box], packed) + + +# ------------------------------------------------------------------ hulls against plain boxes + +def test_a_wedge_clears_an_obstacle_its_bounding_box_overlaps(): + """The hull-versus-box half of the predicate, end to end. + + The obstacle fills the quarter of the crate the wedge slopes away from. Their boxes + overlap across the whole footprint and their solids meet only along one edge, so a + box-only engine has nowhere to put this item and an exact one puts it on the floor. + """ + obstacle = Obstacle("post", AxisAlignedBox(Point(5 * MM, 5 * MM, 0), Dimensions.mm(5, 5, 10))) + crate = container("crate", 10, 10, 10, obstacles=(obstacle,)) + items = [lower_wedge("a")] + result = pack(items, [crate], PackingConfig.balanced(**ONE_CONTAINER)) + assert [p.instance.id for c in result.containers for p in c.placements] == ["a#1"] + assert_sound(result, items, [crate]) + + +def test_the_same_crate_has_no_room_for_the_wedge_as_a_cuboid(): + """The control for the obstacle case.""" + obstacle = Obstacle("post", AxisAlignedBox(Point(5 * MM, 5 * MM, 0), Dimensions.mm(5, 5, 10))) + crate = container("crate", 10, 10, 10, obstacles=(obstacle,)) + result = pack([item("a", 10, 10, 10)], [crate], PackingConfig.balanced(**ONE_CONTAINER)) + assert result.containers == () or not result.containers[0].placements + assert len(result.unpacked) == 1 + + +def test_a_wedge_and_a_box_that_only_touch_are_not_colliding(): + """Directly on the shared predicate, where the second solid is an ordinary cuboid rather + than another hull -- the branch the obstacle path above reaches through the solver.""" + wedge = _placement(lower_wedge("a")) + brick, = item("b", 5, 5, 10).instances() + corner = Point(5 * MM, 5 * MM, 0) + box = Placement(brick, corner, Rotation.LWH, brick.item.dimensions, corner, + brick.item.dimensions) + assert not placements_collide(wedge, box) + overlapping = Point(0, 0, 0) + assert placements_collide(wedge, Placement(brick, overlapping, Rotation.LWH, + brick.item.dimensions, overlapping, + brick.item.dimensions)) + + +def test_a_uniform_run_of_hulls_leaves_the_lattice_to_the_general_solver(): + """`GridSolver` tiles bounding boxes and would call the result exact. Two wedges of one + type are exactly the input that reaches it, so the delegation is asserted on the outcome + it protects: a lattice would have claimed two cells and overlapped the solids.""" + items = [lower_wedge("a", quantity=2)] + box = container("pair", 20, 10, 10) + result = pack(items, [box], PackingConfig.balanced(**ONE_CONTAINER)) + placed = sorted(p.instance.id for c in result.containers for p in c.placements) + assert placed == ["a#1", "a#2"] + assert_sound(result, items, [box]) + + +# ------------------------------------------------------------------ over the wire + +def _wire_request(items: list[dict], height: int = 100) -> dict: + return { + "units": {"length": "mm"}, + "configuration": {"solver_profile": "balanced", "max_containers": 1}, + "items": items, + "containers": [{"id": "crate", "inner_dimensions": { + "length": "100", "width": "100", "height": str(height)}}], + } + + +def _wire_point(x: int, y: int, z: int) -> dict: + return {"x": str(x), "y": str(y), "z": str(z)} + + +def test_a_hull_request_survives_the_wire_and_packs_as_a_hull(): + """`hull_vertices` is parsed through `Length`, so this also pins the wire convention: + non-negative offsets from the corner of the item's own bounding box.""" + lower = [_wire_point(0, 0, 0), _wire_point(100, 0, 0), _wire_point(0, 100, 0), + _wire_point(0, 0, 100), _wire_point(100, 0, 100), _wire_point(0, 100, 100)] + upper = [_wire_point(100, 100, 0), _wire_point(100, 0, 0), _wire_point(0, 100, 0), + _wire_point(100, 100, 100), _wire_point(100, 0, 100), _wire_point(0, 100, 100)] + result = pack_from_dict(_wire_request([ + {"id": "a", "dimensions": {"length": "100", "width": "100", "height": "100"}, + "shape_type": "convex_hull", "hull_vertices": lower}, + {"id": "b", "dimensions": {"length": "100", "width": "100", "height": "100"}, + "shape_type": "convex_hull", "hull_vertices": upper}, + ])) + packed = [p["item_id"] for c in result["containers"] for p in c["placements"]] + assert sorted(packed) == ["a#1", "b#1"] + # Two bounding boxes would fill the crate twice over; two hulls fill it exactly once. + assert int(result["containers"][0]["used_volume_ticks3"]) == (100 * MM) ** 3 + + +def test_a_hull_coordinate_below_zero_is_refused_at_the_wire(): + """`Length` owns non-negativity, and the refusal names it rather than producing a hull + mirrored into the wrong octant.""" + with pytest.raises(ValueError, match="cannot be negative"): + pack_from_dict(_wire_request([ + {"id": "a", "dimensions": {"length": "100", "width": "100", "height": "100"}, + "shape_type": "convex_hull", + "hull_vertices": [_wire_point(0, 0, 0), _wire_point(-1, 0, 0), + _wire_point(0, 100, 0), _wire_point(0, 0, 100)]}, + ])) + + +def test_a_compressible_request_survives_the_wire_and_compresses(): + """`compression_ratio` crosses as a JSON number and is turned into ppm exactly once, at + the boundary, so nothing downstream of the parser ever sees a float.""" + result = pack_from_dict(_wire_request([ + {"id": "cushion", "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}, + {"id": "brick", "dimensions": {"length": "100", "width": "100", "height": "100"}, + "weight": {"value": "101", "unit": "kg"}}, + ], height=200)) + packed = [p["item_id"] for c in result["containers"] for p in c["placements"]] + assert sorted(packed) == ["brick#1", "cushion#1"] + assert int(result["containers"][0]["used_volume_ticks3"]) < 2 * (100 * MM) ** 3 + + +def test_a_crushed_placement_reports_its_uncompressed_volume(): + """Reached only by a plan the solver would never build, and deliberately not an + exception: `crushed` already refuses this arrangement and the validator reports it, so a + volume property raising here would turn a reported issue into a crash.""" + from packvium.nesting import occupied_volume + + heavy = _stack(cushion("soft"), item("brick", 100, 100, 100, weight="500kg")) + crushed_placement = heavy[0] + assert crushed_placement.top_load.ticks == 0 + loaded = replace(crushed_placement, top_load=Weight.parse("500kg")) + assert occupied_volume(loaded) == loaded.dimensions.volume diff --git a/tests/test_optimality_bounds.py b/tests/test_optimality_bounds.py new file mode 100644 index 0000000..ae16a16 --- /dev/null +++ b/tests/test_optimality_bounds.py @@ -0,0 +1,286 @@ +"""The root lower bound is actually computed where the task says it is. + +The cross-implementation agreement and corpus soundness live in +`conformance/tests/test_optimality_bounds_engine.py`. What is asserted here is the wiring: +that an `exact_small` or global-beam solve really does record a bound, that it stays out of +the serialised result, and that the number it records is sound against the packing that +solve produced. + +That last part is why this is not a mock test. `hull_refinements` set the precedent for an +internal counter and nothing ever asserted it fired, so "internal" quietly became +"unobserved". A bound nobody checks is worse than none: it will be believed later. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "packvium-python" / "src")) + +import packvium # noqa: E402 +from packvium import bounds, solvers # noqa: E402 + +#: Four differently-shaped items. Identical items reach `GridSolver`'s quantity-compression +#: fast path, which answers before either target solver is consulted -- a request that never +#: exercises the code under test while looking like it does. +ITEMS = [ + { + "id": f"i{index}", + "quantity": 1, + "dimensions": { + "length": str(30 + 7 * index), + "width": str(20 + 3 * index), + "height": str(15 + 5 * index), + }, + } + for index in range(4) +] +CONTAINERS = [{"id": "c", "inner_dimensions": {"length": "100", "width": "100", "height": "100"}}] + + +def _pack(monkeypatch, configuration): + """Pack, and return the result together with every bound the solve computed.""" + recorded: list[tuple] = [] + original = bounds.compute + + def spy(instances, containers): + computed = original(instances, containers) + recorded.append(computed.as_tuple()) + return computed + + monkeypatch.setattr(solvers.bounds, "compute", spy) + result = packvium.pack_from_dict({ + "containers": CONTAINERS, + "items": ITEMS, + "configuration": dict(configuration, time_limit_ms=20000), + }) + return result, recorded + + +@pytest.mark.parametrize("configuration,expected_solver", [ + ({"solver_profile": "exact_small"}, "exact_small"), + ({"container_plan_beam_width": 4}, None), +]) +def test_a_bound_is_recorded_for_the_solvers_the_task_names(monkeypatch, configuration, + expected_solver): + """`exact_small` and the global container-set beam each reach the root bound.""" + result, recorded = _pack(monkeypatch, configuration) + assert recorded, f"no lower bound was computed for {configuration}" + if expected_solver is not None: + assert result["algorithm"]["solver"].startswith(expected_solver) + + +@pytest.mark.parametrize("configuration", [ + {"solver_profile": "exact_small"}, + {"container_plan_beam_width": 4}, + {"solver_profile": "exact_small", "container_plan_beam_width": 4}, +]) +def test_the_recorded_bound_is_sound_against_the_packing_it_bounded(monkeypatch, + configuration): + """The bound and the arrangement come from the same solve, so they must agree. + + `gap` raises on a score below its bound rather than returning a negative number, so + calling it is the assertion. + """ + result, recorded = _pack(monkeypatch, configuration) + score = [int(component) for component in result["score"]] + for bound in recorded: + bounds.gap(score, bounds.Bounds(*bound)) + + +def test_the_bound_does_not_reach_the_serialised_result(monkeypatch): + """Reporting a gap to a caller is a new public result field, and this project reserves + and rejects such a field before a contract freeze rather than adding it mid-line. + + Also the practical half: `algorithm.metrics` is serialised into every result, so a new + key there changes the bytes of every committed golden and would have to land in all four + engines at once. + """ + result, recorded = _pack(monkeypatch, {"solver_profile": "exact_small"}) + assert recorded, "the guard below would pass vacuously if nothing was computed" + metrics = result["algorithm"]["metrics"] + leaked = [key for key in metrics if "bound" in key or "gap" in key] + assert not leaked, f"the internal bound leaked into algorithm.metrics as {leaked}" + + +def test_an_ordinary_solve_computes_no_bound(monkeypatch): + """The constructive heuristics have nothing to add to it and do not pay for it. + + A capacity relaxation says the same thing whoever asks, so computing it for `grid` or + `layer` would be arithmetic nobody reads -- and this stays true until a caller-facing + field exists to read it. + """ + _result, recorded = _pack(monkeypatch, {"solver_profile": "fast"}) + assert not recorded + + +def test_a_non_default_objective_records_no_bound(monkeypatch): + """`lowest_cost` orders its score keys differently, so a bound vector compared against + it would line up cost against container count. + + Recording nothing is the honest answer; recording a vector that cannot be compared is + how a wrong gap gets published later. + """ + _result, recorded = _pack( + monkeypatch, {"solver_profile": "exact_small", "objective": "lowest_cost"}) + assert not recorded + + +def test_the_bound_survives_into_the_stats_object(): + """The field exists and carries the vector, which is what "internally available" means. + + Asserted directly rather than through a solve so that a rename of the field fails here + with a clear message instead of silently making every spy-based test above vacuous. + """ + stats = solvers.SearchStats() + assert stats.objective_lower_bound is None + stats.objective_lower_bound = (0, 1, 2, 3, 4) + assert stats.objective_lower_bound == (0, 1, 2, 3, 4) + assert "objective_lower_bound" not in stats.to_metrics().to_dict() + + +# ----------------------------------- the degenerate inputs the guards exist for +# +# Every branch below returns a *bound*, and a bound that is wrong on a degenerate request is +# wrong in the direction that matters: it claims work is unavoidable when it is not, or -- +# worse -- claims a score is impossible that a solver then achieves. The helpers are called +# directly because that is the only honest way to reach a guard whose whole purpose is to +# never be reached through the front door. + +def test_a_capacity_nobody_declared_is_infinite_rather_than_zero(): + """`None` means unbounded on the way in and on the way out. + + A payload limit nobody declared cannot be summed into a number; treating it as zero + would make every item unplaceable and the unpacked bound maximal. + """ + assert bounds._capacity_total([None, 5], [1, 1], unbounded_when_value_infinite=True) is None + # Volume is the exception: a container with no usable volume contributes nothing however + # many of it exist, so an absent value is skipped instead of poisoning the total. + assert bounds._capacity_total([None, 5], [1, 2], unbounded_when_value_infinite=False) == 10 + + +def test_unlimited_inventory_is_unbounded_only_when_the_type_carries_something(): + """An unlimited supply of zero capacity is still zero capacity.""" + assert bounds._capacity_total([7], [None], unbounded_when_value_infinite=True) is None + assert bounds._capacity_total([0], [None], unbounded_when_value_infinite=True) == 0 + + +def test_fitting_stops_at_the_item_that_exceeds_the_capacity(): + assert bounds._fit([2, 3, 4], 5) == 2 + assert bounds._fit([2, 3, 4], 100) == 3 + # No declared capacity fits everything rather than nothing. + assert bounds._fit([2, 3, 4], None) == 3 + + +def test_a_sum_past_the_ceiling_is_refused_by_the_guard_itself(): + assert bounds._guard(bounds.MAX_BOUND_SUM, "x") == bounds.MAX_BOUND_SUM + with pytest.raises(bounds.BoundOverflowError): + bounds._guard(bounds.MAX_BOUND_SUM + 1, "x") + + +def test_a_bound_that_cannot_cross_every_binding_exactly_is_refused(): + """Unlimited inventory must not hide a selected-cost overflow from the precheck.""" + instances = [ + packvium.ItemInstance( + packvium.Item.create(str(index), packvium.Dimensions.mm(1, 1, 1), 1), index + ) + for index in range(2) + ] + container = packvium.Container.create( + "c", packvium.Dimensions.mm(1, 1, 1), max_items=1, + cost_minor=bounds.MAX_BOUND_VALUE, + ) + + with pytest.raises(bounds.BoundOverflowError, match="exact portable result ceiling"): + bounds.compute(instances, (container,)) + + +def test_no_container_and_nothing_placed_bound_nothing(): + """Each key must degrade to zero rather than divide by a capacity that is not there.""" + assert bounds._container_bound([], [], False, 0, [], [], [], []) == 0 + assert bounds._cost_bound([5], [1], 0) == 0 + assert bounds._unused_volume_bound([], False, 0, [], 0) == 0 + assert bounds._stack_height_bound([], False, 0, [], [], 0) == 0 + + +def test_a_container_with_no_room_bounds_nothing_rather_than_dividing_by_it(): + """Zero inner volume, zero base area and zero height each reach their own guard.""" + assert bounds._unused_volume_bound([1], False, 1, [0], 1) == 0 + assert bounds._stack_height_bound([1], False, 1, [0], [10], 1) == 0 + assert bounds._stack_height_bound([1], False, 1, [10], [0], 1) == 0 + + +def test_an_item_count_limit_binds_the_unpacked_bound(): + """`max_items` is a capacity like volume and weight, and the worst of the three wins. + + The inventory has to be finite for it to bind: one container holding two of five items + strands three. Left unlimited, an unbounded supply of two-item containers strands + nothing and the count falls to `container_count` instead -- which is the same arithmetic + answering a different question. + """ + container = packvium.Container.create( + "c", packvium.Dimensions.mm(100, 100, 100), max_items=2, quantity=1) + item = packvium.Item.create("i", packvium.Dimensions.mm(1, 1, 1), 1, quantity=5) + instances = packvium.PackingRequest((item,), (container,)).instances + assert bounds.compute(instances, (container,)).unpacked_count == 3 + + unlimited = packvium.Container.create( + "u", packvium.Dimensions.mm(100, 100, 100), max_items=2) + strands_nothing = bounds.compute(instances, (unlimited,)) + assert strands_nothing.unpacked_count == 0 and strands_nothing.container_count == 3 + + +def test_a_slot_limit_raises_the_container_bound(): + """Five items into containers holding two each need three containers, by counting alone.""" + assert bounds._container_bound([1] * 5, [0] * 5, False, 5, [object()], [1000], [None], [2]) == 3 + + +def test_a_nesting_item_occupies_less_than_its_box(): + nesting = packvium.Item.create( + "n", packvium.Dimensions.mm(10, 10, 10), 1, nesting_height=packvium.Length.mm(5)) + plain = packvium.Item.create("p", packvium.Dimensions.mm(10, 10, 10), 1) + assert bounds._occupies_less_than_its_box(nesting) + assert not bounds._occupies_less_than_its_box(plain) + + +def test_a_score_below_its_bound_is_refused_and_an_attained_one_reports_no_gap(): + bound = bounds.Bounds(1, 1, 0, 0, 0) + with pytest.raises(bounds.UnsoundBoundError): + bounds.gap([0, 1, 0, 0, 0], bound) + attained = bounds.gap([1, 1, 0, 0, 0], bound) + assert attained.attained and attained.key is None and attained.absolute == 0 + missed = bounds.gap([2, 1, 0, 0, 0], bound) + assert not missed.attained and missed.key == 0 and missed.relative == (1, 1) + + +def test_a_nesting_request_drops_the_volume_argument_end_to_end(): + """The branch found unsound, exercised through `compute` rather than the helper. + + Five 10mm cubes cannot fit one 10mm container by volume, and the bound says four are + stranded. Declare a nesting height on the same items and the volume argument is dropped + entirely -- nominal volumes stop summing to anything a solution must carry -- so the + bound stops claiming anything is stranded at all. + """ + container = packvium.Container.create("c", packvium.Dimensions.mm(10, 10, 10), quantity=1) + solid = packvium.Item.create("s", packvium.Dimensions.mm(10, 10, 10), 1, quantity=5) + nesting = packvium.Item.create("n", packvium.Dimensions.mm(10, 10, 10), 1, quantity=5, + nesting_height=packvium.Length.mm(5)) + of = lambda item: packvium.PackingRequest((item,), (container,)).instances + assert bounds.compute(of(solid), (container,)).unpacked_count == 4 + nested = bounds.compute(of(nesting), (container,)) + assert nested.unpacked_count == 0 + # And every key that rests on the same argument goes with it rather than half-applying. + assert nested.unused_volume_ppm == 0 and nested.stack_height_ppm == 0 + + +def test_a_container_with_no_usable_volume_still_bounds_the_count_by_its_other_limits(): + """`max(usable) == 0` must skip the volume term, not divide by it. + + A container whose usable volume is zero still carries a payload limit, and the count + bound has to come from that instead of from a division nobody can perform. + """ + assert bounds._container_bound([1, 1], [5, 5], False, 2, [object()], [0], [4], [None]) == 3 diff --git a/tests/test_packing_sequence.py b/tests/test_packing_sequence.py index 7d4bc03..328990d 100644 --- a/tests/test_packing_sequence.py +++ b/tests/test_packing_sequence.py @@ -166,6 +166,41 @@ def test_a_second_escape_direction_saves_an_otherwise_blocked_stop(): assert order == [1, 0] # far (stop 0, index 1) lifts straight out first +def test_two_placements_due_at_the_same_stop_may_block_each_other(): + """Within one stop the unloading order is free, so being in the way is not a violation. + + This is the case `docs/STOP-ACCESSIBILITY.md`'s per-candidate rule is deliberately + optimistic about: its blocker set is `s(q) > s(p)`, strictly greater, and tightening it + to `>=` would refuse this arrangement -- two pallets for the same delivery, one behind + the other, which is an ordinary load rather than a defect. The rule is written against + this test, so a future implementation that gets the comparison wrong fails here rather + than in a customer's van. + """ + container = Dimensions.mm(20, 10, 10) + near, far = box(0, 0, 0, 10, 10, 10), box(10, 0, 0, 10, 10, 10) + assert safe_route_removal_order([near, far], [0, 0], container, directions=("-x",)) == [0, 1] + + +def test_the_same_packing_can_be_legal_through_one_wall_and_illegal_through_another(): + """Route legality is a property of the packing *and* the door, and only one of the two + is expressible in a request today. + + The arrangement below is what the solvers actually produce for a two-stop van load, and + it unloads correctly through `-x` and not at all through `+x`. Nothing in the request + schema names which wall the door is on -- `stop_index` is there and no access field is -- + so a solver enforcing route order has no way to know which of these two answers it is + being asked for. `docs/STOP-ACCESSIBILITY.md` records that as the first thing its design + needs and the reason the constraint cannot simply be switched on. + """ + container = Dimensions.mm(100, 20, 20) + early, late = box(0, 0, 0, 50, 20, 20), box(50, 0, 0, 50, 20, 20) + assert safe_route_removal_order([early, late], [0, 1], container, directions=("-x",)) == [0, 1] + with pytest.raises(RouteSequenceError) as excinfo: + safe_route_removal_order([early, late], [0, 1], container, directions=("+x",)) + assert excinfo.value.stop == 0 + assert excinfo.value.stuck == frozenset({0}) + + def test_no_routed_placements_schedules_nothing(): """Every existing single-stop request leaves `stop_index` unset on every item -- this must be a complete no-op, not merely a small one.""" diff --git a/tests/test_support_polygon.py b/tests/test_support_polygon.py index 5a77c43..3f4b3bc 100644 --- a/tests/test_support_polygon.py +++ b/tests/test_support_polygon.py @@ -8,7 +8,8 @@ from __future__ import annotations from packvium import AxisAlignedBox, Dimensions, Length, Point -from packvium.support_polygon import contact_hull_points, convex_hull, doubled_centroid, point_in_hull +from packvium.support_polygon import (contact_hull_points, convex_hull, doubled_centroid, + eight_times_area, point_in_hull) def box(x, y, l, w, h=10) -> AxisAlignedBox: @@ -102,3 +103,55 @@ def test_two_narrow_supporters_can_still_bracket_the_centroid(): right_rail = box(90, 0, 10, 100) hull = convex_hull(contact_hull_points(candidate, [left_rail, right_rail])) assert point_in_hull(doubled_centroid(candidate), hull) + + +# ---------------------------------- exact support-polygon area + +def test_the_area_of_a_hull_is_eight_times_the_true_one(): + """Hand-computed, and the factor is the whole reason this needs a test. + + `contact_hull_points` doubles every coordinate, which scales area by four, and the + shoelace sum is twice an area. A 100x100 footprint therefore reports 80,000 rather + than 10,000, and any caller comparing against a base area must scale it the same way. + """ + candidate = box(0, 0, 100, 100) + whole = convex_hull(contact_hull_points(candidate, [candidate])) + assert eight_times_area(whole) == 8 * 100 * 100 + + half = convex_hull(contact_hull_points(candidate, [box(0, 0, 100, 50)])) + assert eight_times_area(half) == 8 * 100 * 50 + + +def test_a_degenerate_hull_has_no_area(): + """A single contact point and a razor-thin strip are real placements, not errors.""" + candidate = box(0, 0, 100, 100) + assert eight_times_area(convex_hull([])) == 0 + assert eight_times_area(convex_hull([(0, 0)])) == 0 + assert eight_times_area(convex_hull([(0, 0), (200, 0)])) == 0 + # A supporter meeting the candidate along an edge alone contributes no area. + assert eight_times_area(convex_hull(contact_hull_points(candidate, [box(100, 0, 10, 100)]))) == 0 + + +def test_the_hull_area_is_not_the_contact_area_and_the_rails_prove_it(): + """Two thin rails: a fifth of the base is touched, and the hull covers all of it. + + This is the distinction the partial-base polygon predicate turns on. A rule reading + summed contact area sees 20%; a rule reading the support polygon sees 100%. Neither is + a worse measurement of the other -- they are answers to different questions, and the + published predicate asks the second. + """ + candidate = box(0, 0, 100, 100) + rails = [box(0, 0, 10, 100), box(90, 0, 10, 100)] + contact = sum(10 * 100 for _ in rails) + hull = convex_hull(contact_hull_points(candidate, rails)) + assert contact == 2_000 + assert eight_times_area(hull) == 8 * 100 * 100 + + +def test_one_centred_strip_is_where_the_two_polygon_rules_part(): + """The authors' Figure 4.4: centroid inside the hull, hull under half the base.""" + candidate = box(0, 0, 100, 100) + hull = convex_hull(contact_hull_points(candidate, [box(0, 40, 100, 20)])) + assert point_in_hull(doubled_centroid(candidate), hull) + assert eight_times_area(hull) == 8 * 100 * 20 + assert eight_times_area(hull) <= 8 * (100 * 100) // 2 diff --git a/tests/test_unsupported_fields.py b/tests/test_unsupported_fields.py index 3088e01..e1d164a 100644 --- a/tests/test_unsupported_fields.py +++ b/tests/test_unsupported_fields.py @@ -14,15 +14,21 @@ from __future__ import annotations +import json +from pathlib import Path + import pytest from packvium.serialization import ( UNSUPPORTED_FIELDS, + UNSUPPORTED_SHAPE_TYPES, UnsupportedFeatureError, pack_from_dict, reject_unsupported, ) +ROOT = Path(__file__).resolve().parents[2] + REQUEST = { "policy": {"rules": []}, "configuration": {"tariff": {}}, @@ -56,13 +62,69 @@ def test_a_request_that_touches_nothing_listed_is_accepted() -> None: def test_the_unsupported_lists_match_what_the_field_matrix_records() -> None: - # Each name here must carry a rejected:unsupported_feature level for Python in - # conformance/public-field-matrix.json, which is what makes the corpus assert the - # rejection rather than merely tolerate it. - assert UNSUPPORTED_FIELDS["request"] == () - assert UNSUPPORTED_FIELDS["configuration"] == () - assert UNSUPPORTED_FIELDS["item"] == () - assert UNSUPPORTED_FIELDS["container"] == () + """Every refusal this engine makes is recorded in the matrix, and the reverse. + + The assertion used to be that all four lists are empty, which was the same thing while + they were -- and stopped being the same thing the moment one was populated. What the + coupling is actually for is that the corpus *asserts* each rejection instead of merely + tolerating it, so read the matrix and compare both directions. + """ + matrix = json.loads( + (ROOT / "conformance/public-field-matrix.json").read_text() + ) + rejected_by_matrix = { + path + for path, row in matrix["fields"].items() + if matrix["support_sets"][row["support"]]["python"] + == "rejected:unsupported_feature" + } + declared = {f"{scope}s.*.{name}" if scope in ("item", "container") else name + for scope, names in UNSUPPORTED_FIELDS.items() for name in names} + # A value-keyed refusal is one matrix row for the field itself. `hull_vertices` is an + # array of points, so the schema's leaves -- and therefore its rows -- are the three + # coordinates, not the array. + declared |= {"items.*.shape_type"} if UNSUPPORTED_SHAPE_TYPES else set() + if "items.*.hull_vertices" in declared: + declared.discard("items.*.hull_vertices") + declared |= {f"items.*.hull_vertices.*.{axis}" for axis in "xyz"} + + assert declared == rejected_by_matrix, ( + "the engine and the matrix disagree about what Python refuses; " + f"engine only: {sorted(declared - rejected_by_matrix)}, " + f"matrix only: {sorted(rejected_by_matrix - declared)}" + ) + + +def test_the_default_shape_type_is_served_rather_than_refused() -> None: + """`rigid_cuboid` is implemented, so spelling the default out must not be a rejection. + + This is why `shape_type` is not in the presence-keyed table: that table means "this + engine does not implement the field at all", and a value-keyed refusal is a different + claim. A caller who writes the default explicitly is asking for what they already get. + """ + request = { + "units": {"length": "mm"}, + "items": [{ + "id": "a", + "shape_type": "rigid_cuboid", + "dimensions": {"length": "100", "width": "100", "height": "100"}, + }], + "containers": [{ + "id": "c", + "inner_dimensions": {"length": "200", "width": "200", "height": "200"}, + }], + } + assert pack_from_dict(request)["status"] == "feasible" + + +def test_an_unimplemented_shape_type_names_the_value_it_refused() -> None: + with pytest.raises(UnsupportedFeatureError) as caught: + reject_unsupported( + {"items": [{"id": "a", "shape_type": "convex_hull"}]}, + {"request": (), "configuration": (), "item": (), "container": ()}, + ("convex_hull",), + ) + assert "item.shape_type=convex_hull" in str(caught.value) def test_the_guard_is_wired_into_the_real_entry_point() -> None: