From c81201f1d6c6a7a63d1296498fc4a84f7f2d529f Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 18 Oct 2026 11:53:27 -0400 Subject: [PATCH] chore(release): 1.7.0 --- CHANGELOG.md | 41 + CITATION.cff | 2 +- README.md | 53 +- codemeta.json | 2 +- docs/COMMERCE-API.md | 25 +- docs/GUARANTEES.md | 4 +- docs/PUBLIC-API.md | 102 +- docs/UNITS-AND-NUMERICS.md | 6 +- pyproject.toml | 2 +- src/packvium/__init__.py | 18 +- src/packvium/_request_error.py | 50 + src/packvium/artifacts.py | 2 +- src/packvium/compression.py | 6 +- src/packvium/config.py | 20 +- src/packvium/constraints.py | 153 +- src/packvium/effort.py | 7 +- src/packvium/fixed_placements.py | 7 +- src/packvium/geometry.py | 14 +- src/packvium/made_to_measure.py | 15 +- src/packvium/models.py | 108 +- src/packvium/packer.py | 7 +- src/packvium/packing_sequence.py | 37 +- src/packvium/request_errors.py | 63 +- src/packvium/request_objects.py | 188 ++ src/packvium/result_validation.py | 2376 ++++++++++++++++++++++++ src/packvium/revisions.py | 3 +- src/packvium/serialization.py | 9 +- src/packvium/solvers.py | 234 ++- src/packvium/units.py | 51 +- tests/test_axle_load.py | 30 + tests/test_block_lattice.py | 51 +- tests/test_constraints.py | 51 +- tests/test_fixed_placements.py | 2 +- tests/test_geometry.py | 12 +- tests/test_models.py | 13 +- tests/test_packing_sequence.py | 9 + tests/test_request_errors.py | 24 +- tests/test_request_objects.py | 191 ++ tests/test_request_objects_corpus.py | 210 +++ tests/test_result_validation.py | 87 + tests/test_result_validation_corpus.py | 60 + tests/test_solver_edges.py | 20 +- tests/test_solvers.py | 8 + 43 files changed, 4048 insertions(+), 325 deletions(-) create mode 100644 src/packvium/_request_error.py create mode 100644 src/packvium/request_objects.py create mode 100644 src/packvium/result_validation.py create mode 100644 tests/test_request_objects.py create mode 100644 tests/test_request_objects_corpus.py create mode 100644 tests/test_result_validation.py create mode 100644 tests/test_result_validation_corpus.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bb2f3..bbb0608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,47 @@ What changed in `packvium` on PyPI, release by release. 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). +## [1.7.0] + +The reference validator ships in the package, `quality` looks ahead in its block solver, and a +request built from objects is refused as its JSON would be. No request or result field changes. + +### Added + +- **`packvium.validate(request, result)`, `packvium.Issue` and `packvium.MalformedResult`.** The + reference validator over the JSON documents: a list of `Issue` (`code`, `detail`), empty when + the result is valid, and `MalformedResult` for a result of the wrong shape. It shares no code + with the engine it checks. + +### Changed + +- **A request built from objects is refused as JSON is.** A model constructor that + refuses a value raises `InvalidRequestError` (code, reason, field `""`), a subclass of the + `ValueError` it raised before, and a mistyped value is still a `TypeError` too. `Packer.pack` + checks the objects with the JSON rules before solving. `FixedPlacement` with + `container_instance` below 1 is now refused by `pack` as `invalid_fixed_placement`, not by its + constructor. +- **Every schema enum value is refused as `not_allowed`.** A value outside its list in + `allowed_rotations`, the output and dimensional-weight units, `shape_type`, + `ground_contact_rule` or `configuration.solvers` is `invalid_request` with reason `not_allowed` + at its JSON Pointer, where some raised a `KeyError`. **`ticks` as an output unit is now + refused**; the schema never listed it. +- **`quality` looks ahead in the block solver.** Each round completes its leading + blocks greedily and commits the best, so the answer is never worse than the greedy pass. On our + benchmark 87 of 89 answers improved and none got worse, at 6% more CPU. Under a tight effort + budget the block start can now report `truncated` / `effort_limit`. `fast` and `balanced` are + unchanged. +- **A `nesting_height` not below the item's height is refused as `invalid_value`** at its + pointer, and with no `output` block the result is written in `units.length`'s schema code. + +### Fixed + +- **A tall stack no longer raises `RecursionError`**: the load rules walk stacks iteratively. +- **The covered ground-contact rule counts only supporters with positive area**, so an item no + longer rests on a box it touches only at an edge. +- **A quantity past 2^53 − 1 on the object path is refused** instead of hanging the solver. +- **A route violation names its violators in sorted order.** + ## [1.6.0] Boxes cut to measure, flat bags, stacking groups and a volume-first objective. A request that diff --git a/CITATION.cff b/CITATION.cff index 3eac267..807e5fd 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - name: "Packvium contributors" website: "https://packvium.com" -version: "1.6.0" +version: "1.7.0" license: MIT url: "https://packvium.com" repository-code: "https://github.com/toxakara/packvium-python" diff --git a/README.md b/README.md index eccb397..2116045 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ coordinates and rotations for each item, with a reason for anything that did not Full documentation, the constraint reference and benchmarks live at [packvium.com](https://packvium.com). -> **Version 1.6.0 — the public API is frozen.** Field names, status codes and the +> **Version 1.7.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. @@ -96,8 +96,9 @@ config = PackingConfig.balanced( The result says what stopped the search: `result.termination.code` is `complete`, `effort_limit` (reproducible), or `time_limit` (not reproducible). -`packvium.artifacts.replay_level(result.to_dict())` gives the same verdict as `exact` or -`not_guaranteed`. `reproducibility.py` shows four simulated machines giving four +`replay_level(result.to_dict())`, imported with `from packvium.artifacts import +replay_level` (a submodule `import packvium` does not load), gives the same verdict as +`exact` or `not_guaranteed`. `reproducibility.py` shows four simulated machines giving four different answers under a clock, and one answer under a budget. ## Solver profiles @@ -112,9 +113,14 @@ tries: | `quality` | More solvers and orderings, a beam over container choices, and exact search for small orders. Slower. | | `exact_small` | Bounded exact search when the order has at most `exact_item_limit` items (7, counting quantity); otherwise the same search as `balanced`. | -`PackingConfig.fast()`, `.balanced()`, `.quality()` and `.exact_small()` default to time -limits of 0.2, 1, 5 and 10 seconds; a JSON request defaults to 1 second whatever the -profile. An `effort_budget` makes any of them reproducible. +The JSON path and the `PackingConfig` constructors share a profile's solvers but not all of +its defaults. A JSON request defaults to 1 second, 8 item orderings (1 under `fast`) and +`alternatives: 3` (ranked answers, the winner included) whatever the profile. +`PackingConfig.fast()`, `.balanced()`, `.quality()` and `.exact_small()` default to 0.2, 1, 5 +and 10 seconds; `.fast()` keeps 1 ranked answer, and `.quality()` runs 24 orderings and keeps +5, so `"solver_profile": "quality"` in JSON searches +less than `PackingConfig.quality()` unless you set `multi_start_orders`, `alternatives` and +`time_limit_ms` to match. An `effort_budget` makes any of them reproducible. ## Trucks, axles and delivery routes @@ -130,8 +136,8 @@ A container can describe a vehicle, not only a box: After the solve, `safe_route_removal_order` gives an unloading order stop by stop, `replay_loading_order` checks the reverse as a loading order, and `placement_reachability` -says what can be reached when the doors open. An item the axle ratings cannot carry is -reported as `no_feasible_placement`, not as an axle problem. See `trucking.py`. +says what can be reached when the doors open. An item the axle ratings cannot carry even +alone in the vehicle is reported as `axle_overloaded`. See `trucking.py`. ## Errors @@ -162,6 +168,27 @@ raise `UnknownObjectiveError` or `InvalidDirectionError`). A request that is valid but does not fit completely is not an error: the result lists what was left out, and why, in `unpacked_items`. `errors.py` turns all of these into HTTP responses. +## Checking a result + +*New in 1.7.0.* + +`packvium.validate` checks a result against the request it answers, from the JSON documents +alone: geometry, support and load, accounting, the result's facts and its score. It is the +reference validator every Packvium package exports a port of, so they all return the same +issues, code and detail: + +```python +from packvium import MalformedResult, pack_from_dict, validate + +issues = validate(request, pack_from_dict(request)) # [] -- valid +for issue in issues: + print(issue.code, issue.detail) # e.g. collision cube#1 with cube#2 in box#1 +``` + +It shares no code with the engine, so it judges any engine's result, not only this package's. A +result without the documented shape raises `MalformedResult`, whose `kind` is `contract` or +`shape`. + ## Examples Runnable, in [`examples/`](https://github.com/toxakara/packvium-python/tree/main/examples), @@ -177,7 +204,7 @@ in `constraints.py` and `limits.py` have failed you. | File | What it shows | | --- | --- | | [`basic.py`](https://github.com/toxakara/packvium-python/blob/main/examples/basic.py) | The smallest useful call: items in, placements out — and the three details in it that are easy to miss. | -| [`objectives.py`](https://github.com/toxakara/packvium-python/blob/main/examples/objectives.py) | All six objectives on scenes where they genuinely disagree, including the rate card that makes the heavier shipment the cheaper one. | +| [`objectives.py`](https://github.com/toxakara/packvium-python/blob/main/examples/objectives.py) | Six of the seven objectives (all but `maximum_volume`) on scenes where they genuinely disagree, including the rate card that makes the heavier shipment the cheaper one. | | [`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. | | [`limits.py`](https://github.com/toxakara/packvium-python/blob/main/examples/limits.py) | A courier fleet: a van with a fridge unit and wheel arches as obstacles, an item cap per bike, one dangerous-goods item per van, frozen goods only in a refrigerated vehicle, and a cap on vehicles. | | [`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. | @@ -261,7 +288,9 @@ Python 3.9 or newer. No dependencies. ## The Packvium family One request and result contract, implemented independently in four engines (Rust, -Python, PHP, JavaScript) and held to identical placements on a shared fixture set. +Python, PHP, JavaScript) and checked against each other on a shared fixture set. Python and +PHP are held to byte-identical canonical results; Rust and the JavaScript fallback are held +to independent validity and a per-fixture objective floor. Pick the package for your stack; mixing them in one system is safe. Documentation, the constraint reference and the benchmarks are at @@ -271,7 +300,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 = "1.0"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | +| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.7"` | [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) | @@ -293,7 +322,7 @@ CodeMeta form. @software{packvium_python, author = {{Packvium contributors}}, title = {Packvium for Python}, - version = {1.6.0}, + version = {1.7.0}, license = {MIT}, url = {https://packvium.com} } diff --git a/codemeta.json b/codemeta.json index c917232..1a30786 100644 --- a/codemeta.json +++ b/codemeta.json @@ -3,7 +3,7 @@ "@type": "SoftwareSourceCode", "name": "Packvium for Python", "description": "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.", - "version": "1.6.0", + "version": "1.7.0", "license": "https://spdx.org/licenses/MIT", "author": [ { diff --git a/docs/COMMERCE-API.md b/docs/COMMERCE-API.md index 6237164..7fcc53d 100644 --- a/docs/COMMERCE-API.md +++ b/docs/COMMERCE-API.md @@ -160,9 +160,11 @@ may carry only `tariffs`. Field-level rules: than leaving it to be inferred from which fields the payload happens to carry. - An omitted optional field and an explicit JSON `null` mean the same thing: absent. Writing `"minimum_charge_minor": null` is exactly writing nothing. -- Every integer is exact and, except where a model explicitly allows zero, positive. - The per-field bounds are the ones the models already enforce — see - CATALOG-VERSIONING.md and POLICY-RULES.md. +- Every integer is exact. Dimensions, an item's `weight_g`, `max_payload_g`, + `max_stack_height_mm` and `dimensional_weight_divisor` must be positive; `effective_at`, + `published_at`, `cost_minor`, `minimum_charge_minor`, `fuel_surcharge_permille`, + `flat_charge_minor`, `permille_of_base` and each zone rate in + `cost_per_dimensional_kg_minor` may be zero but not negative. ### Input errors versus rejections @@ -322,13 +324,12 @@ original paths and re-export the relocated definitions, so every workspace test, against the exact same objects. There is one definition of `rate_tariff` in the Python tree, and the installed wheel contains it. `commerce/rating/objective.py` — the solver adapter — is untouched and stays a workspace module: it registers in-process scorer and -selector objects, which EXTENDING.md explains are deliberately not -cross-language features. +selector objects, which are deliberately not cross-language features: an in-process +object cannot cross the JSON boundary every other engine is driven through. ## Conformance standard -Held to the standard every other capability in this project is held to, per -TESTING-AND-RELEASE.md: +Held to the standard every other capability in this project is held to: - **Python and PHP: byte-identical.** Both are ports of one contract; canonical JSON (sorted keys, `,`/`:` separators, no trailing whitespace) of the result document must @@ -364,15 +365,15 @@ TESTING-AND-RELEASE.md: | `catalog_version_info` | `O(h + e log e)` | `O(e)` | The `e log e` term is sorting the three id lists; every other bound is a linear scan of -the relevant history. These match the bounds already published for the underlying -models in ALGORITHMS-AND-COMPLEXITY.md — the wrapper -adds parsing and serialization, both linear in the payload, and nothing else. +the relevant history. These are the bounds of the underlying models; the wrapper adds +parsing and serialization, both linear in the payload, and nothing else. ## Limitations - The document is supplied by the caller. Nothing here fetches, scrapes or embeds any - real carrier's published rates; live rate-card ingestion is still out of scope and - still tracked in LIMITATIONS-AND-ROADMAP.md. + real carrier's published rates. Live rate-card ingestion exists, but in the carrier + connectors, not here: CARRIER-RATE-INGESTION.md builds a + table from a feed, and this API is handed the result like any other document. - `catalog_version_info` returns metadata and id lists, not the resolved master-data records. Exporting the full snapshot is a larger surface with its own wire-format question and is not part of this epic. diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 1ee0c28..e0949d9 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -52,7 +52,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.6.0` freezes the public API. Field names, status codes, the objective +Version `1.7.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. @@ -61,7 +61,7 @@ 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 +The algorithm complexities Packvium states, in these docs and in the source, 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 df8fd8c..a784f42 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -15,8 +15,7 @@ false` dominates. The engines expose the same two fields on their item type: Python `Item.stacking_group` / `Item.accepts_stacking_groups`, PHP trailing constructor parameters `$stackingGroup` / `$acceptsStackingGroups`, Rust `Item::stacking_group` / - `Item::accepts_stacking_groups`, and the TypeScript request type. See - STACKING-GROUPS.md. + `Item::accepts_stacking_groups`, and the TypeScript request type. - **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 @@ -26,11 +25,12 @@ 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. + both**. On the shared fixtures Python and PHP are held to byte-identical results, + Rust and JavaScript to valid results no worse than the native quality budget. The + JavaScript fallback runs its own, smaller portfolio (eight item orderings, one under `fast`, + and four solvers under `quality`), so on a scene whose answer depends on item ordering it can + pack validly but less densely; any such difference 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 @@ -42,7 +42,6 @@ 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 @@ -75,7 +74,7 @@ parameter `$deformableBoundary` with `PackedContainer::achievedDimensions()` / `billedDimensions()`, Rust `Container::deformable_boundary: Option` with `PackedContainer::achieved_dimensions()` / `billed_dimensions()`, and the TypeScript request - type. See DEFORMABLE-CONTAINERS.md. + type. - **Made-to-measure containers.** `made_to_measure` (absent or `null`: a stock container) makes the container an on-demand box machine whose largest box is `inner_dimensions`: `minimum` and `increment` per axis (required; `minimum` equal to @@ -93,8 +92,7 @@ `invalid_value`. The engines expose it as Python `Container.made_to_measure` (a `packvium.MadeToMeasure`, last field), PHP the trailing constructor parameter `?MadeToMeasure $madeToMeasure` (`Packvium\Domain\MadeToMeasure`), Rust - `Container::made_to_measure: Option`, and the TypeScript request type. See - MADE-TO-MEASURE-CONTAINERS.md. + `Container::made_to_measure: Option`, and the TypeScript request type. - `PackingConfig`: solver profile, deadline, alternatives, deterministic seed, clearance, support threshold (`minimum_support_ratio`, default 0: support is not required, and an item may rest on nothing above the floor; set it above 0 to require that share of the @@ -104,7 +102,7 @@ that bounds all four stable implementations by counted work instead of wall-clock time. In code it is Python `packvium.EffortBudget`, PHP `Packvium\Config\EffortBudget` and Rust `packvium_core::EffortBudget`. `solvers` is an ordered list: every named strategy is run, stable ties prefer - the earlier name, and an unknown name is a structured error. The optional + the earlier name, and an unknown name is a structured error. The stable solver names are `grid`, `extreme_points`, `homogeneous_blocks`, `layer`, `maximal_spaces` and `exact_small`. `homogeneous_blocks` is the unrestricted solid-lattice quality strategy and safely delegates to `extreme_points` when a scene @@ -114,8 +112,7 @@ example, for multiple declared item types or a custom hard constraint); it does not silently return an empty lattice result. The optional `shipping_cost` objective accepts exact dimensional-weight divisor/unit inputs and ranks billable weight from the shipped container's outer dimensions. These fields - have identical semantics in all four implementations — see - ALGORITHMS-AND-COMPLEXITY.md. + have identical semantics in all four implementations. `require_placement_coordinates` (Python/PHP/Rust/JavaScript, default `true`) lets a caller trade per-item coordinates for speed and memory on a bulk single-item-type order: when `false` and `GridSolver`'s regular-lattice preconditions already hold (see the @@ -138,18 +135,18 @@ search with `invalid_fixed_placement`. Python `Packer.pack(items, containers, fixed_placements)` and PHP `Packer::pack($items, $containers, $fixedPlacements)` take `FixedPlacement` values; Rust takes them in `PackingRequest.fixed_placements`, and every - JSON entry point reads the field. PLAN-REVISIONS.md is the contract; - all four engines implement it. + JSON entry point reads the field; all four engines implement it. - `catalog_versions_used`: optional immutable catalog references resolved before packing. Each reference has exactly `catalog_id`, positive `version`, non-negative `published_at_epoch_ms`, and `content_digest`; catalog IDs are unique. Production packing uses a monotonic system clock. Test harnesses and deterministic simulations may inject a monotonic clock into Python `Packer(..., clock=...)`, PHP -`Packer(..., clock: ...)`, Rust `Deadline::with_clock(...)` with -`SolverRegistry::solve_with_deadline(...)`, or the JavaScript fallback's optional clock -argument. The callable returns an implementation-native monotonic value; it must never -move backwards. This is a testing seam, not a serialized request field. +`Packer(..., clock: ...)`, or Rust `Deadline::with_clock(...)` with +`SolverRegistry::solve_with_deadline(...)`. `@packvium/engine` exposes no clock: its +`pack(request)` takes the request alone. The callable returns an implementation-native +monotonic value; it must never move backwards. This is a testing seam, not a serialized +request field. ## Core output @@ -161,8 +158,7 @@ move backwards. This is a testing seam, not a serialized request field. alongside it. The pre-1.1.0 extension point remains open: closing the object would make a previously valid producer fail a 1.1.0 validator, which is a breaking change and therefore not legal in this minor release. No engine emits the reserved fields - yet, so `code` is still the only key any Packvium result carries. See - OPTIMALITY-CERTIFICATES.md; + yet, so `code` is still the only key any Packvium result carries; - legacy `status`: `optimal`, `feasible`, `best_found`, `time_limit`, `infeasible`, or `invalid_result`; - packed containers with exact coordinates, dimensions, rotations, and a `centre_of_mass_offset_ppm` — the weighted centre of mass's exact-integer Chebyshev @@ -174,7 +170,7 @@ move backwards. This is a testing seam, not a serialized request field. share the positive `denominator`; signed numerators intentionally expose a centre of mass outside the axle span instead of clipping it; - unpacked instances with reason codes, proof levels and rejection observations; -- objective score — a five-key lexicographic vector of exact integers, identical across every implementation (OBJECTIVE.md) — and solver statistics; +- objective score — a five-key lexicographic vector of exact integers, lower is better, computed by the same formula in every implementation — and solver statistics. Under the default objective the keys are `unpacked_count`, `container_count`, `total_cost_minor`, `unused_volume_ppm` (per container `floor((V - used) x 10^6 / V)` of inner volume `V`) and `stack_height_ppm` (per container `floor(top x 10^6 / H)` of inner height `H`); `configuration.objective` selects one of seven orderings (`default`, `lowest_cost`, `shipping_cost`, `lowest_landed_cost`, `open_dimension_height`, `maximum_value`, `maximum_volume`), and the result echoes it in `objective`; - the exact `catalog_versions_used` lineage from the request (an empty array when no catalog was involved), preserved by Python, PHP, Rust and JavaScript; - warnings and top-K alternatives. With `minimum_support_ratio` at its default 0, a result @@ -285,8 +281,7 @@ own `code` property): {"code": "invalid_direction", "direction": "sideways"} ``` -`conformance/scene/sequence-fixtures.json` pins this shape across all four languages; -see `docs/SERIALIZATION.md`. +A shared fixture pins this shape across all four languages. Sequence diagnostics use a separate canonical `SequenceWarning` DTO instead of putting caller-facing prose into an untyped string. Its four fields are stable in @@ -409,6 +404,12 @@ without turning a minor release into a parsing break. `status` remains for one compatibility cycle and is checked against the three facts. The alpha release emits no `proven_optimal` claim without a future proof/certificate. +`status: infeasible`, with `feasibility: infeasible` and `optimality: proven_infeasible`, is +emitted only when the portfolio ranks no result at all. A request whose every item is proven +unpackable is not that case: every engine answers `status: best_found`, `feasibility: +unknown` and `optimality: best_found`, with each item and its `proven` reason in +`unpacked_items`. Read `unpacked_items`, not `status`, to learn that nothing fits. + ## Portfolio termination `termination` describes the whole solver portfolio instead of copying one ambiguous @@ -457,8 +458,9 @@ The aggregate fields are derived only from `starts`. Exactly one record is selec clock, and `effort_limit` when the selected start stopped at a counted-work boundary. A truncated loser alone leaves the selected answer's code `complete`, while still making `all_required_starts_completed` false. The two algorithm limit flags preserve -the exact cause. -`winning_start_truncated || global_deadline_reached`. +the exact cause: one of `algorithm.time_limit_reached` and `algorithm.effort_limit_reached` +is true exactly when `winning_start_truncated || global_deadline_reached`, and +`global_deadline_reached` requires `time_limit_reached`. ## Rejection proof @@ -489,7 +491,7 @@ The four levels have deliberately different meanings: | Level | Meaning | | --- | --- | | `proven` | A request-level bound proves rejection without relying on search completeness. Currently `no_compatible_container_dimensions`, `payload_exceeded`, `rotation_restricted` and `no_eligible_container` — each a pure geometry or tag check across every container, none dependent on how far the search got. | -| `observed` | The engine exhausted or rejected the candidates it actually examined; this is not a global impossibility proof. | +| `observed` | The engine exhausted or rejected the candidates it actually examined; this is not a global impossibility proof. Currently `no_feasible_placement`, `search_exhausted`, `exact_search_incomplete` and `insufficient_support` — the last when replaying the finished layout finds a place the item fits once the support rules are set aside, and none with them, in every engine. | | `inferred` | The reason follows from solver state or a compound rule but has no complete certificate. | | `unknown_due_to_limit` | A deadline stopped the search, so no negative conclusion is claimed. | @@ -510,6 +512,28 @@ load can only refuse an item a fresh container would take, so they all end there relieve the front one -- and both rank after the structural proofs, the deadline, the effort budget and `group_cannot_fit_together`. +## Result validation + +Every package can check a result against the request it answers with the same reference +validator, and gets the same issue list back: geometry, support and load, accounting, the +result's facts and its score, each issue a `code` and a `detail`, an empty list meaning +valid. A result without the documented shape is refused as `MalformedResult`, whose `kind` is +`contract` or `shape`. + +- Python: `packvium.validate(request, result)`, `packvium.MalformedResult`. +- Rust: `packvium_core::validate_json(request, result)` over JSON text, returning + `Vec` or `MalformedResult { kind, message }`; `validate_json_outcome` returns the + same as one JSON document for a binding. +- PyO3 (`packvium_native_rust`): `validate_json(request, result)` over JSON text, a list of + `{"code", "detail"}` dicts, `MalformedResult` (a `ValueError`) with `kind`. +- `@packvium/engine`: `validate(request, result)`, `MalformedResult`, and + `ValidatorUnavailableError` (`code: 'validator_unavailable'`) when the compiled addon is + absent, until the JavaScript fallback's port. +- `@packvium/browser`: `validate(request, result)`, a promise, `MalformedResult`. + +- PHP: `Packvium\Validation\validate(array $request, array $result)`, a `list` + (`code`, `detail`), `MalformedResult` with `kind`. + ## Commercial and control-plane API Three deterministic functions over one canonical JSON document -- a carrier quote, an @@ -546,8 +570,7 @@ request returns a result with a status rather than raising. A document derived from an already validated packing result: what to do first, why this carton, and what was not packed. It is a view, not a decision — it calls no solver and no -validator, and a test in each language asserts that. The contract is -EXECUTION-PLAN.md. +validator, and a test in each language asserts that. | Language | Entry point | | --- | --- | @@ -609,8 +632,7 @@ One portable document built from a validated result. It carries: of the solver record, and an honest replay level. It is exported as JSON, RFC 4180 CSV, or a self-contained printable HTML work order. Like the -plan it is a view, not a decision: no solver, validator, renderer or clock is called. The -contract is OPERATIONAL-ARTIFACTS.md. +plan it is a view, not a decision: no solver, validator, renderer or clock is called. | Language | Builder | Exports | | --- | --- | --- | @@ -645,8 +667,7 @@ An append-only, hash-chained record of what differed from an approved plan: a mi a substituted carton, an operator lock or a verified placement. Each revision derives the request the next plan solves; a replan is an ordinary solve of it, with locks and verified placements carried as `fixed_placements`. A revision is a pure function of its parent, the -approved operational artifact and its events, and calls no solver or clock. The contract is -PLAN-REVISIONS.md. +approved operational artifact and its events, and calls no solver or clock. | Language | Surface | | --- | --- | @@ -663,9 +684,8 @@ stopping at the first. Refusals use `invalid_revision`, `invalid_event`, `event_ ## Scenario and recommendation API Scenario what-if comparison and catalog/rule recommendation publication, exported as -`packvium.simulation` and `packvium.recommendations`. The full contract is -INTELLIGENCE-API.md; what belongs here is the surface and its two -limits, both of which are part of the API rather than notes about it. +`packvium.simulation` and `packvium.recommendations`. This section is the surface and its +two limits, both of which are part of the API rather than notes about it. | Language | Entry point | | --- | --- | @@ -677,7 +697,7 @@ document is proved by driving four engines as subprocesses over JSON. `run_scena an `OrderEvaluator` *callback* as its central argument, and a callback does not cross a pipe -- so the harness that proves the packing and commerce contracts cannot express a scenario at all. Whether the data-in/data-out half ever crosses runtimes is an open -question, not an omission; INTELLIGENCE-API.md scopes it. +question, not an omission. **Historical replay ships with the ledger it reads.** `evaluate_on_history` scores a recommendation against decisions held out by an explicit `split_at`, and could not be @@ -700,10 +720,10 @@ importing the submodule, which puts them outside the frozen API snapshot the pac commerce surfaces are held to. They work, they are tested, and they will not be withdrawn inside 1.x — but a parameter may be renamed or a returned value gain a field in a minor release, announced in the changelog rather than blocked by a gate. Pin the version if you -depend on the exact shape. COMPATIBILITY.md states why this one surface -is held that way and what would change it. +depend on the exact shape. ## JSON API Python, PHP, Rust and the JavaScript fallback accept the same top-level keys: `units`, -`configuration`, `items`, `containers`, `catalog_versions_used`, and `output`. +`configuration`, `items`, `containers`, `catalog_versions_used`, `policy`, `output` and +`fixed_placements`. diff --git a/docs/UNITS-AND-NUMERICS.md b/docs/UNITS-AND-NUMERICS.md index 272c4ee..fd1e6e6 100644 --- a/docs/UNITS-AND-NUMERICS.md +++ b/docs/UNITS-AND-NUMERICS.md @@ -20,7 +20,7 @@ Conversion supports floor, ceiling and ties-to-even nearest rounding. Applicatio ## Geometry versus scores -Feasibility checks are integer-only, including the support ratio: the requested fraction is converted once to parts per million and compared as `supported_area >= floor(base_area x ratio_ppm / 10^6)`, never as a float division against an epsilon. The independent validator rechecks boundaries and intersections using exact integers. +Feasibility checks are integer-only, including the support ratio: the requested fraction is converted once to parts per million, `ratio_ppm = floor(ratio x 10^6 + 0.5)` on the IEEE double the request carries (each step correctly rounded, so every language gets the same integer; on about one in a hundred seven-decimal halves it differs from rounding the exact decimal, `0.0001245` giving 124), and compared as `supported_area >= floor(base_area x ratio_ppm / 10^6)`, never as a float division against an epsilon. Every result validator applies this same rule rather than the exact rational. `void_fill_reserve_ratio` goes through the same conversion and reserves `floor(inner_volume x ratio_ppm / 10^6)` cubic ticks, which the validators recompute the same way. The independent validator rechecks boundaries and intersections using exact integers. The Rust solver and validator were the exception until 2026-09-02: both compared `area / base_area + 1e-12 < ratio` in `f64`. On a real-sized base (a square centimetre is `2.56 x 10^10` square ticks) an area one square tick short of the requirement sits well inside that epsilon, so Rust admitted a placement the shared validator refused. Both now go through `support_area_sufficient`, the rule above; the `f64` ratio survives only as the reported `support_ratio` on the placement record. @@ -33,7 +33,7 @@ Coordinates remain 64-bit integers. Values that exceed that range are handled wi | Quantity | Why it overflows | How it is handled | | --- | --- | --- | | Volume, for output and ordering | A one-metre cube is `4.096 x 10^21` cubic ticks | `BigInt::multiply`/`add`, compared as decimal integers; this includes exact-small's equal-count physical-volume tie-break | -| Objective keys 3 and 4 | Ratio of two volumes | `BigInt::subtract` / `BigInt::divide`, see OBJECTIVE.md | +| Objective keys 3 and 4 | `unused_volume_ppm`, per container `floor((V - used) x 10^6 / V)`, scales a volume by 10^6; `stack_height_ppm` is `floor(top x 10^6 / H)` | `BigInt::subtract` / `BigInt::divide` | | Load shares, `weight x area / total_area` | Eighth-microgram counts times squared ticks | `Arithmetic::mulDiv`, exact and overflow-free | | Serialized decimals | Ticks beyond `2^53` do not survive a `double` | long division on digits, rounded ties-to-even | @@ -46,7 +46,7 @@ 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 +The engines compute lower bounds on the objective during search. 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. diff --git a/pyproject.toml b/pyproject.toml index 091478d..3bfb456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "packvium" -version = "1.6.0" +version = "1.7.0" description = "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results." readme = "README.md" requires-python = ">=3.9" diff --git a/src/packvium/__init__.py b/src/packvium/__init__.py index 08a8999..b8a1688 100644 --- a/src/packvium/__init__.py +++ b/src/packvium/__init__.py @@ -59,8 +59,8 @@ "ALL_DIRECTIONS", "AlgorithmReport", "Axle", "AxisAlignedBox", "Container", "DefaultSolutionScorer", "Dimensions", "EffortBudget", "ExtensionRegistry", "FixedPlacement", "FixedPlacementError", "InvalidRequestError", "PolicyError", "UnknownObjectiveError", "UnknownSolverError", "UnsupportedFeatureError", - "IndependentSolutionValidator", "InvalidDirectionError", "Item", "ItemInstance", "LEVEL_PREFIXES", "Length", - "LoadingDependencyGraph", "MadeToMeasure", "NestedPacker", + "IndependentSolutionValidator", "InvalidDirectionError", "Issue", "Item", "ItemInstance", "LEVEL_PREFIXES", "Length", + "LoadingDependencyGraph", "MadeToMeasure", "MalformedResult", "NestedPacker", "NestedPackingResult", "Obstacle", "PackedContainer", "Packer", "PackingConfig", "PackingLevel", "PackingRequest", "PackingResult", "PackingStatus", "Placement", "Point", "REASON_MESSAGES", "Reachability", "ReasonProof", "RebalanceResult", "RejectionObservation", "ResultFact", "Rotation", @@ -73,5 +73,17 @@ "Weight", "dimensional_weight", "explain_reason", "explain_unpacked_item", "explain_unpacked_items", "pack_from_dict", "placement_reachability", "rebalance_weight", "replay_loading_order", "replay_removal_order", "safe_loading_order", "safe_loading_order_with_evidence", "safe_removal_order", - "safe_removal_order_with_evidence", "safe_route_removal_order", "use_trace", + "safe_removal_order_with_evidence", "safe_route_removal_order", "use_trace", "validate", ] + +#: The result validator is a large module most processes never call, so it is imported on first +#: use (PEP 562) rather than at `import packvium`: every packing process paid its compile time. +_RESULT_VALIDATION = frozenset({"Issue", "MalformedResult", "validate"}) + + +def __getattr__(name: str) -> object: + if name in _RESULT_VALIDATION: + from . import result_validation + + return getattr(result_validation, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/packvium/_request_error.py b/src/packvium/_request_error.py new file mode 100644 index 0000000..3a922c9 --- /dev/null +++ b/src/packvium/_request_error.py @@ -0,0 +1,50 @@ +"""The request error type, with no dependencies, so the models that refuse a value can raise it. + +`request_errors` re-exports `InvalidRequestError` and holds the rules that raise it over a JSON +request; this module only defines the type and the form a model's own refusal takes. +""" + +from __future__ import annotations + + +class InvalidRequestError(ValueError): + """The request is not one any engine may answer. Nothing was solved.""" + + code = "invalid_request" + + def __init__(self, reason: str, field: str, detail: str) -> None: + self.reason = reason + self.field = field + self.detail = detail + super().__init__(self._message()) + + def _message(self) -> str: + return f"{self.code}: {self.field}: {self.detail}" if self.field else f"{self.code}: {self.detail}" + + +class ObjectRefusal(InvalidRequestError): + """A model refused a value while being built on its own, before it is part of a request, so + there is no field to name; `Packer.pack` names the field for what it checks itself. + + The JSON path reports a refusal its rules did not name as `invalid_value` with this detail, + the form every engine shares, so `serialization` turns one of these back into that.""" + + def __init__(self, reason: str, detail: str) -> None: + super().__init__(reason, "", detail) + + def _message(self) -> str: + # The text a model has always raised, so a wrapper that quotes it (a commerce document, + # a fixed placement's coordinate check) reads exactly what it read before. + return self.detail + + +class ObjectTypeRefusal(ObjectRefusal, TypeError): + """A mistyped value: still a `TypeError`, as before it was a request error.""" + + +def refused(reason: str, detail: str) -> ObjectRefusal: + return ObjectRefusal(reason, detail) + + +def refused_type(detail: str) -> ObjectTypeRefusal: + return ObjectTypeRefusal("wrong_type", detail) diff --git a/src/packvium/artifacts.py b/src/packvium/artifacts.py index 6d91595..76821dd 100644 --- a/src/packvium/artifacts.py +++ b/src/packvium/artifacts.py @@ -33,7 +33,7 @@ #: The suite version of this builder, the same string in all four engines of one release. #: The engine's own name is deliberately not recorded: four correct builders naming themselves #: would emit four different documents. `make version-set` rewrites this line. -SUITE_VERSION = "1.6.0" +SUITE_VERSION = "1.7.0" #: The deterministic part of `result.algorithm`. `duration_ms` is wall-clock time and never #: enters an artifact. diff --git a/src/packvium/compression.py b/src/packvium/compression.py index 541984b..d83c6d3 100644 --- a/src/packvium/compression.py +++ b/src/packvium/compression.py @@ -16,6 +16,7 @@ from math import gcd from ._compat import dataclass +from ._request_error import refused from .units import Length, Weight #: Parts per million, the scale `compression_ratio` is carried at once parsed. Shared with @@ -154,5 +155,8 @@ def ratio_to_ppm(ratio: float) -> int: 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") + # Below zero is the schema's floor; the schema sets no ceiling, so above one stays the + # catch-all `invalid_value` every engine answers. + raise refused("below_minimum" if ratio < 0 else "invalid_value", + "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 9df287b..c6effe4 100644 --- a/src/packvium/config.py +++ b/src/packvium/config.py @@ -1,10 +1,11 @@ from __future__ import annotations +from ._request_error import refused from ._compat import dataclass from enum import Enum from .effort import EffortBudget -from .units import Length +from .units import LENGTH_UNIT_CODES, WEIGHT_UNIT_CODES, Length class SolverProfile(str, Enum): @@ -69,14 +70,21 @@ def __post_init__(self) -> None: or self.multi_start_orders <= 0 or self.max_candidates_per_item <= 0 or self.parallel_starts <= 0 or self.container_plan_beam_width <= 0 or self.container_plan_node_limit <= 0): - raise ValueError("positive configuration values required") + raise refused("below_minimum", "positive configuration values required") if self.max_containers is not None and self.max_containers < 1: - raise ValueError("max_containers must be at least 1") + raise refused("below_minimum", "max_containers must be at least 1") if self.max_candidate_points < 16: - raise ValueError("max_candidate_points must be at least 16") - if not 0 <= self.minimum_support_ratio <= 1: raise ValueError("minimum_support_ratio must be between 0 and 1") + raise refused("below_minimum", "max_candidate_points must be at least 16") + if not 0 <= self.minimum_support_ratio <= 1: + ratio = self.minimum_support_ratio + reason = "below_minimum" if ratio < 0 else "above_maximum" if ratio > 1 else "invalid_value" + raise refused(reason, "minimum_support_ratio must be between 0 and 1") if self.dimensional_weight_divisor is not None and self.dimensional_weight_divisor <= 0: - raise ValueError("dimensional_weight_divisor must be positive") + raise refused("below_minimum", "dimensional_weight_divisor must be positive") + for name, allowed in (("dimensional_weight_length_unit", LENGTH_UNIT_CODES), + ("dimensional_weight_weight_unit", WEIGHT_UNIT_CODES)): + if getattr(self, name) not in allowed: + raise refused("not_allowed", f"{name} must be one of {list(allowed)}") @classmethod def fast(cls, time_limit_ms: int = 200, **kwargs) -> "PackingConfig": diff --git a/src/packvium/constraints.py b/src/packvium/constraints.py index 7f49a19..d550f80 100644 --- a/src/packvium/constraints.py +++ b/src/packvium/constraints.py @@ -119,6 +119,17 @@ def active_constraints(constraints: Sequence[PlacementConstraint], container: Co return active +def is_position_independent(constraint: PlacementConstraint) -> bool: + """Whether `constraint`'s verdict for an item never reads the candidate's position. + + Compatibility and tag counts look only at the item, the container and what is already + placed, all fixed for one candidate search, so one verdict serves every (point, + rotation) of it -- as Rust and JavaScript already compute them once per item. Exact + types, as in `active_constraints`: a subclass may override `evaluate` and read more. + """ + return type(constraint) in (CompatibilityConstraint, TagCountConstraint) + + class ProvenRejection(Protocol): """A constraint that can rule an item out of *every* offered container without searching for a placement first. @@ -167,8 +178,7 @@ class DirectSupport: @dataclass(frozen=True, slots=True) class DirectSupportView: - """Candidate support surfaces and positive-area direct supporters.""" - surfaces: tuple[AxisAlignedBox, ...] + """Positive-area direct supporters of one candidate, in placement-index order.""" supporters: tuple[DirectSupport, ...] @property @@ -193,13 +203,12 @@ def direct_support_view( ) -> DirectSupportView: """Direct supports for one active candidate in O(m) time and O(m) output space. - Ordinary face semantics stay byte-for-byte equivalent: every box whose top is on - the candidate's base plane remains a ``surface``, while only positive-area overlaps - are supporters. If an exact same-type/same-footprint nesting predecessor exists, - shadowed face surfaces from that nesting column are removed and the immediate - predecessor becomes one full-area supporter. Lists remain in placement-index order. + A box whose top is on the candidate's base plane is a supporter when it overlaps the + base with positive area. If an exact same-type/same-footprint nesting predecessor + exists, shadowed face supporters from that nesting column are removed and the + immediate predecessor becomes one full-area supporter. The list stays in + placement-index order. """ - face_surfaces: list[tuple[int, AxisAlignedBox]] = [] face_supporters: list[DirectSupport] = [] nearest_lower: tuple[int, AxisAlignedBox] | None = None nesting = item.item.nesting_height @@ -207,7 +216,6 @@ def direct_support_view( other = placement.envelope_box other_nesting = placement.instance.item.nesting_height if other.z2 == box.origin.z: - face_surfaces.append((index, other)) area = other.overlap_area_xy(box) if area > 0: face_supporters.append(DirectSupport(index, other, area)) @@ -227,23 +235,13 @@ def direct_support_view( else None ) if predecessor is None: - return DirectSupportView( - tuple(surface for _, surface in face_surfaces), tuple(face_supporters) - ) + return DirectSupportView(tuple(face_supporters)) - surfaces = [ - entry for entry in face_surfaces - if not _same_nesting_column(placements[entry[0]], item, box) - ] supporters = [ support for support in face_supporters if not _same_nesting_column(placements[support.index], item, box) ] predecessor_index, predecessor_box = predecessor - surface_at = 0 - while surface_at < len(surfaces) and surfaces[surface_at][0] < predecessor_index: - surface_at += 1 - surfaces.insert(surface_at, (predecessor_index, predecessor_box)) supporter_at = 0 while supporter_at < len(supporters) and supporters[supporter_at].index < predecessor_index: supporter_at += 1 @@ -251,9 +249,7 @@ def direct_support_view( supporter_at, DirectSupport(predecessor_index, predecessor_box, predecessor_box.overlap_area_xy(box)), ) - return DirectSupportView( - tuple(surface for _, surface in surfaces), tuple(supporters) - ) + return DirectSupportView(tuple(supporters)) class LoadSupportGraph: @@ -508,21 +504,22 @@ def resting_above(units: Sequence[LoadUnit], graph: LoadSupportGraph | None = No An item stacked through two levels of intermediaries appears in the set of every level beneath it, not merely the one it directly touches. + + Walked iteratively in the load order `top_loads` uses: a child's top is above its + supporter's, so every child's set is complete before its supporter reads it. A + recursive walk spent one Python frame per stack level and a tall stack of sheets + raised RecursionError. O(sum of set sizes) after the load order, which the + graph sorts once in O(n log n) and caches. """ graph = graph if graph is not None else LoadSupportGraph(units) - memo: dict[int, frozenset[int]] = {} - - def above_set(index: int) -> frozenset[int]: - cached = memo.get(index) - if cached is not None: return cached + above: list[frozenset[int]] = [frozenset()] * len(units) + for index in graph._descending_indices(): result: set[int] = set() for child in graph.children(index): result.add(child) - result |= above_set(child) - memo[index] = frozenset(result) - return memo[index] - - return [above_set(index) for index in range(len(units))] + result |= above[child] + above[index] = frozenset(result) + return above def stacked_counts(units: Sequence[LoadUnit], graph: LoadSupportGraph | None = None) -> list[int]: @@ -536,7 +533,8 @@ def stacked_counts(units: Sequence[LoadUnit], graph: LoadSupportGraph | None = N RIDES_THE_WHOLE_ROUTE = float("inf") -def route_order_violated(units: Sequence[LoadUnit], stops: Sequence[float]) -> tuple[str, str] | None: +def route_order_violated(units: Sequence[LoadUnit], stops: Sequence[float], + graph: LoadSupportGraph | None = None) -> tuple[str, str] | None: """First item due at an earlier stop with a later-stop item resting above it. docs/VALIDATION-CONTRACT.md requires every item due at a stop to be removable @@ -548,8 +546,10 @@ def route_order_violated(units: Sequence[LoadUnit], stops: Sequence[float]) -> t post-validator answers; this check is the necessary condition, not the whole of it. """ if all(stop == RIDES_THE_WHOLE_ROUTE for stop in stops): return None - for index, above in enumerate(resting_above(units)): - for upper in above: + for index, above in enumerate(resting_above(units, graph)): + # Ascending, not set order: the detail names the first violator, and PHP and + # Python must name the same one (S16). + for upper in sorted(above): if stops[upper] > stops[index]: return ("unloading_order_violation", f"{units[index].label} blocked by {units[upper].label}") return None @@ -652,16 +652,18 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: return ConstraintResult.allow() -def _touches_corners(candidate, surfaces) -> bool: - """Whether every one of the candidate's four base corners rests on some surface. +def _touches_corners(candidate, supporters) -> bool: + """Whether every one of the candidate's four base corners rests on some supporter. A ratio cannot express this: 90% of the footprint concentrated in the middle - passes any ratio below that but leaves every corner hanging. + passes any ratio below that but leaves every corner hanging. Only positive-area + supporters count: a neighbour touching a corner along an edge carries nothing, + and Rust, JS and every validator ignore it. """ corners = ((candidate.origin.x, candidate.origin.y), (candidate.x2, candidate.origin.y), (candidate.origin.x, candidate.y2), (candidate.x2, candidate.y2)) return all( - any(surface.origin.x <= x <= surface.x2 and surface.origin.y <= y <= surface.y2 for surface in surfaces) + any(box.origin.x <= x <= box.x2 and box.origin.y <= y <= box.y2 for box in supporters) for x, y in corners ) @@ -681,11 +683,10 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: return ConstraintResult.allow() candidate = context.envelope_box support = direct_support_view(context.placements, context.item, candidate) - surfaces = support.surfaces supporters = [entry.box for entry in support.supporters] rule = context.item.item.ground_contact_rule if rule is not None and rule != "free": - if rule == "covered" and not _touches_corners(candidate, surfaces): + if rule == "covered" and not _touches_corners(candidate, supporters): return ConstraintResult.reject("ground_contact_violation", f"covered: {len(supporters)} supporter(s), not all four corners touched") if rule == "single" and len(supporters) != 1: return ConstraintResult.reject("ground_contact_violation", f"single: rests on {len(supporters)} item(s)") @@ -720,19 +721,17 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: return ConstraintResult.allow() -class TopLoadConstraint: - """Rejects a placement that would rest on something unable to carry it. - - 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. +class PlacedSupportBase: + """The support graph over one placement tuple, with each candidate appended to it. 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. + removes. Every rule that reads the graph -- bearing and route order -- + keeps one of these. 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") @@ -764,6 +763,28 @@ def _base_for(self, placements: tuple[Placement, ...], footprint: int): self._base = LoadSupportGraph(units, cell_hint=hint) return self._base, units + def with_candidate(self, placements: tuple[Placement, ...], + unit: LoadUnit) -> tuple[tuple[LoadUnit, ...], LoadSupportGraph]: + """`placements`' units and graph with `unit` appended at the last index.""" + box = unit.box + footprint = max(box.x2 - box.origin.x, box.y2 - box.origin.y) + base, base_units = self._base_for(placements, footprint) + return base_units + (unit,), base.with_unit(unit, cell_hint=self._hint) + + +class TopLoadConstraint: + """Rejects a placement that would rest on something unable to carry it. + + 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 graph comes from a `PlacedSupportBase`, so it is not rebuilt per candidate. + """ + + __slots__ = ("_support",) + + def __init__(self) -> None: + self._support = PlacedSupportBase() + def evaluate(self, context: ConstraintContext) -> ConstraintResult: if not context.stack_sensitive: return ConstraintResult.allow() candidate = context.envelope_box @@ -774,10 +795,7 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: 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) + units, graph = self._support.with_candidate(context.placements, unit) failure = non_stackable_failure( context.placements, context.item, graph, len(units) - 1 ) @@ -803,15 +821,19 @@ class RouteOrderConstraint: a route sees no new rejection code and no behaviour change. """ + __slots__ = ("_support",) + + def __init__(self) -> None: + # Its own base rather than a rebuild per candidate: 60 routed placements once + # cost 8713 graph constructions. + self._support = PlacedSupportBase() + def evaluate(self, context: ConstraintContext) -> ConstraintResult: if not context.route_sensitive: return ConstraintResult.allow() - units = load_units( - context.placements, - LoadUnit( - context.envelope_box, context.item.weight.ticks, None, None, context.item.id, - None if context.item.item.nesting_height is None else context.item.item.id, - None if context.item.item.nesting_height is None else context.item.item.nesting_height.ticks, - ), + candidate = LoadUnit( + context.envelope_box, context.item.weight.ticks, None, None, context.item.id, + None if context.item.item.nesting_height is None else context.item.item.id, + None if context.item.item.nesting_height is None else context.item.item.nesting_height.ticks, ) stops = [ RIDES_THE_WHOLE_ROUTE if p.instance.item.stop_index is None else float(p.instance.item.stop_index) @@ -819,7 +841,8 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: ] item_stop = context.item.item.stop_index stops.append(RIDES_THE_WHOLE_ROUTE if item_stop is None else float(item_stop)) - failure = route_order_violated(units, stops) + units, graph = self._support.with_candidate(context.placements, candidate) + failure = route_order_violated(units, stops, graph) if failure is not None: return ConstraintResult.reject(*failure) return ConstraintResult.allow() @@ -893,7 +916,7 @@ def _base_for(self, placements: tuple[Placement, ...], container: Dimensions, directions: tuple[str, ...]): """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 + Cached by tuple identity for the same reason `PlacedSupportBase` is: 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. """ diff --git a/src/packvium/effort.py b/src/packvium/effort.py index 47aa776..8c2b28a 100644 --- a/src/packvium/effort.py +++ b/src/packvium/effort.py @@ -1,6 +1,7 @@ from __future__ import annotations from ._compat import dataclass +from ._request_error import refused, refused_type from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -31,8 +32,12 @@ def __post_init__(self) -> None: for name in ("max_candidates_evaluated", "max_placement_attempts", "max_search_nodes", "max_restarts"): limit = getattr(self, name) + # Only what cannot be compared at all is refused here; an integral float or a + # boolean is the request rules' to judge, with its pointer (`check_objects`). + if limit is not None and not isinstance(limit, (int, float)): + raise refused_type(f"effort_budget.{name} must be an integer") if limit is not None and limit < 1: - raise ValueError(f"effort_budget.{name} must be at least 1") + raise refused("below_minimum", f"effort_budget.{name} must be at least 1") def exceeded(self, stats: "SearchStats") -> bool: return ((self.max_candidates_evaluated is not None and stats.candidates_evaluated >= self.max_candidates_evaluated) diff --git a/src/packvium/fixed_placements.py b/src/packvium/fixed_placements.py index 5420714..375ef2c 100644 --- a/src/packvium/fixed_placements.py +++ b/src/packvium/fixed_placements.py @@ -60,6 +60,11 @@ def require_fixed_placement_shapes(raw: Any, unit: str = "mm") -> List[Mapping[s return raw +def orientation_rule() -> str: + """The orientation rule as the request-error tables word a closed vocabulary.""" + return f"must be one of {json_spelling(list(ORIENTATIONS))}" + + def _malformed(detail: str, field: str) -> FixedPlacementError: return FixedPlacementError(detail, "malformed", field) @@ -77,7 +82,7 @@ def _require_entry_shape(entry: Any, where: str, field: str, unit: str) -> None: if not isinstance(entry[name], str) or not entry[name]: raise _malformed(f"{where}.{name} is a non-empty string", f"{field}/{name}") if entry["orientation"] not in ORIENTATIONS: - raise _malformed(f"{where}.orientation is one of the six codes", f"{field}/orientation") + raise _malformed(f"{where}.orientation {orientation_rule()}", f"{field}/orientation") instance = json_integer(entry.get("container_instance", 1)) if instance is None or instance < 1: raise _malformed(f"{where}.container_instance counts from 1", f"{field}/container_instance") diff --git a/src/packvium/geometry.py b/src/packvium/geometry.py index e4b3904..b728597 100644 --- a/src/packvium/geometry.py +++ b/src/packvium/geometry.py @@ -7,6 +7,7 @@ from typing import Iterable from .units import Length, Rounding, Weight +from ._request_error import refused class Rotation(str, Enum): @@ -64,7 +65,8 @@ def __post_init__(self) -> None: @classmethod def of(cls, length, width, height, unit: str = "mm", rounding: Rounding = Rounding.NEAREST) -> "Dimensions": - return cls(Length.of(length, unit, rounding), Length.of(width, unit, rounding), Length.of(height, unit, rounding)) + return cls._read(Length.of(length, unit, rounding), Length.of(width, unit, rounding), + Length.of(height, unit, rounding)) @classmethod def mm(cls, length, width, height, rounding: Rounding = Rounding.NEAREST) -> "Dimensions": @@ -76,12 +78,20 @@ def inches(cls, length, width, height, rounding: Rounding = Rounding.NEAREST) -> @classmethod def from_dict(cls, data: dict, default_unit: str = "mm", rounding: Rounding = Rounding.NEAREST) -> "Dimensions": - return cls( + return cls._read( Length.parse(data["length"], default_unit, rounding), Length.parse(data["width"], default_unit, rounding), Length.parse(data["height"], default_unit, rounding), ) + @classmethod + def _read(cls, length: Length, width: Length, height: Length) -> "Dimensions": + """Sides read from the caller's input, where a zero side is a request refusal; + `__post_init__` still guards the arithmetic with a plain error.""" + if min(length.ticks, width.ticks, height.ticks) <= 0: + raise refused("invalid_value", "all dimensions must be positive") + return cls(length, width, height) + @property def volume(self) -> int: return self.length.ticks * self.width.ticks * self.height.ticks diff --git a/src/packvium/made_to_measure.py b/src/packvium/made_to_measure.py index 904883f..d660ee5 100644 --- a/src/packvium/made_to_measure.py +++ b/src/packvium/made_to_measure.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING from ._compat import dataclass +from ._request_error import refused from .geometry import Dimensions from .units import Length, Weight @@ -51,13 +52,13 @@ class MadeToMeasure: def __post_init__(self) -> None: if min(self.minimum) <= 0: - raise ValueError("made_to_measure minimum must be positive") + raise refused("invalid_value", "made_to_measure minimum must be positive") if min(self.increment) <= 0: - raise ValueError("made_to_measure increment must be positive") + raise refused("invalid_value", "made_to_measure increment must be positive") if self.cost_per_square_metre_minor < 0 or self.tare_per_square_metre < 0: - raise ValueError("made_to_measure prices cannot be negative") + raise refused("below_minimum", "made_to_measure prices cannot be negative") if min(self.outer_allowance) < 0: - raise ValueError("made_to_measure outer_allowance cannot be negative") + raise refused("negative_measure", "made_to_measure outer_allowance cannot be negative") def admits(self, maximum: Triple) -> None: """Refuse a machine whose grid does not land on its own maximum. O(1). @@ -66,10 +67,10 @@ def admits(self, maximum: Triple) -> None: inside the maximum up onto the grid can never pass it (D3, D4).""" for axis, name in enumerate(("length", "width", "height")): if self.minimum[axis] > maximum[axis]: - raise ValueError(f"made_to_measure minimum {name} exceeds inner_dimensions") + raise refused("invalid_value", f"made_to_measure minimum {name} exceeds inner_dimensions") if (maximum[axis] - self.minimum[axis]) % self.increment[axis]: - raise ValueError( - f"made_to_measure increment {name} does not divide inner_dimensions minus minimum" + raise refused( + "invalid_value", f"made_to_measure increment {name} does not divide inner_dimensions minus minimum" ) def grid(self, axis: int, value: int) -> int: diff --git a/src/packvium/models.py b/src/packvium/models.py index c221ea9..a6c8628 100644 --- a/src/packvium/models.py +++ b/src/packvium/models.py @@ -14,11 +14,21 @@ from .made_to_measure import MadeToMeasure, as_triple from .nesting import used_volume as nesting_used_volume from .units import Length, Weight +from ._request_error import refused GROUND_CONTACT_RULES = frozenset({"free", "covered", "single", "multiple"}) + +def _ratio_between_zero_and_one(value: float, name: str) -> None: + """The ratio rule of the request schema: below zero and above one are told apart, and + anything else outside [0, 1] (NaN) is still refused.""" + if 0 <= value <= 1: + return + reason = "below_minimum" if value < 0 else "above_maximum" if value > 1 else "invalid_value" + raise refused(reason, f"{name} must be between 0 and 1") + @dataclass(frozen=True, slots=True) class Obstacle: """An excluded zone. `additional_boxes` expresses a non-rectangular shape (a @@ -115,22 +125,23 @@ class Item: accepts_stacking_groups: frozenset[str] = field(default_factory=frozenset) def __post_init__(self) -> None: - if not self.id: raise ValueError("item id is required") - if self.quantity <= 0: raise ValueError("item quantity must be positive") - if not 0 <= self.minimum_support_ratio <= 1: raise ValueError("minimum_support_ratio must be between 0 and 1") + if not self.id: raise refused("invalid_value", "item id is required") + if self.quantity <= 0: raise refused("below_minimum", "item quantity must be positive") + _ratio_between_zero_and_one(self.minimum_support_ratio, "minimum_support_ratio") if self.max_stacked_items is not None and self.max_stacked_items < 1: - raise ValueError("max_stacked_items must be at least 1") + raise refused("below_minimum", "max_stacked_items must be at least 1") 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") + reason = "below_minimum" if self.stop_index < 0 else "above_maximum" + raise refused(reason, "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") + raise refused("below_minimum", "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") + raise refused("invalid_value", "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)}") + raise refused("not_allowed", 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") + if not rotations: raise refused("invalid_value", "at least one rotation must be allowed") object.__setattr__(self, "allowed_rotations", rotations) object.__setattr__(self, "tags", frozenset(self.tags)) object.__setattr__(self, "incompatible_tags", frozenset(self.incompatible_tags)) @@ -150,14 +161,17 @@ def _validate_shape(self) -> None: have to be consistent with. """ object.__setattr__(self, "shape_type", ShapeType(self.shape_type)) + # The value's own floor first, as the JSON rules check it before the shape. + if self.max_compression_pressure_kpa is not None and self.max_compression_pressure_kpa < 0: + raise refused("below_minimum", "max_compression_pressure_kpa cannot be negative") 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") + raise refused("invalid_value", 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" + raise refused( + "invalid_value", 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() @@ -178,7 +192,7 @@ def _fields_foreign_to_shape(self) -> tuple[tuple[str, Any], ...]: def _validate_hull(self) -> None: if self.hull_vertices is None: - raise ValueError("a convex_hull item requires hull_vertices") + raise refused("invalid_value", "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)) @@ -188,20 +202,18 @@ def _validate_hull(self) -> None: # `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}" + raise refused( + "invalid_value", 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 " + raise refused( + "invalid_value", "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") + raise refused("invalid_value", "compression_ratio must be between zero and one") @classmethod def create(cls, id: str, dimensions: Dimensions, weight=0, **kwargs) -> "Item": @@ -246,22 +258,22 @@ class RateTable: def __post_init__(self) -> None: if not self.weight_brackets_g: - raise ValueError("rate_table requires at least one weight bracket") + raise refused("invalid_value", "rate_table requires at least one weight bracket") if len(self.weight_brackets_g) != len(self.prices_minor): - raise ValueError("rate_table weight_brackets_g and prices_minor must be the same length") + raise refused("invalid_value", "rate_table weight_brackets_g and prices_minor must be the same length") if any(bound <= 0 for bound in self.weight_brackets_g): - raise ValueError("rate_table weight brackets must be positive") + raise refused("below_minimum", "rate_table weight brackets must be positive") if any( later <= earlier for earlier, later in zip(self.weight_brackets_g, self.weight_brackets_g[1:]) ): - raise ValueError("rate_table weight brackets must be strictly ascending") + raise refused("invalid_value", "rate_table weight brackets must be strictly ascending") if any(price < 0 for price in self.prices_minor): - raise ValueError("rate_table prices cannot be negative") + raise refused("below_minimum", "rate_table prices cannot be negative") if self.minimum_charge_minor < 0: - raise ValueError("rate_table minimum_charge_minor cannot be negative") + raise refused("below_minimum", "rate_table minimum_charge_minor cannot be negative") if self.fuel_surcharge_permille < 0: - raise ValueError("rate_table fuel_surcharge_permille cannot be negative") + raise refused("below_minimum", "rate_table fuel_surcharge_permille cannot be negative") def charge_minor_or_none(self, billed_weight_g: int) -> int | None: """The exact landed cost of one shipment at this billed weight, or `None` when the @@ -344,20 +356,20 @@ class Container: made_to_measure: MadeToMeasure | None = None def __post_init__(self) -> None: - if not self.id: raise ValueError("container id is required") + if not self.id: raise refused("invalid_value", "container id is required") if self.deformable_boundary is not None: self._require_bag_fields() if self.made_to_measure is not None: self.made_to_measure.admits(as_triple(self.inner_dimensions)) - if self.quantity is not None and self.quantity <= 0: raise ValueError("container quantity must be positive") - if self.cost_minor < 0: raise ValueError("container cost cannot be negative") - if not 0 <= self.void_fill_reserve_ratio <= 1: raise ValueError("void_fill_reserve_ratio must be between 0 and 1") + if self.quantity is not None and self.quantity <= 0: raise refused("below_minimum", "container quantity must be positive") + if self.cost_minor < 0: raise refused("below_minimum", "container cost cannot be negative") + _ratio_between_zero_and_one(self.void_fill_reserve_ratio, "void_fill_reserve_ratio") if self.axles is not None: front, rear = self.axles if front.position.ticks >= rear.position.ticks: - raise ValueError("the front axle must be strictly nearer the origin than the rear axle") + raise refused("invalid_value", "the front axle must be strictly nearer the origin than the rear axle") if front.position.ticks < 0 or rear.position.ticks > self.inner_dimensions.length.ticks: - raise ValueError("axle positions must lie within the container's length") - if any(limit < 1 for limit in self.tag_limits.values()): raise ValueError("tag_limits must be at least 1") - if self.max_items is not None and self.max_items < 1: raise ValueError("container max_items must be at least 1") + raise refused("invalid_value", "axle positions must lie within the container's length") + if any(limit < 1 for limit in self.tag_limits.values()): raise refused("below_minimum", "tag_limits must be at least 1") + if self.max_items is not None and self.max_items < 1: raise refused("below_minimum", "container max_items must be at least 1") # Deduplicated into the canonical order rather than kept as given: two callers # naming the same doors in a different order must search identically, and this is # the one place every construction path passes through. @@ -366,11 +378,11 @@ def __post_init__(self) -> None: raise InvalidDirectionError(direction) object.__setattr__(self, "access_directions", tuple(d for d in ALL_DIRECTIONS if d in set(self.access_directions))) - if self.outer_dimensions and not self.inner_dimensions.fits_inside(self.outer_dimensions): raise ValueError("outer dimensions cannot be smaller than inner dimensions") + if self.outer_dimensions and not self.inner_dimensions.fits_inside(self.outer_dimensions): raise refused("invalid_value", "outer dimensions cannot be smaller than inner dimensions") boundary = AxisAlignedBox(Point(0, 0, 0), self.inner_dimensions) for obstacle in self.obstacles: if not all(boundary.contains(box) for box in obstacle.boxes): - raise ValueError(f"obstacle {obstacle.id} lies outside container") + raise refused("invalid_value", f"obstacle {obstacle.id} lies outside container") object.__setattr__(self, "tags", frozenset(self.tags)) if self.max_stack_density is not None: object.__setattr__(self, "max_stack_density", Weight.parse(self.max_stack_density)) @@ -380,15 +392,15 @@ def _require_bag_fields(self) -> None: so those fields are refused rather than read against a frame that moves with its contents. The JSON path names the same fields as request errors first.""" if self.deformable_boundary not in DEFORMABLE_BOUNDARY_KINDS: - raise ValueError(f"unknown deformable_boundary kind {self.deformable_boundary!r}") - refused = [name for name, present in ( + raise refused("not_allowed", f"unknown deformable_boundary kind {self.deformable_boundary!r}") + fields = [name for name, present in ( ("obstacles", bool(self.obstacles)), ("axles", self.axles is not None), ("access_directions", bool(self.access_directions)), ("max_stack_density", self.max_stack_density is not None), ("outer_dimensions", self.outer_dimensions is not None), ("void_fill_reserve_ratio", self.void_fill_reserve_ratio > 0)) if present] - if refused: - raise ValueError(f"{refused[0]} cannot be combined with deformable_boundary") + if fields: + raise refused("not_allowed", f"{fields[0]} cannot be combined with deformable_boundary") @property def is_flat_bag(self) -> bool: @@ -660,10 +672,8 @@ class FixedPlacement: position: Point rotation: Rotation container_instance: int = 1 - - def __post_init__(self) -> None: - if self.container_instance < 1: - raise ValueError("container_instance counts from 1") + # Every rule on these fields is checked where the placement sits in a request, by + # `Packer.pack` (`request_objects.check_objects`), with the JSON path's refusal and pointer. @property def packed_container_id(self) -> str: @@ -677,10 +687,10 @@ class PackingRequest: fixed_placements: tuple[FixedPlacement, ...] = () def __post_init__(self) -> None: - if not self.items: raise ValueError("at least one item is required") - if not self.containers: raise ValueError("at least one container is required") - if len({item.id for item in self.items}) != len(self.items): raise ValueError("item ids must be unique") - if len({container.id for container in self.containers}) != len(self.containers): raise ValueError("container ids must be unique") + if not self.items: raise refused("invalid_value", "at least one item is required") + if not self.containers: raise refused("invalid_value", "at least one container is required") + if len({item.id for item in self.items}) != len(self.items): raise refused("duplicate_id", "item ids must be unique") + if len({container.id for container in self.containers}) != len(self.containers): raise refused("duplicate_id", "container ids must be unique") @property def instances(self) -> tuple[ItemInstance, ...]: diff --git a/src/packvium/packer.py b/src/packvium/packer.py index beadf20..2148789 100644 --- a/src/packvium/packer.py +++ b/src/packvium/packer.py @@ -8,6 +8,7 @@ resolve_objective_scorer, unpriceable_container) from .fixed_placements import admit_fixed_placements from .models import Container, Item, PackingRequest, UnratedWeightError +from .request_objects import check_objects from .result import AlgorithmReport, PackingResult, PackingStatus from .result import aggregate_termination, unsupported_placements_warning from .solvers import Deadline, SolverOrchestrator @@ -34,7 +35,11 @@ def __init__( self.trace = trace def pack(self, items, containers, fixed_placements=()) -> PackingResult: - request = PackingRequest(tuple(items), tuple(containers), tuple(fixed_placements)) + items, containers, fixed_placements = tuple(items), tuple(containers), tuple(fixed_placements) + # The JSON path's rules, with the JSON path's reason and field: a request + # built from objects is refused exactly where the same request as JSON would be. + check_objects(items, containers, self.config, fixed_placements) + request = PackingRequest(items, containers, fixed_placements) # Both weight objectives price the same billed weight, so both need the divisor # up front -- a wrong guess would silently misprice every shipment. And rating # some containers while others carry no tariff would rank a priced packing diff --git a/src/packvium/packing_sequence.py b/src/packvium/packing_sequence.py index 6a8194b..abe2b09 100644 --- a/src/packvium/packing_sequence.py +++ b/src/packvium/packing_sequence.py @@ -145,23 +145,25 @@ def _validate_box_at_step(index: int, step: int, boxes: Sequence[AxisAlignedBox] def _acyclic(depends_on: tuple[frozenset[int], ...]) -> bool: - visiting: set[int] = set() - visited: set[int] = set() - - def visit(node: int) -> bool: - if node in visited: - return True - if node in visiting: - return False - visiting.add(node) - for dependency in depends_on[node]: - if not visit(dependency): - return False - visiting.discard(node) - visited.add(node) - return True + """Kahn's algorithm: acyclic exactly when every node can be released. O(V + E). - return all(visit(node) for node in range(len(depends_on))) + Iterative, not a depth-first walk: a stack of sheets is a dependency chain as long + as the stack, and recursion spent one frame per level. + """ + waiting = [0] * len(depends_on) + for dependencies in depends_on: + for dependency in dependencies: + waiting[dependency] += 1 + ready = [node for node, count in enumerate(waiting) if count == 0] + released = 0 + while ready: + node = ready.pop() + released += 1 + for dependency in depends_on[node]: + waiting[dependency] -= 1 + if waiting[dependency] == 0: + ready.append(dependency) + return released == len(depends_on) @dataclass(frozen=True, slots=True) @@ -528,10 +530,9 @@ def verify_loading_prefix_business_rules( if rule is not None and rule != "free" and candidate.envelope_box.origin.z != 0: candidate_box = candidate.envelope_box support = direct_support_view(present[:-1], candidate.instance, candidate_box) - surfaces = support.surfaces supporters = [entry.box for entry in support.supporters] violated = ( - (rule == "covered" and not _touches_corners(candidate_box, surfaces)) + (rule == "covered" and not _touches_corners(candidate_box, supporters)) or (rule == "single" and len(supporters) != 1) or (rule == "multiple" and len(supporters) < 2) ) diff --git a/src/packvium/request_errors.py b/src/packvium/request_errors.py index 2275dd1..1c13623 100644 --- a/src/packvium/request_errors.py +++ b/src/packvium/request_errors.py @@ -12,8 +12,8 @@ - `detail`: the fixed text for the reason, so the message is identical in every engine. `check_request` walks the schema's rules over the raw JSON before any model is built, and the -first violation wins, in a fixed order every engine follows: units, configuration, items, -containers. The floors are the ones the request schema declares, and a cross-engine suite +first violation wins, in a fixed order every engine follows: units, output, configuration, +items, containers. The floors are the ones the request schema declares, and a cross-engine suite holds all four engines to the same reason, field and message. """ @@ -22,8 +22,9 @@ from typing import Any, Callable, Iterable, Mapping, Optional, Sequence, Set, Tuple, Type from ._canonical_json import MAX_EXACT_MAGNITUDE, json_integer, json_spelling +from ._request_error import InvalidRequestError from .flat_bag import DEFORMABLE_BOUNDARY_KINDS -from .units import Length, Weight +from .units import LENGTH_UNIT_CODES, WEIGHT_UNIT_CODES, Length, Weight from .made_to_measure import box_cost_minor_for REASONS = ("missing_field", "wrong_type", "below_minimum", "above_maximum", "negative_measure", @@ -33,21 +34,13 @@ OBJECTIVES = ("default", "lowest_cost", "shipping_cost", "lowest_landed_cost", "open_dimension_height", "maximum_value", "maximum_volume") ACCESS_DIRECTIONS = ("+x", "-x", "+y", "-y", "+z", "-z") - - -class InvalidRequestError(ValueError): - """The request is not one any engine may answer. Nothing was solved.""" - - code = "invalid_request" - - def __init__(self, reason: str, field: str, detail: str) -> None: - self.reason = reason - self.field = field - self.detail = detail - super().__init__(self._message()) - - def _message(self) -> str: - return f"{self.code}: {self.field}: {self.detail}" if self.field else f"{self.code}: {self.detail}" +#: The schema's closed vocabularies, each refused as `not_allowed` like the two above. +SOLVERS = ("grid", "extreme_points", "homogeneous_blocks", "layer", "maximal_spaces", "exact_small") +ROTATIONS = ("LWH", "LHW", "WLH", "WHL", "HLW", "HWL") +LENGTH_UNITS = LENGTH_UNIT_CODES +WEIGHT_UNITS = WEIGHT_UNIT_CODES +GROUND_CONTACT_RULES = ("free", "covered", "single", "multiple") +SHAPE_TYPES = ("rigid_cuboid", "convex_hull", "compressible") def pointer(*parts: Any) -> str: @@ -155,6 +148,7 @@ def check_request(data: Any) -> None: """Refuse the request with its first violation, or return. O(size of the request).""" request = _object(data, "") unit = _check_units(request.get("units")) + _check_output(request.get("output")) _check_configuration(request.get("configuration"), unit) items = _required_list(request, "items", "") for index, raw in enumerate(items): @@ -179,6 +173,14 @@ def _check_units(raw: Any) -> str: return length +def _check_output(raw: Any) -> None: + if raw is None: + return + output = _object(raw, "/output") + _optional(output, "length_unit", "/output", lambda v, f: _one_of(v, f, LENGTH_UNITS)) + _optional(output, "weight_unit", "/output", lambda v, f: _one_of(v, f, WEIGHT_UNITS)) + + def _check_configuration(raw: Any, unit: str) -> None: if raw is None: return @@ -188,6 +190,11 @@ def _check_configuration(raw: Any, unit: str) -> None: lambda v, f: _one_of(v, f, SOLVER_PROFILES)) _optional(configuration, "objective", "/configuration", lambda v, f: _one_of(v, f, OBJECTIVES)) + _optional(configuration, "solvers", "/configuration", lambda v, f: _each_one_of(v, f, SOLVERS)) + _optional(configuration, "dimensional_weight_length_unit", "/configuration", + lambda v, f: _one_of(v, f, LENGTH_UNITS)) + _optional(configuration, "dimensional_weight_weight_unit", "/configuration", + lambda v, f: _one_of(v, f, WEIGHT_UNITS)) for name, minimum in _CONFIGURATION_INTEGERS: _optional(configuration, name, "/configuration", lambda v, f, m=minimum: _integer(v, f, m)) _optional(configuration, "minimum_support_ratio", "/configuration", _ratio) @@ -208,6 +215,7 @@ def _check_item(raw: Any, where: str, unit: str) -> None: _optional(item, "weight", where, lambda v, f: _measure(v, f, Weight, "g")) _optional(item, "max_top_load", where, lambda v, f: _measure(v, f, Weight, "g")) _optional(item, "nesting_height", where, lambda v, f: _measure(v, f, Length, unit)) + _optional(item, "nesting_height", where, lambda v, f: _nesting_below_height(v, f, item, unit)) _optional(item, "max_stacked_items", where, lambda v, f: _integer(v, f, 1)) _optional(item, "stop_index", where, lambda v, f: _integer(v, f, 0)) _optional(item, "value", where, lambda v, f: _integer(v, f, 0)) @@ -216,6 +224,16 @@ def _check_item(raw: Any, where: str, unit: str) -> None: _optional(item, "compression_ratio", where, lambda v, f: _ratio(v, f, None)) _optional(item, "stacking_group", where, _stacking_group_name) _optional(item, "accepts_stacking_groups", where, _stacking_group_names) + _optional(item, "allowed_rotations", where, lambda v, f: _each_one_of(v, f, ROTATIONS)) + _optional(item, "ground_contact_rule", where, lambda v, f: _one_of(v, f, GROUND_CONTACT_RULES)) + _optional(item, "shape_type", where, lambda v, f: _one_of(v, f, SHAPE_TYPES)) + + +def _nesting_below_height(value: Any, field: str, item: Mapping[str, Any], unit: str) -> None: + """A nested copy sinks by less than its own height, or it would vanish into the one below +. Both measures have passed their own rules already. O(1).""" + if Length.parse(value, unit).ticks >= Length.parse(item["dimensions"]["height"], unit).ticks: + raise InvalidRequestError("invalid_value", field, "must be less than the item's height") def _check_container(raw: Any, where: str, unit: str) -> None: @@ -309,10 +327,13 @@ def _set_beside_a_bag(name: str, value: Any) -> bool: return value is not None +def _each_one_of(raw: Any, where: str, allowed: Sequence[str]) -> None: + for index, value in enumerate(_list(raw, where)): + _one_of(value, pointer_join(where, index), allowed) + + def _access_directions(raw: Any, where: str) -> None: - values = _list(raw, where) - for index, value in enumerate(values): - _one_of(value, pointer_join(where, index), ACCESS_DIRECTIONS) + _each_one_of(raw, where, ACCESS_DIRECTIONS) def _dimensions(raw: Any, where: str, unit: str) -> None: diff --git a/src/packvium/request_objects.py b/src/packvium/request_objects.py new file mode 100644 index 0000000..6e821f0 --- /dev/null +++ b/src/packvium/request_objects.py @@ -0,0 +1,188 @@ +"""The request schema's rules, applied to a request built from objects. + +`check_request` walks a JSON request; `Packer.pack` takes objects, whose own constructors already +refuse most of the same values but cannot know where in a request they will sit. This walks the +objects in the JSON rules' order -- configuration, items, item ids, containers, container ids, +fixed placements -- with the same primitives, so a value the JSON path refuses is refused here with the same reason +and the same RFC 6901 field. Only rules `check_request` also applies are checked, so on the JSON +path, which runs `check_request` first, this never refuses anything. + +Unordered sets (accepted stacking groups, tag limits) are walked in code-point order: a set has +no order of its own, and hash order must not decide which value an error names. O(size of the +request). +""" + +from __future__ import annotations + +from typing import Any, Iterable, Optional, Sequence + +from ._canonical_json import MAX_EXACT_MAGNITUDE, json_integer, json_spelling +from .config import PackingConfig +from .fixed_placements import FixedPlacementError, orientation_rule +from .flat_bag import DEFORMABLE_BOUNDARY_KINDS +from .geometry import Point, Rotation +from .made_to_measure import MadeToMeasure, as_triple, box_cost_minor_for +from .models import Container, FixedPlacement, Item, RateTable +from .request_errors import (BAG_EXCLUSIONS, InvalidRequestError, _integer, _one_of, + _ratio, _set_beside_a_bag, _stacking_group_name, pointer, + pointer_join) + +#: (JSON name, PackingConfig attribute, floor), in `check_request`'s order. +_CONFIGURATION_INTEGERS = (("time_limit_ms", "time_limit_ms", 1), ("alternatives", "top_k", 1), + ("max_containers", "max_containers", 1), + ("exact_item_limit", "exact_item_limit", 1), + ("multi_start_orders", "multi_start_orders", 1), + ("max_candidates_per_item", "max_candidates_per_item", 1), + ("max_candidate_points", "max_candidate_points", 16), + ("container_plan_beam_width", "container_plan_beam_width", 1), + ("container_plan_node_limit", "container_plan_node_limit", 1), + ("dimensional_weight_divisor", "dimensional_weight_divisor", 1)) +_EFFORT_LIMITS = ("max_candidates_evaluated", "max_placement_attempts", "max_search_nodes", + "max_restarts") + + +def check_objects(items: Sequence[Item], containers: Sequence[Container], config: PackingConfig, + fixed_placements: Sequence[FixedPlacement] = ()) -> None: + """Refuse the request with its first violation, or return.""" + _check_configuration(config) + for index, item in enumerate(items): + _check_item(item, pointer("items", index)) + _require_unique_ids(items, "items") + for index, container in enumerate(containers): + _check_container(container, pointer("containers", index)) + _require_unique_ids(containers, "containers") + for index, placement in enumerate(fixed_placements): + _check_fixed_placement(placement, f"fixed_placements[{index}]", pointer("fixed_placements", index)) + + +def _check_configuration(config: PackingConfig) -> None: + # `objective` is not checked here: an unknown one keeps its own, older error on this path, + # `UnknownObjectiveError`, raised when the objective is resolved. + where = "/configuration" + for name, attribute, minimum in _CONFIGURATION_INTEGERS: + _optional(getattr(config, attribute), pointer_join(where, name), minimum) + _ratio(config.minimum_support_ratio, pointer_join(where, "minimum_support_ratio")) + if config.effort_budget is not None: + budget = pointer_join(where, "effort_budget") + for name in _EFFORT_LIMITS: + _optional(getattr(config.effort_budget, name), pointer_join(budget, name), 1) + + +def _check_item(item: Item, where: str) -> None: + _string_id(item.id, where) + _integer(item.quantity, pointer_join(where, "quantity"), 1) + _optional(item.max_stacked_items, pointer_join(where, "max_stacked_items"), 1) + _optional(item.stop_index, pointer_join(where, "stop_index"), 0) + _optional(item.value, pointer_join(where, "value"), 0) + _optional(item.max_compression_pressure_kpa, pointer_join(where, "max_compression_pressure_kpa"), 0) + _ratio(item.minimum_support_ratio, pointer_join(where, "minimum_support_ratio")) + if item.stacking_group is not None: + _stacking_group_name(item.stacking_group, pointer_join(where, "stacking_group")) + accepted = pointer_join(where, "accepts_stacking_groups") + for index, name in enumerate(_code_point_order(item.accepts_stacking_groups)): + _stacking_group_name(name, pointer_join(accepted, index)) + + +def _check_container(container: Container, where: str) -> None: + _string_id(container.id, where) + _optional(container.quantity, pointer_join(where, "quantity"), 1) + _optional(container.max_items, pointer_join(where, "max_items"), 1) + _integer(container.cost_minor, pointer_join(where, "cost_minor"), 0) + _ratio(container.void_fill_reserve_ratio, pointer_join(where, "void_fill_reserve_ratio")) + limits = pointer_join(where, "tag_limits") + for tag in _code_point_order(container.tag_limits): + _integer(container.tag_limits[tag], pointer_join(limits, tag), 1) + if container.rate_table is not None: + _check_rate_table(container.rate_table, pointer_join(where, "rate_table")) + if container.deformable_boundary is not None: + _check_flat_bag(container, where) + if container.made_to_measure is not None: + _check_made_to_measure(container, container.made_to_measure, where) + + +def _check_rate_table(table: RateTable, where: str) -> None: + for name, minimum in (("weight_brackets_g", 1), ("prices_minor", 0)): + for index, value in enumerate(getattr(table, name)): + _integer(value, pointer_join(where, name, index), minimum) + _integer(table.minimum_charge_minor, pointer_join(where, "minimum_charge_minor"), 0) + _integer(table.fuel_surcharge_permille, pointer_join(where, "fuel_surcharge_permille"), 0) + + +def _check_flat_bag(container: Container, where: str) -> None: + _one_of(container.deformable_boundary, pointer_join(where, "deformable_boundary", "kind"), + DEFORMABLE_BOUNDARY_KINDS) + for name in BAG_EXCLUSIONS: + if _set_beside_a_bag(name, _as_json_value(getattr(container, name))): + raise InvalidRequestError("not_allowed", pointer_join(where, name), + "cannot be combined with deformable_boundary") + + +def _check_made_to_measure(container: Container, machine: MadeToMeasure, where: str) -> None: + field = pointer_join(where, "made_to_measure") + price = pointer_join(field, "cost_per_square_metre_minor") + _integer(machine.cost_per_square_metre_minor, price, 0) + if container.outer_dimensions is not None: + raise InvalidRequestError("not_allowed", pointer_join(where, "outer_dimensions"), + "cannot be combined with made_to_measure") + largest = box_cost_minor_for(machine.cost_per_square_metre_minor, container.cost_minor, + as_triple(container.inner_dimensions)) + if largest > MAX_EXACT_MAGNITUDE: + raise InvalidRequestError("invalid_value", price, f"prices the largest box above {MAX_EXACT_MAGNITUDE}") + + +def _check_fixed_placement(placement: FixedPlacement, where: str, field: str) -> None: + """`fixed_placements.require_fixed_placement_shapes`' rules that an object can break, in its + order and with its refusal; whether the set can hold is `admit_fixed_placements`' to say.""" + for name, value in (("item_type", placement.item_id), ("container_type", placement.container_id)): + if not isinstance(value, str) or not value: + raise _malformed_placement(f"{where}.{name} is a non-empty string", pointer_join(field, name)) + if not isinstance(placement.rotation, Rotation): + raise _malformed_placement(f"{where}.orientation {orientation_rule()}", pointer_join(field, "orientation")) + instance = json_integer(placement.container_instance) + if instance is None or instance < 1: + raise _malformed_placement(f"{where}.container_instance counts from 1", + pointer_join(field, "container_instance")) + position = pointer_join(field, "position") + if not isinstance(placement.position, Point): + raise _malformed_placement(f"{where}.position is a point object", position) + for axis in ("x", "y", "z"): + # A point holds ticks, so its coordinate is a measure only as an integer; `Point` + # itself already refuses a negative one. + value = getattr(placement.position, axis) + if isinstance(value, bool) or not isinstance(value, int): + raise _malformed_placement(f"{where}.position.{axis} is a measure", pointer_join(position, axis)) + + +def _malformed_placement(detail: str, field: str) -> FixedPlacementError: + return FixedPlacementError(detail, "malformed", field) + + +def _string_id(identifier: Any, where: str) -> None: + if not isinstance(identifier, str): + raise InvalidRequestError("wrong_type", pointer_join(where, "id"), "must be a string") + + +def _require_unique_ids(entries: Sequence[Item] | Sequence[Container], key: str) -> None: + """The later of two equal ids is named, as on the JSON path.""" + seen: set[str] = set() + for index, entry in enumerate(entries): + if entry.id in seen: + raise InvalidRequestError("duplicate_id", pointer(key, index, "id"), + f"repeats the id {json_spelling(entry.id)}") + seen.add(entry.id) + + +def _optional(value: Optional[int], field: str, minimum: int) -> None: + if value is not None: + _integer(value, field, minimum) + + +def _code_point_order(values: Iterable[Any]) -> list[Any]: + """Strings in code-point order; anything mistyped after them, so it is still checked and + sorting a mixed set cannot fail.""" + return sorted(values, key=lambda value: (not isinstance(value, str), value if isinstance(value, str) else repr(value))) + + +def _as_json_value(value: Any) -> Any: + """An empty tuple is how an object leaves a list field out; JSON leaves it absent.""" + return None if value == () else value diff --git a/src/packvium/result_validation.py b/src/packvium/result_validation.py new file mode 100644 index 0000000..c6917e7 --- /dev/null +++ b/src/packvium/result_validation.py @@ -0,0 +1,2376 @@ +"""`packvium.validate`: language-neutral validation of a packing result. + +Takes a request and a result **as JSON** -- the documented wire contract -- and decides +whether the result is physically and structurally sound. It shares no code with any +implementation: units, geometry, support and load are all recomputed here from first +principles. That is the point. A validator built on the library's own constraint classes +cannot catch a bug inside those classes, and one built on the library's unit parser +cannot catch a bug in unit parsing. So this module imports nothing else from `packvium` but +the Python-version dataclass shim, and `tests/test_result_validation.py` holds it to that. + +It is therefore usable against Python, PHP, Rust and the JavaScript fallback alike, and +against any future implementation that speaks the same JSON; the repository's conformance +suite runs every engine's output through this same module. + + from packvium import validate + issues = validate(request, result) + +An empty list means the result is valid. See docs/VALIDATION-CONTRACT.md. +""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import replace +from fractions import Fraction +from itertools import combinations +from math import gcd +from typing import Any, Sequence + +# The package's dataclass shim is a Python-version adapter, not engine code: the one import +# from `packvium` this module allows itself. +from ._compat import dataclass + +# ---------------------------------------------------------------------------- units + +#: Length ticks per unit. One tick is 1/16000 mm, so an international inch +#: (25.4 mm) is exactly 406 400 ticks and common fractional inches stay integral. +LENGTH_TICKS = { + "mm": 16_000, "cm": 160_000, "m": 16_000_000, + "in": 406_400, "inch": 406_400, "inches": 406_400, + "ft": 4_876_800, "tick": 1, "ticks": 1, +} + +#: Weight ticks per unit. One tick is 1/8 microgram. +WEIGHT_TICKS = { + "mg": 8_000, "g": 8_000_000, "kg": 8_000_000_000, + "oz": 226_796_185, "lb": 3_628_738_960, "lbs": 3_628_738_960, + "tick": 1, "ticks": 1, +} + +#: Axis permutation each orientation code applies to (length, width, height). +ROTATIONS = { + "LWH": (0, 1, 2), "LHW": (0, 2, 1), "WLH": (1, 0, 2), + "WHL": (1, 2, 0), "HLW": (2, 0, 1), "HWL": (2, 1, 0), +} + +#: Orientations that keep the item's own height vertical. +UPRIGHT_ROTATIONS = ("LWH", "WLH") + +#: One square metre in length ticks squared -- the reference area a +#: `Container.max_stack_density` limit is expressed per. +SQUARE_METRE_TICKS = (LENGTH_TICKS["mm"] * 1000) ** 2 + +#: The six axis-aligned exit directions a placement could be slid out through. +_EXIT_DIRECTIONS = ("+x", "-x", "+y", "-y", "+z", "-z") + + +class MalformedResult(Exception): + """The result does not have the shape the contract requires.""" + + +def parse_rational(text: Any) -> Fraction: + """Parse an integer, decimal, fraction (``3/16``) or mixed fraction (``12 3/8``).""" + if isinstance(text, int): + return Fraction(text) + value = str(text).strip() + if " " in value: + whole, fraction = value.split(None, 1) + sign = -1 if whole.startswith("-") else 1 + return Fraction(whole) + sign * Fraction(fraction) + return Fraction(value) + + +def to_ticks(measure: Any, default_unit: str, table: dict[str, int]) -> int: + """Convert a schema ``measure`` to exact ticks, rounding halves to even.""" + unit = default_unit + raw = measure + if isinstance(measure, dict): + raw = measure["value"] + unit = measure.get("unit", default_unit) + elif isinstance(measure, str): + stripped = measure.strip() + for suffix in sorted(table, key=len, reverse=True): + if stripped.lower().endswith(suffix) and stripped[: -len(suffix)].strip(): + raw = stripped[: -len(suffix)].strip() + unit = suffix + break + scaled = parse_rational(raw) * table[unit.lower()] + floor = scaled.numerator // scaled.denominator + remainder = scaled - floor + if remainder > Fraction(1, 2): + return floor + 1 + if remainder < Fraction(1, 2): + return floor + return floor if floor % 2 == 0 else floor + 1 + + +def dimensions_to_ticks(value: dict, unit: str) -> tuple[int, int, int]: + return ( + to_ticks(value["length"], unit, LENGTH_TICKS), + to_ticks(value["width"], unit, LENGTH_TICKS), + to_ticks(value["height"], unit, LENGTH_TICKS), + ) + + +# ------------------------------------------------------------------------- geometry + + +@dataclass(frozen=True) +class Box: + """An axis-aligned box in ticks.""" + + x: int + y: int + z: int + length: int + width: int + height: int + + @property + def x2(self) -> int: + return self.x + self.length + + @property + def y2(self) -> int: + return self.y + self.width + + @property + def z2(self) -> int: + return self.z + self.height + + @property + def base_area(self) -> int: + return self.length * self.width + + def intersects(self, other: "Box") -> bool: + return ( + self.x < other.x2 and other.x < self.x2 + and self.y < other.y2 and other.y < self.y2 + and self.z < other.z2 and other.z < self.z2 + ) + + def contains(self, other: "Box") -> bool: + return ( + self.x <= other.x and self.y <= other.y and self.z <= other.z + and other.x2 <= self.x2 and other.y2 <= self.y2 and other.z2 <= self.z2 + ) + + def overlap_area_xy(self, other: "Box") -> int: + dx = max(0, min(self.x2, other.x2) - max(self.x, other.x)) + dy = max(0, min(self.y2, other.y2) - max(self.y, other.y)) + return dx * dy + + +@dataclass(frozen=True) +class Issue: + code: str + detail: str + + def __str__(self) -> str: + return f"{self.code}: {self.detail}" + + +# -------------------------------------------------------------------------- request + + +@dataclass(frozen=True) +class ItemSpec: + id: str + dimensions: tuple[int, int, int] + weight: int + quantity: int + allowed_rotations: tuple[str, ...] + stackable: bool + must_be_on_floor: bool + max_top_load: int | None + minimum_support_ratio: Fraction + tags: frozenset[str] + eligible_container_tags: frozenset[str] + max_stacked_items: int | None + ground_contact_rule: str | None + nesting_height: int | None + incompatible_tags: frozenset[str] + stop_index: int | None + value: int | None + shape_type: str + hull_vertices: tuple[tuple[int, int, int], ...] | None + compression_ratio_ppm: int | None + max_compression_pressure_kpa: int | None + stacking_group: str | None = None + accepts_stacking_groups: frozenset[str] = frozenset() + + +@dataclass(frozen=True) +class MachineSpec: + """A made-to-measure container's machine, in ticks, minor units and weight ticks.""" + + minimum: tuple[int, int, int] + increment: tuple[int, int, int] + price_per_square_metre_minor: int + tare_per_square_metre: int + allowance: tuple[int, int, int] + + +@dataclass(frozen=True) +class ContainerSpec: + id: str + inner: tuple[int, int, int] + outer: tuple[int, int, int] | None + cost_minor: int + max_payload: int | None + max_items: int | None + quantity: int | None + obstacles: tuple[Box, ...] + tags: frozenset[str] + tag_limits: dict[str, int] + void_fill_reserve_ratio: Fraction + tare_weight: int + axles: tuple[tuple[int, int | None], tuple[int, int | None]] | None + max_stack_density: int | None + #: 's carrier rate card, kept raw: this validator re-derives landed cost from + #: the request rather than trusting any engine's arithmetic, which is the whole point + #: of recomputing an objective independently. + rate_table: dict | None + #: The walls this container may be unloaded through. Empty means the + #: horizontal half of route order is not checked -- not that the container is sealed. + #: Six walls and none are both nearly-vacuous, but they are *different* + #: nearly-vacuous, so the absent field keeps the pre-reading. + access_directions: tuple[str, ...] + #: A flat bag, admitted only when a + c <= L and b + c <= W over the largest + #: envelope x2, y2 and z2 of its placements. + flat_bag: bool = False + #: Whether a packed instance must report `achieved_dimensions`: a flat bag or a + #: made-to-measure box. Either way it is the tight extent of the placements. + reports_achieved_extent: bool = False + #: The on-demand box machine this container is cut by, or None for stock. + made_to_measure: "MachineSpec | None" = None + + +def _parse_items(request: dict, unit: str) -> dict[str, ItemSpec]: + items = {} + for raw in request["items"]: + rotations = raw.get("allowed_rotations") + if rotations is None: + rotations = UPRIGHT_ROTATIONS if raw.get("keep_upright") else tuple(ROTATIONS) + items[raw["id"]] = ItemSpec( + id=raw["id"], + dimensions=dimensions_to_ticks(raw["dimensions"], unit), + weight=to_ticks(raw.get("weight", 0), "g", WEIGHT_TICKS), + quantity=raw.get("quantity", 1), + allowed_rotations=tuple(rotations), + stackable=raw.get("stackable", True), + must_be_on_floor=raw.get("must_be_on_floor", False), + max_top_load=( + None if raw.get("max_top_load") is None + else to_ticks(raw["max_top_load"], "g", WEIGHT_TICKS) + ), + minimum_support_ratio=Fraction(str(raw.get("minimum_support_ratio", 0))), + tags=frozenset(raw.get("tags", [])), + eligible_container_tags=frozenset(raw.get("eligible_container_tags", [])), + max_stacked_items=raw.get("max_stacked_items"), + ground_contact_rule=raw.get("ground_contact_rule"), + nesting_height=( + None if raw.get("nesting_height") is None + else to_ticks(raw["nesting_height"], unit, LENGTH_TICKS) + ), + incompatible_tags=frozenset(raw.get("incompatible_tags", [])), + stop_index=raw.get("stop_index"), + value=raw.get("value"), + shape_type=raw.get("shape_type", "rigid_cuboid"), + hull_vertices=( + None if raw.get("hull_vertices") is None + else tuple( + tuple(to_ticks(vertex[axis], unit, LENGTH_TICKS) for axis in "xyz") + for vertex in raw["hull_vertices"] + ) + ), + compression_ratio_ppm=( + None if raw.get("compression_ratio") is None + # The solvers' ratio rule, applied once here so nothing downstream sees a + # float: floor(ratio * 1000000 + 0.5) on the double. + else _ratio_ppm(Fraction(str(raw["compression_ratio"]))) + ), + max_compression_pressure_kpa=raw.get("max_compression_pressure_kpa"), + stacking_group=raw.get("stacking_group"), + accepts_stacking_groups=frozenset(raw.get("accepts_stacking_groups") or ()), + ) + return items + + +def _parse_containers(request: dict, unit: str) -> dict[str, ContainerSpec]: + containers = {} + for raw in request["containers"]: + obstacles = [] + for obstacle in raw.get("obstacles", []): + for component in (obstacle, *obstacle.get("additional_boxes", [])): + origin = component.get("origin", {}) + dims = dimensions_to_ticks(component["dimensions"], unit) + obstacles.append(Box( + to_ticks(origin.get("x", 0), unit, LENGTH_TICKS), + to_ticks(origin.get("y", 0), unit, LENGTH_TICKS), + to_ticks(origin.get("z", 0), unit, LENGTH_TICKS), + *dims, + )) + raw_axles = raw.get("axles") + axles = None if raw_axles is None else tuple( + ( + to_ticks(axle["position"], unit, LENGTH_TICKS), + None if axle.get("max_load") is None + else to_ticks(axle["max_load"], "g", WEIGHT_TICKS), + ) + for axle in raw_axles + ) + containers[raw["id"]] = ContainerSpec( + id=raw["id"], + inner=dimensions_to_ticks(raw["inner_dimensions"], unit), + access_directions=tuple( + direction for direction in _EXIT_DIRECTIONS + if direction in set(raw.get("access_directions", ())) + ), + outer=( + None if raw.get("outer_dimensions") is None + else dimensions_to_ticks(raw["outer_dimensions"], unit) + ), + cost_minor=raw.get("cost_minor", 0), + max_payload=( + None if raw.get("max_payload") is None + else to_ticks(raw["max_payload"], "g", WEIGHT_TICKS) + ), + max_items=raw.get("max_items"), + quantity=raw.get("quantity"), + obstacles=tuple(obstacles), + tags=frozenset(raw.get("tags", [])), + tag_limits={str(tag): int(limit) for tag, limit in raw.get("tag_limits", {}).items()}, + void_fill_reserve_ratio=Fraction(str(raw.get("void_fill_reserve_ratio", 0))), + tare_weight=to_ticks(raw.get("tare_weight", 0), "g", WEIGHT_TICKS), + axles=axles, + max_stack_density=( + None if raw.get("max_stack_density") is None + else to_ticks(raw["max_stack_density"], "g", WEIGHT_TICKS) + ), + rate_table=raw.get("rate_table"), + flat_bag=raw.get("deformable_boundary") is not None, + reports_achieved_extent=(raw.get("deformable_boundary") is not None + or raw.get("made_to_measure") is not None), + made_to_measure=_parse_machine(raw.get("made_to_measure"), unit), + ) + return containers + + +def _parse_machine(raw: dict | None, unit: str) -> MachineSpec | None: + if raw is None: + return None + + def sides(name: str) -> tuple[int, int, int]: + value = raw.get(name) + return (0, 0, 0) if value is None else dimensions_to_ticks(value, unit) + + tare = raw.get("tare_per_square_metre") + return MachineSpec( + minimum=sides("minimum"), + increment=sides("increment"), + price_per_square_metre_minor=raw.get("cost_per_square_metre_minor") or 0, + tare_per_square_metre=0 if tare is None else to_ticks(tare, "g", WEIGHT_TICKS), + allowance=sides("outer_allowance"), + ) + + +# ------------------------------------------------------------------------ validation + + +def _envelope(placement: dict, clearance: int) -> Box: + """Reconstruct the envelope box: the physical box grown by the clearance margin.""" + position = placement["position"] + dimensions = placement["dimensions"] + try: + return Box( + position["x"]["ticks"] - clearance, + position["y"]["ticks"] - clearance, + position["z"]["ticks"] - clearance, + dimensions["length"]["ticks"] + 2 * clearance, + dimensions["width"]["ticks"] + 2 * clearance, + dimensions["height"]["ticks"] + 2 * clearance, + ) + except (KeyError, TypeError) as error: + raise MalformedResult(f"placement is missing tick coordinates: {error}") from error + + +def _materialize_lattice_summaries( + request: dict, + result: dict, + items: dict[str, ItemSpec], + containers: dict[str, ContainerSpec], + clearance: int, + issues: list[Issue], +) -> dict: + """Independently expand compact lattice results before ordinary validation. + + The wire summary is treated as an untrusted, reconstructible representation. Its + lattice parameters must agree with the request before they are used to synthesize + placements; every synthesized placement then passes through the same geometry, + accounting, load and objective checks as an explicitly materialized result. + """ + if not any("lattice_summary" in packed for packed in result["containers"]): + return result + + expanded = deepcopy(result) + coordinates_optional = ( + request.get("configuration", {}).get("require_placement_coordinates") is False + ) + for packed in expanded["containers"]: + summary = packed.get("lattice_summary") + if summary is None: + continue + + container_id = packed.get("id", "?") + if not coordinates_optional: + issues.append(Issue( + "unexpected_lattice_summary", + f"{container_id}: request requires explicit placement coordinates", + )) + if packed.get("placements"): + issues.append(Issue( + "lattice_placements_conflict", + f"{container_id}: compact summary and explicit placements are mutually exclusive", + )) + + item_type = summary.get("item_type") + item = items.get(item_type) + container = containers.get(packed.get("container_type")) + if item is None or container is None: + # The ordinary accounting/container checks report the unknown reference. + continue + + try: + orientation = summary["orientation"] + physical = tuple( + summary["physical_dimensions"][axis]["ticks"] + for axis in ("length", "width", "height") + ) + envelope = tuple( + summary["envelope_dimensions"][axis]["ticks"] + for axis in ("length", "width", "height") + ) + nx = summary["nx"] + ny = summary["ny"] + layers_used = summary["layers_used"] + layer_step = summary["layer_step"]["ticks"] + count = summary["count"] + except (KeyError, TypeError) as error: + raise MalformedResult( + f"{container_id}: lattice_summary is missing a canonical field: {error}" + ) from error + + problems: list[str] = [] + if orientation not in ROTATIONS: + problems.append(f"unknown orientation {orientation!r}") + else: + expected_physical = tuple(item.dimensions[axis] for axis in ROTATIONS[orientation]) + if physical != expected_physical: + problems.append( + f"physical dimensions {physical} != rotated item {expected_physical}" + ) + if orientation not in item.allowed_rotations: + problems.append(f"orientation {orientation} is forbidden for {item_type}") + + expected_envelope = tuple(dimension + 2 * clearance for dimension in physical) + if envelope != expected_envelope: + problems.append( + f"envelope dimensions {envelope} != physical+clearance {expected_envelope}" + ) + if item.nesting_height is not None: + problems.append("compact lattice form is not valid for nesting items") + if layer_step != envelope[2]: + problems.append(f"layer_step {layer_step} != envelope height {envelope[2]}") + + positive_integers = { + "nx": nx, + "ny": ny, + "layers_used": layers_used, + "layer_step": layer_step, + "count": count, + } + for field, value in positive_integers.items(): + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + problems.append(f"{field} must be a positive integer, got {value!r}") + + if not problems: + expected_nx = container.inner[0] // envelope[0] + expected_ny = container.inner[1] // envelope[1] + expected_layers = (count + nx * ny - 1) // (nx * ny) + if nx != expected_nx: + problems.append(f"nx {nx} != independently computed {expected_nx}") + if ny != expected_ny: + problems.append(f"ny {ny} != independently computed {expected_ny}") + if layers_used != expected_layers: + problems.append( + f"layers_used {layers_used} != independently computed {expected_layers}" + ) + if count > item.quantity: + problems.append(f"count {count} exceeds requested quantity {item.quantity}") + vertical_capacity = container.inner[2] // layer_step + if layers_used > vertical_capacity: + problems.append( + f"layers_used {layers_used} exceeds vertical capacity {vertical_capacity}" + ) + + if problems: + issues.extend( + Issue("lattice_summary_mismatch", f"{container_id}: {problem}") + for problem in problems + ) + packed["placements"] = [] + continue + + placements = [] + per_layer = nx * ny + for index in range(count): + x_index = index % nx + y_index = (index // nx) % ny + z_index = index // per_layer + placements.append({ + "item_id": f"{item_type}#lattice:{container_id}:{index + 1}", + "item_type": item_type, + "orientation": orientation, + "position": { + "x": {"ticks": x_index * envelope[0] + clearance}, + "y": {"ticks": y_index * envelope[1] + clearance}, + "z": {"ticks": z_index * layer_step + clearance}, + }, + "dimensions": deepcopy(summary["physical_dimensions"]), + }) + packed["placements"] = placements + + return expanded + + +def _check_structure(result: dict, issues: list[Issue]) -> None: + summary = result["summary"] + packed = sum(len(c["placements"]) for c in result["containers"]) + unpacked = len(result["unpacked_items"]) + if summary["container_count"] != len(result["containers"]): + issues.append(Issue("summary_mismatch", + f"container_count {summary['container_count']} != {len(result['containers'])}")) + if summary["packed_item_count"] != packed: + issues.append(Issue("summary_mismatch", + f"packed_item_count {summary['packed_item_count']} != {packed}")) + if summary["unpacked_item_count"] != unpacked: + issues.append(Issue("summary_mismatch", + f"unpacked_item_count {summary['unpacked_item_count']} != {unpacked}")) + if result["complete"] != (unpacked == 0): + issues.append(Issue("complete_mismatch", + f"complete={result['complete']} with {unpacked} unpacked")) + + +def _resolve_container_tag_rules(request: dict) -> tuple[dict, ...]: + """Participating `require_container_tag` rules, resolved from the request alone. + + Deliberately a second implementation of the resolution in docs/POLICY-RULES.md + rather than an import from any engine: a validator that resolved the rule set with + the engine's own code would agree with it by construction, including when both are + wrong -- which is the case this exists to catch. + + Only this rule form is resolved, because only it can support a *proven* unpacked + reason. Segregation and per-container caps depend on what else was packed, so an + item they leave behind was left behind by the search. + """ + policy = request.get("policy") + if not isinstance(policy, dict): + return () + declared = policy.get("rules") or [] + if not isinstance(declared, list) or not declared: + return () + as_of = policy.get("as_of") + if not isinstance(as_of, int): + return () + shipment = policy.get("shipment") or {} + latest: dict[str, dict] = {} + for rule in declared: + if not isinstance(rule, dict) or "require_container_tag" not in rule: + continue + applies_to = rule.get("applies_to") or {} + if any(shipment.get(fact) != value for fact, value in applies_to.items()): + continue + if rule.get("effective_at", 0) > as_of: + continue + current = latest.get(rule.get("id")) + if current is None or ( + (rule.get("effective_at", 0), rule.get("version", 0)) + > (current.get("effective_at", 0), current.get("version", 0)) + ): + latest[rule.get("id")] = rule + return tuple(latest.values()) + + +def _check_accounting( + result: dict, + items: dict[str, ItemSpec], + containers: dict[str, ContainerSpec], + clearance: int, + issues: list[Issue], + container_tag_rules: tuple[dict, ...] = (), +) -> None: + seen: set[str] = set() + per_type: dict[str, int] = {} + for container in result["containers"]: + for placement in container["placements"]: + item_id = placement["item_id"] + if item_id in seen: + issues.append(Issue("duplicate_item", item_id)) + seen.add(item_id) + per_type[placement["item_type"]] = per_type.get(placement["item_type"], 0) + 1 + for unpacked in result["unpacked_items"]: + item_id = unpacked["item_id"] + if item_id in seen: + issues.append(Issue("duplicate_item", f"{item_id} is both packed and unpacked")) + seen.add(item_id) + per_type[unpacked["item_type"]] = per_type.get(unpacked["item_type"], 0) + 1 + if not unpacked.get("reason"): + issues.append(Issue("missing_reason", item_id)) + _check_reason_proof( + unpacked, + items.get(unpacked["item_type"]), + containers, + clearance, + result["algorithm"]["time_limit_reached"], + result["algorithm"]["effort_limit_reached"], + issues, + container_tag_rules, + ) + + for item_type, count in per_type.items(): + if item_type not in items: + issues.append(Issue("unknown_item", item_type)) + elif count != items[item_type].quantity: + issues.append(Issue("quantity_mismatch", + f"{item_type}: {count} accounted for, {items[item_type].quantity} requested")) + for item_type, spec in items.items(): + if item_type not in per_type: + issues.append(Issue("quantity_mismatch", + f"{item_type}: 0 accounted for, {spec.quantity} requested")) + + +def _fits_empty(container: ContainerSpec, size: tuple[int, int, int]) -> bool: + """Whether one envelope of this size fits the empty container: its frame and, for a flat + bag, both girth sums with the envelope alone at the origin.""" + if any(size[index] > container.inner[index] for index in range(3)): + return False + return not container.flat_bag or (size[0] + size[2] <= container.inner[0] + and size[1] + size[2] <= container.inner[1]) + + +def _achieved_extent(packed: dict, clearance: int) -> tuple[int, int, int]: + """The largest envelope x2, y2 and z2 over a packed container's placements.""" + envelopes = [_envelope(placement, clearance) for placement in packed["placements"]] + return (max((box.x2 for box in envelopes), default=0), + max((box.y2 for box in envelopes), default=0), + max((box.z2 for box in envelopes), default=0)) + + +def _check_achieved_extent(packed: dict, spec: ContainerSpec, clearance: int, + issues: list[Issue]) -> None: + """`achieved_dimensions` is present exactly when the container declares a soft size, and + equals the tight extent; a flat bag's extent must pass the girth rule.""" + reported = packed.get("achieved_dimensions") + if not spec.reports_achieved_extent: + if reported is not None: + issues.append(Issue("achieved_dimensions_mismatch", + f"{packed['id']}: reported for a container of fixed size")) + return + a, b, c = _achieved_extent(packed, clearance) + stated = None if not isinstance(reported, dict) else tuple( + reported.get(side, {}).get("ticks") for side in ("length", "width", "height")) + if stated != (a, b, c): + issues.append(Issue("achieved_dimensions_mismatch", + f"{packed['id']}: reported {stated}, computed {(a, b, c)}")) + if spec.flat_bag and (a + c > spec.inner[0] or b + c > spec.inner[1]): + issues.append(Issue("bag_girth_exceeded", + f"{packed['id']}: a + c = {a + c}, b + c = {b + c}, " + f"flat size {spec.inner[0]} x {spec.inner[1]}")) + + +def _fabricated_side(achieved: int, minimum: int, increment: int) -> int: + """The smallest size on the machine grid `minimum + k * increment` that is at least + `achieved` (docs/MADE-TO-MEASURE-CONTAINERS.md, `grid_a`).""" + return minimum + -(-(max(achieved, minimum) - minimum) // increment) * increment + + +def _per_square_metre_ceiling(rate: int, sides: tuple[int, int, int]) -> int: + """`ceil(rate * 2(LW + LH + WH) / 1 m^2)`, the material and board rounding.""" + length, width, height = sides + area = 2 * (length * width + length * height + width * height) + return -(-rate * area // SQUARE_METRE_TICKS) + + +def _reported_ticks(value: Any, keys: tuple[str, ...]) -> tuple[int, ...] | None: + if not isinstance(value, dict): + return None + return tuple((value.get(key) or {}).get("ticks") for key in keys) + + +def _check_made_to_measure(packed: dict, compact: bool, spec: ContainerSpec, + items: dict[str, ItemSpec], clearance: int, + issues: list[Issue]) -> ContainerSpec: + """Recompute the box cut for a made-to-measure container from its placements alone, and + return the spec every later check uses: the recomputed box, never the reported one. + + O(p) for `p` placements; every product is an exact integer (step 6). + """ + container_id = packed.get("id", "?") + block = packed.get("made_to_measure") + machine = spec.made_to_measure + if machine is None: + if block is not None: + issues.append(Issue("made_to_measure_unexpected", container_id)) + return spec + if not isinstance(block, dict): + issues.append(Issue("made_to_measure_missing", container_id)) + block = {} + if compact: + issues.append(Issue("lattice_summary_on_made_to_measure", container_id)) + sides = ("length", "width", "height") + achieved = _achieved_extent(packed, clearance) + fabricated = tuple( + _fabricated_side(achieved[axis], machine.minimum[axis], machine.increment[axis]) + for axis in range(3) + ) + if any(fabricated[axis] > spec.inner[axis] for axis in range(3)): + issues.append(Issue("fabricated_dimensions_exceed_machine", + f"{container_id}: {fabricated} beyond {spec.inner}")) + reported_inner = _reported_ticks(packed.get("inner_dimensions"), sides) + if reported_inner != fabricated: + issues.append(Issue("fabricated_dimensions_mismatch", + f"{container_id}: reported {reported_inner}, computed {fabricated}")) + outer = tuple(fabricated[axis] + machine.allowance[axis] for axis in range(3)) + reported_outer = _reported_ticks(packed.get("outer_dimensions"), sides) + if reported_outer != outer: + issues.append(Issue("outer_dimensions_mismatch", + f"{container_id}: reported {reported_outer}, computed {outer}")) + cost = spec.cost_minor + _per_square_metre_ceiling(machine.price_per_square_metre_minor, fabricated) + if block.get("cost_minor") != cost: + issues.append(Issue("made_to_measure_cost_mismatch", + f"{container_id}: reported {block.get('cost_minor')}, computed {cost}")) + tare = spec.tare_weight + _per_square_metre_ceiling(machine.tare_per_square_metre, fabricated) + reported_tare = (block.get("tare_weight") or {}).get("ticks") + if reported_tare != tare: + issues.append(Issue("made_to_measure_tare_mismatch", + f"{container_id}: reported {reported_tare}, computed {tare}")) + payload = sum(items[p["item_type"]].weight for p in packed["placements"] if p["item_type"] in items) + reported_gross = (packed.get("gross_weight") or {}).get("ticks") + if reported_gross != tare + payload: + issues.append(Issue("gross_weight_mismatch", + f"{container_id}: reported {reported_gross}, computed {tare + payload}")) + return replace(spec, inner=fabricated, outer=outer, cost_minor=cost, tare_weight=tare) + + +def _check_reason_proof( + unpacked: dict, + spec: ItemSpec | None, + containers: dict[str, ContainerSpec], + clearance: int, + timed_out: bool, + effort_limited: bool, + issues: list[Issue], + container_tag_rules: tuple[dict, ...] = (), +) -> None: + item_id = unpacked.get("item_id", "?") + reason = unpacked.get("reason", "") + proof = unpacked.get("proof") + if not isinstance(proof, dict): + issues.append(Issue("missing_reason_proof", item_id)) + return + level = proof.get("level") + expected = ( + "proven" if reason in { + "no_compatible_container_dimensions", "payload_exceeded", "no_eligible_container", + "rotation_restricted", "policy_rule", + } + else "unknown_due_to_limit" if reason in {"time_limit", "effort_limit"} + else "observed" if reason in { + "no_feasible_placement", "search_exhausted", "exact_search_incomplete", + "insufficient_support", + } + else "inferred" + ) + if level != expected: + issues.append(Issue( + "reason_proof_mismatch", + f"{item_id}: {reason!r} has proof {level!r}, expected {expected!r}", + )) + if reason == "time_limit" and not timed_out: + issues.append(Issue( + "reason_limit_mismatch", + f"{item_id}: time_limit reason without a reached time limit", + )) + if reason == "effort_limit" and not effort_limited: + issues.append(Issue( + "reason_limit_mismatch", + f"{item_id}: effort_limit reason without a reached effort limit", + )) + if spec is not None and reason == "no_compatible_container_dimensions": + fits = any( + _fits_empty(container, tuple(spec.dimensions[axis] + 2 * clearance + for axis in ROTATIONS[rotation])) + for container in containers.values() + for rotation in spec.allowed_rotations + ) + if fits: + issues.append(Issue( + "invalid_proven_reason", + f"{item_id}: at least one allowed orientation fits a container", + )) + # A genuine "no rotation at all works" claim must fail even with every + # physical orientation tried, not only the item's own restricted set -- + # otherwise this reason and `rotation_restricted` below would overlap. + fits_any_rotation = any( + _fits_empty(container, tuple(spec.dimensions[axis] + 2 * clearance + for axis in ROTATIONS[rotation])) + for container in containers.values() + for rotation in ROTATIONS + ) + if fits_any_rotation: + issues.append(Issue( + "invalid_proven_reason", + f"{item_id}: fits at least one container once every rotation is tried, " + f"not only its own allowed set -- should be rotation_restricted", + )) + if spec is not None and reason == "rotation_restricted": + fits_own_rotations = any( + _fits_empty(container, tuple(spec.dimensions[axis] + 2 * clearance + for axis in ROTATIONS[rotation])) + for container in containers.values() + for rotation in spec.allowed_rotations + ) + if fits_own_rotations: + issues.append(Issue( + "invalid_proven_reason", + f"{item_id}: already fits a container within its own allowed rotations", + )) + fits_any_rotation = any( + _fits_empty(container, tuple(spec.dimensions[axis] + 2 * clearance + for axis in ROTATIONS[rotation])) + for container in containers.values() + for rotation in ROTATIONS + ) + if not fits_any_rotation: + issues.append(Issue( + "invalid_proven_reason", + f"{item_id}: does not fit any container even with every rotation tried " + f"-- should be no_compatible_container_dimensions", + )) + if spec is not None and reason == "payload_exceeded": + payload_fits = any( + container.max_payload is None or spec.weight <= container.max_payload + for container in containers.values() + ) + if payload_fits: + issues.append(Issue( + "invalid_proven_reason", + f"{item_id}: its weight is within at least one container payload limit", + )) + if spec is not None and reason == "no_eligible_container": + eligible = any( + not spec.eligible_container_tags + or spec.eligible_container_tags.intersection(container.tags) + for container in containers.values() + ) + if eligible: + issues.append(Issue( + "invalid_proven_reason", + f"{item_id}: at least one container satisfies its eligible tags", + )) + if spec is not None and reason == "policy_rule": + # Two separate claims, and both are checked. That some rule rules this item out + # of every container, and that the rule the result *names* is that rule: a + # citation naming an innocent rule sends a caller to change the wrong policy, + # which is worse than no citation at all. + blocking = [ + rule for rule in container_tag_rules + if rule["require_container_tag"].get("item_tag") in spec.tags + and not any( + rule["require_container_tag"].get("container_tag") in container.tags + for container in containers.values() + ) + ] + if not blocking: + issues.append(Issue( + "invalid_proven_reason", + f"{item_id}: no participating policy rule rules it out of every container", + )) + else: + cited = " ".join(unpacked.get("details") or ()) + if not any(f"{rule['id']}@{rule['version']}" in cited for rule in blocking): + issues.append(Issue( + "invalid_proven_reason", + f"{item_id}: cites {cited!r}, which names no rule that actually blocks it", + )) + observations = proof.get("observations") + if not isinstance(observations, list) or not observations: + issues.append(Issue("missing_reason_observations", item_id)) + return + observed_reason = False + for observation in observations: + if not isinstance(observation, dict): + issues.append(Issue("malformed_reason_observation", item_id)) + continue + code = observation.get("code") + count = observation.get("count") + details = observation.get("details") + if not isinstance(code, str) or not code or not isinstance(count, int) or count < 1 or not isinstance(details, list): + issues.append(Issue("malformed_reason_observation", item_id)) + continue + if code == reason: + observed_reason = True + if not observed_reason: + issues.append(Issue("reason_observation_mismatch", f"{item_id}: {reason}")) + + +# ---------------------------------------------------------------- irregular geometry + + +def _rotate_hull( + vertices: tuple[tuple[int, int, int], ...], orientation: str +) -> tuple[tuple[int, int, int], ...]: + """Reorient a hull the way `ROTATIONS` reorients its box, without ever mirroring it. + + Three of the six orientations are odd permutations of the coordinate axes. A bare + permutation would hand back the item's mirror image -- invisible on a cuboid, wrong on a + hull -- so the sign of one axis flips when the permutation is odd and every orientation + stays a proper rotation. The result is translated so the rotated hull's bounding box + starts at the origin, which is the frame a placement position is expressed in. + """ + axes = ROTATIONS[orientation] + inversions = sum( + 1 for first in range(3) for second in range(first + 1, 3) if axes[first] > axes[second] + ) + sign = -1 if inversions % 2 else 1 + turned = [ + (sign * vertex[axes[0]], vertex[axes[1]], vertex[axes[2]]) for vertex in vertices + ] + low = tuple(min(vertex[axis] for vertex in turned) for axis in range(3)) + return tuple( + (x - low[0], y - low[1], z - low[2]) for x, y, z in turned + ) + + +def _box_vertices(box: Box) -> tuple[tuple[int, int, int], ...]: + return tuple( + (box.x + dx * box.length, box.y + dy * box.width, box.z + dz * box.height) + for dx in (0, 1) for dy in (0, 1) for dz in (0, 1) + ) + + +def _placed_hull( + envelope: Box, placement: dict, spec: "ItemSpec | None" +) -> tuple[tuple[int, int, int], ...]: + """The solid a placement actually occupies, as vertices in container coordinates. + + A `rigid_cuboid`, a `compressible` item and any hull the engine was not allowed to treat + exactly all come back as their eight envelope corners, so one collision routine answers + every pair. The three exact-hull conditions are re-derived here from the request and the + reported placement rather than trusted from the engine. + """ + if (spec is None or spec.shape_type != "convex_hull" or spec.hull_vertices is None + or spec.stop_index is not None + or (envelope.length, envelope.width, envelope.height) + != tuple(spec.dimensions[axis] for axis in ROTATIONS[placement["orientation"]])): + return _box_vertices(envelope) + local = _rotate_hull(spec.hull_vertices, placement["orientation"]) + return tuple((envelope.x + x, envelope.y + y, envelope.z + z) for x, y, z in local) + + +def _hull_axes( + left: tuple[tuple[int, int, int], ...], right: tuple[tuple[int, int, int], ...] +) -> set[tuple[int, int, int]]: + """The complete separating-axis superset of docs/IRREGULAR-ITEMS.md. + + Deliberately the naive set -- every vertex triple of each hull, plus every pair of pair + directions across the two -- rather than the engine's supporting-plane filter. This is a + validator: it is allowed to be slow and has to be obviously right, and taking the same + shortcut as the thing it checks would make the check circular. + """ + axes: set[tuple[int, int, int]] = set() + for hull in (left, right): + for first, second, third in combinations(hull, 3): + axes.add(_primitive_axis(_cross_product( + _difference(second, first), _difference(third, first)))) + left_directions = [_difference(b, a) for a, b in combinations(left, 2)] + right_directions = [_difference(b, a) for a, b in combinations(right, 2)] + for one in left_directions: + for other in right_directions: + axes.add(_primitive_axis(_cross_product(one, other))) + axes.discard((0, 0, 0)) + return axes + + +def _difference(left: tuple[int, int, int], right: tuple[int, int, int]) -> tuple[int, int, int]: + return (left[0] - right[0], left[1] - right[1], left[2] - right[2]) + + +def _cross_product(left: tuple[int, int, int], right: tuple[int, int, int]) -> tuple[int, int, int]: + 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 _primitive_axis(axis: tuple[int, int, int]) -> tuple[int, int, int]: + divisor = gcd(gcd(abs(axis[0]), abs(axis[1])), abs(axis[2])) + if divisor == 0: + return (0, 0, 0) + reduced = (axis[0] // divisor, axis[1] // divisor, axis[2] // divisor) + for value in reduced: + if value: + return reduced if value > 0 else (-reduced[0], -reduced[1], -reduced[2]) + return (0, 0, 0) + + +def _solids_overlap( + left: tuple[tuple[int, int, int], ...], right: tuple[tuple[int, int, int], ...] +) -> bool: + """Whether two convex solids share interior volume. Touching is contact, not collision.""" + for axis in _hull_axes(left, right): + left_values = [_dot_product(vertex, axis) for vertex in left] + right_values = [_dot_product(vertex, axis) for vertex in right] + if max(left_values) <= min(right_values) or max(right_values) <= min(left_values): + return False + return True + + +def _occupied_volume( + envelope: Box, placement: dict, spec: "ItemSpec | None", load: int +) -> int: + """Space a placement actually takes: its hull, its compressed box, or its box. + + Recomputed from the request rather than read from the result, which is the whole point of + this file. Both non-default shapes get utilisation wrong in the flattering direction if + the box is used instead -- a hull item reports two interlocking wedges as 200% of a crate, + and a compressible one never shows the height it gave up under load. + """ + if spec is not None and spec.max_compression_pressure_kpa is not None: + return envelope.base_area * _compressed_height(envelope, spec, load) + if spec is not None and spec.shape_type == "convex_hull" and spec.hull_vertices is not None: + # Collision intentionally uses the box for routes and clearance. Volume is a property + # of the physical hull and must not inherit that conservative collision fallback. + return _hull_volume(_rotate_hull(spec.hull_vertices, placement["orientation"])) + solid = _placed_hull(envelope, placement, spec) + if len(solid) == 8 and solid == _box_vertices(envelope): + return (placement["dimensions"]["length"]["ticks"] + * placement["dimensions"]["width"]["ticks"] + * placement["dimensions"]["height"]["ticks"]) + return _hull_volume(solid) + + +def _compressed_height(envelope: Box, spec: ItemSpec, load: int) -> int: + """Occupied height under load, rounded up, never below one tick. + + The formula of docs/IRREGULAR-ITEMS.md, in exact rationals. A crushed item keeps its + uncompressed height here: the arrangement is already invalid and `_check_crush` reports + it, so this stays a reported issue rather than a second, quieter one. + """ + assert spec.compression_ratio_ppm is not None + assert spec.max_compression_pressure_kpa is not None + pressure = Fraction( + load * 980_665 * (LENGTH_TICKS["mm"] * 1000) ** 2, + WEIGHT_TICKS["kg"] * 100_000 * 1_000 * envelope.base_area, + ) + if pressure > spec.max_compression_pressure_kpa or spec.max_compression_pressure_kpa == 0: + return envelope.height + divisor = spec.max_compression_pressure_kpa * 1_000_000 * pressure.denominator + retained = divisor - spec.compression_ratio_ppm * pressure.numerator + return max(1, -(-envelope.height * retained // divisor)) + + +def _hull_volume(vertices: tuple[tuple[int, int, int], ...]) -> int: + """Exact volume of a convex hull, `6V = sum of a . (b x c)` over its outward faces. + + Written the slow, obvious way -- every vertex triple is tested for being a supporting + plane, and every face is wound with integer cross products -- because a validator that + took the engine's shortcuts would stop being a second opinion. + """ + normals = set() + for first, second, third in combinations(vertices, 3): + axis = _primitive_axis(_cross_product( + _difference(second, first), _difference(third, first))) + if axis != (0, 0, 0): + normals.add(axis) + six_volumes = 0 + for axis in sorted(normals): + for outward in (axis, (-axis[0], -axis[1], -axis[2])): + extreme = max(_dot_product(vertex, outward) for vertex in vertices) + face = [v for v in vertices if _dot_product(v, outward) == extreme] + if len(face) < 3: + continue + ordered = _wind_face(face, outward) + apex = ordered[0] + for second, third in zip(ordered[1:], ordered[2:]): + six_volumes += _dot_product(apex, _cross_product(second, third)) + return abs(six_volumes) // 6 + + +def _wind_face( + face: list[tuple[int, int, int]], outward: tuple[int, int, int] +) -> tuple[tuple[int, int, int], ...]: + """Corners of one planar convex face, in cyclic order seen from outside. + + The vertices sharing a supporting plane are not all corners of the polygon they lie on: + one can sit inside the face or part-way along an edge, and fanning over the raw set + triangulates the wrong region. The surface then fails to close and the volume is wrong -- + which is how the defect this replaces was found. Gift-wrapping keeps only the corners, + starting from the lexicographically smallest vertex, which is extreme in any linear order + and therefore a corner. Collinear candidates resolve to the farthest, so a vertex lying on + an edge is walked past instead of doubled back through. + """ + start = min(face) + ordered = [start] + current = start + for _ in range(len(face)): + following = None + for candidate in face: + if candidate == current: + continue + if following is None: + following = candidate + continue + turn = _dot_product( + _cross_product(_difference(following, current), _difference(candidate, current)), + outward, + ) + reach = _dot_product(_difference(candidate, current), _difference(candidate, current)) + held = _dot_product(_difference(following, current), _difference(following, current)) + if turn < 0 or (turn == 0 and reach > held): + following = candidate + if following is None or following == start: + break + ordered.append(following) + current = following + return tuple(ordered) + + +def _dot_product(left: tuple[int, int, int], right: tuple[int, int, int]) -> int: + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2] + + +def _check_rotation(placement: dict, spec: ItemSpec, issues: list[Issue]) -> None: + orientation = placement["orientation"] + if orientation not in ROTATIONS: + issues.append(Issue("unknown_orientation", f"{placement['item_id']}: {orientation}")) + return + if orientation not in spec.allowed_rotations: + issues.append(Issue("forbidden_rotation", f"{placement['item_id']}: {orientation}")) + expected = tuple(spec.dimensions[axis] for axis in ROTATIONS[orientation]) + actual = ( + placement["dimensions"]["length"]["ticks"], + placement["dimensions"]["width"]["ticks"], + placement["dimensions"]["height"]["ticks"], + ) + if actual != expected: + issues.append(Issue("dimension_mismatch", + f"{placement['item_id']}: {orientation} of {spec.dimensions} is {expected}, got {actual}")) + + +def _check_container( + packed: dict, + spec: ContainerSpec, + items: dict[str, ItemSpec], + clearance: int, + global_support: Fraction, + issues: list[Issue], +) -> None: + boundary = Box(0, 0, 0, *spec.inner) + envelopes = {} + payload = 0 + + for placement in packed["placements"]: + item_id = placement["item_id"] + item_spec = items.get(placement["item_type"]) + envelope = _envelope(placement, clearance) + envelopes[item_id] = (envelope, placement, item_spec) + + if not boundary.contains(envelope): + issues.append(Issue("outside_container", f"{item_id} in {packed['id']}")) + for obstacle in spec.obstacles: + if envelope.intersects(obstacle) and _solids_overlap( + _placed_hull(envelope, placement, item_spec), _box_vertices(obstacle) + ): + issues.append(Issue("obstacle_collision", f"{item_id} in {packed['id']}")) + if item_spec is None: + continue + + payload += item_spec.weight + _check_rotation(placement, item_spec, issues) + if (item_spec.eligible_container_tags + and not item_spec.eligible_container_tags.intersection(spec.tags)): + issues.append(Issue( + "container_ineligible", + f"{item_id}: {packed['container_type']} has none of " + f"{sorted(item_spec.eligible_container_tags)}", + )) + if item_spec.must_be_on_floor and envelope.z != 0: + issues.append(Issue("floor_violation", f"{item_id} at z={envelope.z}")) + + for (left_id, left_value), (right_id, right_value) in combinations(envelopes.items(), 2): + left, left_placement, left_spec = left_value + right, right_placement, right_spec = right_value + if (left.intersects(right) + and _solids_overlap(_placed_hull(left, left_placement, left_spec), + _placed_hull(right, right_placement, right_spec)) + and not _valid_nesting( + left, left_placement, left_spec, right, right_placement, right_spec)): + issues.append(Issue("collision", f"{left_id} with {right_id} in {packed['id']}")) + if ( + left_spec is not None and right_spec is not None + and (left_spec.incompatible_tags & right_spec.tags + or right_spec.incompatible_tags & left_spec.tags) + ): + issues.append(Issue( + "incompatible_items", f"{left_id} is incompatible with {right_id} in {packed['id']}" + )) + + supporters, direct_above = _support_graph(envelopes) + _check_support_and_load( + packed, envelopes, supporters, direct_above, global_support, issues + ) + _check_stack_density(packed, envelopes, supporters, spec.max_stack_density, issues) + _check_crush(packed, envelopes, supporters, issues) + _check_route_removal_order(packed, envelopes, direct_above, spec.inner, + spec.access_directions, issues) + + if spec.max_payload is not None and payload > spec.max_payload: + issues.append(Issue("payload_exceeded", + f"{packed['id']}: {payload} > {spec.max_payload} ticks")) + reported = packed.get("payload_weight", {}).get("ticks") + if reported is not None and reported != payload: + issues.append(Issue("payload_weight_mismatch", + f"{packed['id']}: reported {reported}, computed {payload}")) + if spec.max_items is not None and len(packed["placements"]) > spec.max_items: + issues.append(Issue("max_items_exceeded", + f"{packed['id']}: {len(packed['placements'])} > {spec.max_items}")) + for tag, limit in sorted(spec.tag_limits.items()): + count = sum( + 1 + for placement in packed["placements"] + if (items.get(placement["item_type"]) is not None + and tag in items[placement["item_type"]].tags) + ) + if count > limit: + issues.append(Issue( + "tag_count_exceeded", + f"{packed['id']}: {tag} count {count} > limit {limit}", + )) + + loads = _propagate_load(envelopes, supporters) + used = sum( + _occupied_volume(envelopes[p["item_id"]][0], p, items.get(p["item_type"]), + loads[p["item_id"]]) + for p in packed["placements"] + ) + for (_, left_value), (_, right_value) in combinations(envelopes.items(), 2): + left, left_placement, left_spec = left_value + right, right_placement, right_spec = right_value + if _valid_nesting(left, left_placement, left_spec, right, right_placement, right_spec): + assert left_spec is not None and left_spec.nesting_height is not None + used -= left_spec.nesting_height * left.length * left.width + reported_volume = packed.get("used_volume_ticks3") + if reported_volume is not None and int(reported_volume) != used: + issues.append(Issue("used_volume_mismatch", + f"{packed['id']}: reported {reported_volume}, computed {used}")) + volume = spec.inner[0] * spec.inner[1] * spec.inner[2] + scaled_reserve = _ratio_ppm(spec.void_fill_reserve_ratio) + reserved = (volume // SCORE_SCALE) * scaled_reserve + reserved += ((volume % SCORE_SCALE) * scaled_reserve) // SCORE_SCALE + reported_reserve = packed.get("void_fill_reserve_ticks3") + if reported_reserve is not None and int(reported_reserve) != reserved: + issues.append(Issue( + "void_fill_reserve_mismatch", + f"{packed['id']}: reported {reported_reserve}, computed {reserved}", + )) + elif reserved and reported_reserve is None: + issues.append(Issue( + "void_fill_reserve_mismatch", + f"{packed['id']}: non-zero reserve {reserved} is not reported", + )) + if used > volume - reserved: + issues.append(Issue( + "void_fill_reserve_exceeded", + f"{packed['id']}: used {used} > usable {volume - reserved}", + )) + + total_weight = sum( + items[p["item_type"]].weight + for p in packed["placements"] + if p["item_type"] in items + ) + if total_weight == 0: + expected_com = 0 + else: + doubled_x = sum( + items[p["item_type"]].weight + * (2 * p["position"]["x"]["ticks"] + p["dimensions"]["length"]["ticks"]) + for p in packed["placements"] + if p["item_type"] in items + ) + doubled_y = sum( + items[p["item_type"]].weight + * (2 * p["position"]["y"]["ticks"] + p["dimensions"]["width"]["ticks"]) + for p in packed["placements"] + if p["item_type"] in items + ) + x_ppm = abs(doubled_x - total_weight * spec.inner[0]) * SCORE_SCALE // ( + total_weight * spec.inner[0] + ) + y_ppm = abs(doubled_y - total_weight * spec.inner[1]) * SCORE_SCALE // ( + total_weight * spec.inner[1] + ) + expected_com = max(x_ppm, y_ppm) + if packed.get("centre_of_mass_offset_ppm") != expected_com: + issues.append(Issue( + "centre_of_mass_mismatch", + f"{packed['id']}: reported {packed.get('centre_of_mass_offset_ppm')}, " + f"computed {expected_com}", + )) + + if spec.axles is not None: + (front_x, front_limit), (rear_x, rear_limit) = spec.axles + denominator = 2 * (rear_x - front_x) + gross = spec.tare_weight + total_weight + doubled_weighted_x = spec.tare_weight * spec.inner[0] + sum( + items[p["item_type"]].weight + * (2 * p["position"]["x"]["ticks"] + p["dimensions"]["length"]["ticks"]) + for p in packed["placements"] + if p["item_type"] in items + ) + front_numerator = 2 * gross * rear_x - doubled_weighted_x + rear_numerator = doubled_weighted_x - 2 * gross * front_x + expected_reactions = { + "basis": "gross", + "denominator": str(denominator), + "front_numerator": str(front_numerator), + "rear_numerator": str(rear_numerator), + } + if packed.get("axle_reactions") != expected_reactions: + issues.append(Issue( + "axle_reaction_mismatch", + f"{packed['id']}: reported {packed.get('axle_reactions')}, " + f"computed {expected_reactions}", + )) + if ( + front_limit is not None + and front_numerator > front_limit * denominator + ) or ( + rear_limit is not None + and rear_numerator > rear_limit * denominator + ): + issues.append(Issue("axle_overloaded", packed["id"])) + elif "axle_reactions" in packed: + issues.append(Issue( + "axle_reaction_mismatch", + f"{packed['id']}: reactions reported without configured axles", + )) + + +def _valid_nesting( + left: Box, + left_placement: dict, + left_spec: ItemSpec | None, + right: Box, + right_placement: dict, + right_spec: ItemSpec | None, +) -> bool: + if ( + left_spec is None + or right_spec is None + or left_spec.id != right_spec.id + or left_placement["item_type"] != right_placement["item_type"] + or left_spec.nesting_height is None + or right_spec.nesting_height != left_spec.nesting_height + ): + return False + if (left.x, left.y, left.x2, left.y2) != (right.x, right.y, right.x2, right.y2): + return False + lower, upper = (left, right) if left.z <= right.z else (right, left) + return lower.z != upper.z and lower.z2 - upper.z == left_spec.nesting_height + + +def _support_graph( + envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], +) -> tuple[dict[str, list[tuple[str, int]]], dict[str, set[str]]]: + """Build exact direct-support edges in deterministic placement order. + + Ordinary face contacts retain their existing order. Nested instances are grouped + by item type and exact XY footprint, sorted once by height, and connected only to + their adjacent predecessor. A predecessor contributes the upper item's full base + area and replaces shadowed face contacts from that same column. Construction is + O(n log n + q + e) time and O(n + e) space, where ``q`` is the number of same-plane + candidates and ``e`` the represented support edges. + """ + item_ids = list(envelopes) + ordinal = {item_id: index for index, item_id in enumerate(item_ids)} + by_top: dict[int, list[str]] = {} + for item_id, (envelope, _, _) in envelopes.items(): + by_top.setdefault(envelope.z2, []).append(item_id) + + supporters: dict[str, list[tuple[str, int]]] = {item_id: [] for item_id in item_ids} + for upper_id, (upper, _, _) in envelopes.items(): + for lower_id in by_top.get(upper.z, ()): + if lower_id == upper_id: + continue + area = envelopes[lower_id][0].overlap_area_xy(upper) + if area > 0: + supporters[upper_id].append((lower_id, area)) + + group_keys: dict[str, tuple[str, int, int, int, int, int]] = {} + groups: dict[tuple[str, int, int, int, int, int], list[str]] = {} + for item_id, (envelope, placement, spec) in envelopes.items(): + if spec is None or spec.nesting_height is None: + continue + key = ( + placement["item_type"], envelope.x, envelope.y, + envelope.x2, envelope.y2, spec.nesting_height, + ) + group_keys[item_id] = key + groups.setdefault(key, []).append(item_id) + + predecessors: dict[str, str] = {} + for group in groups.values(): + group.sort(key=lambda item_id: (envelopes[item_id][0].z, ordinal[item_id])) + for lower_id, upper_id in zip(group, group[1:]): + lower = envelopes[lower_id][0] + upper = envelopes[upper_id][0] + lower_spec = envelopes[lower_id][2] + assert lower_spec is not None and lower_spec.nesting_height is not None + nesting = lower_spec.nesting_height + if lower.z2 - upper.z == nesting: + predecessors[upper_id] = lower_id + + for upper_id, lower_id in predecessors.items(): + key = group_keys[upper_id] + edges = [ + edge for edge in supporters[upper_id] + if group_keys.get(edge[0]) != key + ] + edge = (lower_id, envelopes[upper_id][0].base_area) + position = next( + (index for index, (candidate_id, _) in enumerate(edges) + if ordinal[candidate_id] > ordinal[lower_id]), + len(edges), + ) + edges.insert(position, edge) + supporters[upper_id] = edges + + direct_above: dict[str, set[str]] = {item_id: set() for item_id in item_ids} + for upper_id, edges in supporters.items(): + for lower_id, _ in edges: + direct_above[lower_id].add(upper_id) + return supporters, direct_above + + +def _check_support_and_load( + packed: dict, + envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], + supporters: dict[str, list[tuple[str, int]]], + direct_above: dict[str, set[str]], + global_support: Fraction, + issues: list[Issue], +) -> None: + for item_id, (envelope, placement, spec) in envelopes.items(): + if spec is None or envelope.z == 0: + continue + + supporting = [ + (other_id, envelopes[other_id][0], envelopes[other_id][2], area) + for other_id, area in supporters[item_id] + ] + + required = max(global_support, spec.minimum_support_ratio) + rule = spec.ground_contact_rule + if rule not in (None, "free"): + if rule == "covered": + corners = ( + (envelope.x, envelope.y), + (envelope.x2, envelope.y), + (envelope.x, envelope.y2), + (envelope.x2, envelope.y2), + ) + covered = all( + any( + surface.x <= x <= surface.x2 and surface.y <= y <= surface.y2 + for _, surface, _, _ in supporting + ) + for x, y in corners + ) + if not covered: + issues.append(Issue( + "ground_contact_violation", + f"{item_id}: covered requires all four base corners", + )) + elif rule == "single" and len(supporting) != 1: + issues.append(Issue( + "ground_contact_violation", + f"{item_id}: single has {len(supporting)} supporters", + )) + elif rule == "multiple" and len(supporting) < 2: + issues.append(Issue( + "ground_contact_violation", + f"{item_id}: multiple has {len(supporting)} supporters", + )) + if required > 0: + area = sum(area for _, _, _, area in supporting) + if area < _required_support_area(envelope.base_area, _ratio_ppm(required)): + ratio = Fraction(area, envelope.base_area) + issues.append(Issue("insufficient_support", + f"{item_id}: {float(ratio):.6f} < {float(required):.6f}")) + + for lower_id, _, lower_spec, _ in supporting: + if lower_spec is None: + continue + if not lower_spec.stackable: + issues.append(Issue("non_stackable", f"{item_id} rests on {lower_id}")) + elif (lower_spec.accepts_stacking_groups + and spec.stacking_group not in lower_spec.accepts_stacking_groups): + issues.append(Issue("stacking_group_incompatible", f"{item_id} rests on {lower_id}")) + if lower_spec.max_top_load is not None and spec.weight > lower_spec.max_top_load: + issues.append(Issue("top_load_exceeded", + f"{item_id} ({spec.weight} ticks) on {lower_id} " + f"(limit {lower_spec.max_top_load})")) + + for item_id, (_, _, spec) in envelopes.items(): + if spec is None or spec.max_stacked_items is None: + continue + descendants: set[str] = set() + pending = list(direct_above[item_id]) + while pending: + above_id = pending.pop() + if above_id in descendants: + continue + descendants.add(above_id) + pending.extend(direct_above[above_id]) + if len(descendants) > spec.max_stacked_items: + issues.append(Issue( + "max_stacked_items_exceeded", + f"{item_id}: {len(descendants)} above > limit {spec.max_stacked_items}", + )) + + +def _propagate_load( + envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], + supporters: dict[str, list[tuple[str, int]]], +) -> dict[str, int]: + """Cumulative weight each item bears, from everything resting on it however + indirectly, split across its supporters by contact area and conserved exactly + (the integer remainder goes to the last supporter). Processes top-down, by + descending envelope ceiling, so an item's own inherited load is finalized + before it is pushed further down onto whatever it itself rests on. + """ + loads: dict[str, int] = {item_id: 0 for item_id in envelopes} + order = sorted( + envelopes, + key=lambda item_id: (-envelopes[item_id][0].z2, -envelopes[item_id][0].z, item_id), + ) + for upper_id in order: + upper, _, upper_spec = envelopes[upper_id] + if upper_spec is None: + continue + supports = supporters[upper_id] + total_area = sum(area for _, area in supports) + if total_area == 0: + continue + downward = upper_spec.weight + loads[upper_id] + assigned = 0 + for index, (lower_id, area) in enumerate(supports): + share = downward - assigned if index == len(supports) - 1 else downward * area // total_area + assigned += share + loads[lower_id] += share + return loads + + +def _check_crush( + packed: dict, + envelopes: dict[str, tuple[Box, dict, "ItemSpec | None"]], + supporters: dict[str, list[tuple[str, int]]], + issues: list[Issue], +) -> None: + """No `compressible` item may carry more pressure than it declared. + + Recomputed from the request and the reported placements, never from anything the engine + said about compression. The cumulative mass above each item comes from `_propagate_load`, + which this validator already derives for `max_stack_density` -- the same number the + pressure model needs as its numerator, so the crush verdict rides one traversal rather + than inventing a second notion of "resting on". + + Exact rational throughout: the boundary is inclusive, and a floating-point comparison + would put loads a millionth over the limit on the wrong side of it. + """ + if all(spec is None or spec.max_compression_pressure_kpa is None + for _, _, spec in envelopes.values()): + return + loads = _propagate_load(envelopes, supporters) + for item_id, (envelope, _, spec) in envelopes.items(): + if spec is None or spec.max_compression_pressure_kpa is None: + continue + # P(kPa) = load_ticks * g * (ticks per metre)^2 / (ticks per kg * g_denominator + # * pascals per kPa * footprint_ticks^2) + pressure = Fraction( + loads[item_id] * 980_665 * (LENGTH_TICKS["mm"] * 1000) ** 2, + WEIGHT_TICKS["kg"] * 100_000 * 1_000 * envelope.base_area, + ) + if pressure > spec.max_compression_pressure_kpa: + issues.append(Issue( + "crush_violation", + f"{item_id} in {packed['id']}: {float(pressure):.6f} kPa exceeds " + f"{spec.max_compression_pressure_kpa} kPa", + )) + + +def _check_stack_density( + packed: dict, + envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], + supporters: dict[str, list[tuple[str, int]]], + max_density: int | None, + issues: list[Issue], +) -> None: + """`Container.max_stack_density` limits weight per square metre of an + item's own base area, at every level of the stack, not only the floor -- a wide + item narrowing to a small waist part-way up is exactly the case a flat + `max_top_load` cannot express. + """ + if max_density is None: + return + loads = _propagate_load(envelopes, supporters) + for item_id, (envelope, _, spec) in envelopes.items(): + if spec is None: + continue + total = spec.weight + loads[item_id] + if total * SQUARE_METRE_TICKS > max_density * envelope.base_area: + issues.append(Issue( + "stack_density_exceeded", + f"{item_id} in {packed['id']}: {total} ticks over {envelope.base_area} " + f"ticks^2 exceeds {max_density} ticks/m^2", + )) + + +def _swept_region(envelope: Box, container: tuple[int, int, int], direction: str) -> Box: + """The region between `envelope`'s own face and the matching container wall + along `direction` -- the space a placement leaving that way would sweep.""" + x1, y1, z1, x2, y2, z2 = envelope.x, envelope.y, envelope.z, envelope.x2, envelope.y2, envelope.z2 + if direction == "+x": + x1, x2 = x2, container[0] + elif direction == "-x": + x1, x2 = 0, x1 + elif direction == "+y": + y1, y2 = y2, container[1] + elif direction == "-y": + y1, y2 = 0, y1 + elif direction == "+z": + z1, z2 = z2, container[2] + else: + z1, z2 = 0, z1 + return Box(x1, y1, z1, x2 - x1, y2 - y1, z2 - z1) + + +def _has_clear_exit( + item_id: str, + envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], + present: set[str], + container: tuple[int, int, int], + directions: Sequence[str] = _EXIT_DIRECTIONS, +) -> bool: + """Can `item_id` be slid straight out through some container wall without + passing through any other currently-present placement?""" + envelope = envelopes[item_id][0] + for direction in directions: + region = _swept_region(envelope, container, direction) + if not any( + other_id != item_id and region.intersects(envelopes[other_id][0]) + for other_id in present + ): + return True + return False + + + +def corridor_replay_succeeds( + boxes: dict[str, Box], + stops: dict[str, float], + container: tuple[int, int, int], + directions: Sequence[str], +) -> bool: + """The whole-scene stop-accessibility replay, recomputed from the rule alone. + + Deliberately a second implementation rather than a call into the engine: a check that + shares code with the thing it checks can only find typos. It answers the *sufficient* + question of docs/STOP-ACCESSIBILITY.md -- can every item due at stop `t` actually be + taken out at stop `t`, in some order -- against an explicit door set, which the request + schema cannot yet express. + + Vertical blocking is not consulted here. That half is `_check_route_removal_order`'s, + and keeping them apart is what lets a caller test either one without the other + masking it. + """ + present = set(boxes) + for stop in sorted({value for value in stops.values() if value != float("inf")}): + due = {item_id for item_id, value in stops.items() if value == stop} + while due: + removable = [ + item_id for item_id in sorted(due) + if any( + not any(other != item_id + and _swept_region(boxes[item_id], container, direction) + .intersects(boxes[other]) + for other in present) + for direction in directions + ) + ] + if not removable: + return False + chosen = removable[0] + present.discard(chosen) + due.discard(chosen) + return True + + +def _check_route_removal_order( + packed: dict, + envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], + direct_above: dict[str, set[str]], + container: tuple[int, int, int], + doors: tuple[str, ...], + issues: list[Issue], +) -> None: + """Items due at an earlier route stop must all be removable -- + unblocked by whatever still rests on them, and slidable straight out some wall -- + before the route moves on to the next stop. A placement with no `stop_index` is + never scheduled for removal and stays present for every stop, exactly like a + fixture that rides the whole route, but still counts as a potential blocker. + + gave the request a way to say *which* wall (`container.access_directions`), + and this is where an engine that ignores it is caught. Without doors the check runs + against all six, which is what it always did and is what a container that states none + still gets. With doors it is the whole-scene replay `docs/STOP-ACCESSIBILITY.md` calls + the definition of correctness -- and because it is computed here from the request + rather than read off the result, an engine cannot satisfy it by agreeing with itself. + """ + stops = { + item_id: spec.stop_index + for item_id, (_, _, spec) in envelopes.items() + if spec is not None and spec.stop_index is not None + } + if not stops: + return + present = set(envelopes) + for stop in sorted(set(stops.values())): + due = {item_id for item_id, value in stops.items() if value == stop and item_id in present} + while due: + removable = [ + item_id for item_id in due + if not (direct_above[item_id] & present) + and _has_clear_exit(item_id, envelopes, present, container, + doors or _EXIT_DIRECTIONS) + ] + if not removable: + issues.append(Issue( + "unloading_order_violation", + f"{packed['id']}: stop {stop} cannot fully unload {sorted(due)} " + "before the route moves on", + )) + return + chosen = min(removable) + present.discard(chosen) + due.discard(chosen) + + +def validate(request: dict, result: dict) -> list[Issue]: + """Return every way ``result`` fails to be a sound answer to ``request``. + + An empty list means valid. Raises :class:`MalformedResult` when the result does not + have the documented shape at all — that is a contract break, not a packing defect. + """ + for key in ("status", "feasibility", "termination", "optimality", "complete", + "algorithm", "summary", "containers", "unpacked_items", "score"): + if key not in result: + raise MalformedResult(f"result is missing {key!r}") + + unit = request.get("units", {}).get("length", "mm") + configuration = request.get("configuration", {}) + clearance = to_ticks(configuration.get("clearance", 0), unit, LENGTH_TICKS) + global_support = Fraction(str(configuration.get("minimum_support_ratio", 0))) + + items = _parse_items(request, unit) + containers = _parse_containers(request, unit) + + issues: list[Issue] = [] + validated_result = _materialize_lattice_summaries( + request, result, items, containers, clearance, issues + ) + _check_structure(validated_result, issues) + _check_algorithm_metrics(validated_result, issues) + _check_effort_budget(request, validated_result, issues) + _check_result_facts(validated_result, issues) + _check_accounting( + validated_result, items, containers, clearance, issues, + _resolve_container_tag_rules(request), + ) + + if validated_result["status"] == "infeasible" and any( + unpacked.get("proof", {}).get("level") != "proven" + for unpacked in validated_result["unpacked_items"] + ): + issues.append(Issue( + "unproven_infeasible", + "infeasible status requires a proven rejection for every unpacked item", + )) + + inventory: dict[str, int] = {} + fabricated: dict[str, ContainerSpec] = {} + for packed, reported in zip(validated_result["containers"], result["containers"]): + container_type = packed["container_type"] + spec = containers.get(container_type) + if spec is None: + issues.append(Issue("unknown_container_type", container_type)) + continue + inventory[container_type] = inventory.get(container_type, 0) + 1 + spec = _check_made_to_measure(packed, "lattice_summary" in reported, spec, items, + clearance, issues) + if spec.made_to_measure is not None: + fabricated[packed["id"]] = spec + _check_container(packed, spec, items, clearance, global_support, issues) + _check_achieved_extent(packed, spec, clearance, issues) + + for container_type, count in inventory.items(): + quantity = containers[container_type].quantity + if quantity is not None and count > quantity: + issues.append(Issue("container_inventory_exceeded", + f"{container_type}: used {count}, available {quantity}")) + + max_containers = configuration.get("max_containers") + opened = len(validated_result["containers"]) + if max_containers is not None and opened > max_containers: + issues.append(Issue("max_containers_exceeded", + f"{opened} containers opened, limit {max_containers}")) + + _check_fixed_placements(request, validated_result, unit, issues) + _check_groups(request, validated_result, issues) + _check_solver_selection(request, validated_result, issues) + _check_objective(request, validated_result, containers, items, clearance, issues, fabricated) + return issues + + +FixedKey = tuple[str, str, int, int, int, str] + + +def _check_fixed_placements(request: dict, result: dict, unit: str, issues: list[Issue]) -> None: + """Every fixed placement is reported where the request put it, and nothing else claims to be. + + Physics needs no rule here: a fixed item is an ordinary placement, so every check above + already applied to it. What only this rule can see is whether it moved + (docs/PLAN-REVISIONS.md). + """ + requested: set[FixedKey] = set() + for entry in request.get("fixed_placements", ()): + container_id = f"{entry['container_type']}#{entry.get('container_instance', 1)}" + position = entry.get("position", {}) + requested.add(( + container_id, + entry["item_type"], + *(to_ticks(position.get(axis, 0), unit, LENGTH_TICKS) for axis in "xyz"), + entry["orientation"], + )) + reported: set[FixedKey] = set() + container_types: dict[str, str] = {} + for container in result["containers"]: + container_types[container.get("id", "")] = container["container_type"] + for placement in container["placements"]: + if placement.get("fixed") is not True: + continue + position = placement["position"] + reported.add(( + container.get("id", ""), + placement["item_type"], + *(position[axis]["ticks"] for axis in "xyz"), + placement["orientation"], + )) + for key in sorted(requested): + container_id = key[0] + if container_types.get(container_id) != container_id.rsplit("#", 1)[0]: + issues.append(Issue("fixed_container_missing", container_id)) + elif key not in reported: + issues.append(Issue("fixed_placement_moved", f"{key[1]} fixed in {container_id} at {key[2:5]} {key[5]}")) + for key in sorted(reported - requested): + issues.append(Issue("unexpected_fixed_placement", f"{key[1]} in {key[0]} at {key[2:5]} {key[5]}")) + + +def _check_algorithm_metrics(result: dict, issues: list[Issue]) -> None: + algorithm = result["algorithm"] + if not isinstance(algorithm, dict): + raise MalformedResult("algorithm must be an object") + metrics = algorithm.get("metrics") + fields = ( + "candidate_points_considered", + "orientations_considered", + "feasible_candidates", + "collision_checks", + "support_checks", + "space_partitions", + "search_nodes_expanded", + ) + if not isinstance(metrics, dict) or any( + not isinstance(metrics.get(field), int) + or isinstance(metrics.get(field), bool) + or metrics[field] < 0 + for field in fields + ): + issues.append(Issue( + "solver_metrics_mismatch", + "algorithm.metrics must contain every non-negative integer counter", + )) + return + legacy_candidates = algorithm.get("candidates_evaluated") + legacy_attempts = algorithm.get("placements_attempted") + if ( + not isinstance(legacy_candidates, int) + or isinstance(legacy_candidates, bool) + or not isinstance(legacy_attempts, int) + or isinstance(legacy_attempts, bool) + or legacy_candidates != metrics["feasible_candidates"] + or legacy_attempts != metrics["orientations_considered"] + ): + issues.append(Issue( + "solver_metrics_mismatch", + "legacy candidate counters must mirror algorithm.metrics", + )) + + +def _check_effort_budget(request: dict, result: dict, issues: list[Issue]) -> None: + """Verify that counted-work limits are respected and the stop flag is evidenced.""" + algorithm = result["algorithm"] + effort_limited = algorithm.get("effort_limit_reached") + if not isinstance(effort_limited, bool): + raise MalformedResult("algorithm.effort_limit_reached must be a boolean") + + budget = request.get("configuration", {}).get("effort_budget") + if budget is None: + if effort_limited: + issues.append(Issue( + "effort_budget_mismatch", + "effort_limit_reached is true but the request has no effort_budget", + )) + return + if not isinstance(budget, dict): + raise MalformedResult("config.effort_budget must be an object") + + metrics = algorithm.get("metrics") + if not isinstance(metrics, dict): + return + metric_limits = ( + ("max_candidates_evaluated", "feasible_candidates"), + ("max_placement_attempts", "orientations_considered"), + ("max_search_nodes", "search_nodes_expanded"), + ) + reached = False + for budget_name, metric_name in metric_limits: + limit = budget.get(budget_name) + value = metrics.get(metric_name) + if limit is None or not isinstance(value, int) or isinstance(value, bool): + continue + if value > limit: + issues.append(Issue( + "effort_budget_exceeded", + f"{metric_name} is {value}, above {budget_name}={limit}", + )) + reached = reached or value >= limit + + restart_limit = budget.get("max_restarts") + starts = result.get("termination", {}).get("starts", []) + if isinstance(restart_limit, int) and isinstance(starts, list): + started = sum( + 1 for start in starts + if isinstance(start, dict) and start.get("started") is True + ) + if started > restart_limit: + issues.append(Issue( + "effort_budget_exceeded", + f"started {started} solver runs, above max_restarts={restart_limit}", + )) + reached = reached or started >= restart_limit + + if effort_limited and not reached: + issues.append(Issue( + "effort_budget_mismatch", + "effort_limit_reached is true but no configured counted-work boundary was reached", + )) + + +def _check_result_facts(result: dict, issues: list[Issue]) -> None: + def code(name: str) -> str: + fact = result[name] + if not isinstance(fact, dict): + raise MalformedResult(f"{name} must be an object") + value = fact.get("code") + if not isinstance(value, str) or not value: + raise MalformedResult(f"{name}.code must be a non-empty string") + return value + + algorithm = result["algorithm"] + if not isinstance(algorithm, dict): + raise MalformedResult("algorithm must be an object") + timed_out = algorithm.get("time_limit_reached") + if not isinstance(timed_out, bool): + raise MalformedResult("algorithm.time_limit_reached must be a boolean") + effort_limited = algorithm.get("effort_limit_reached") + if not isinstance(effort_limited, bool): + raise MalformedResult("algorithm.effort_limit_reached must be a boolean") + if timed_out and effort_limited: + issues.append(Issue( + "result_fact_mismatch", + "time_limit_reached and effort_limit_reached cannot both be true", + )) + + termination = result["termination"] + if not isinstance(termination, dict): + raise MalformedResult("termination must be an object") + starts = termination.get("starts") + aggregate = None + if not isinstance(starts, list) or not starts: + issues.append(Issue( + "termination_aggregate_mismatch", + "termination.starts must contain at least one start", + )) + else: + malformed = False + for start in starts: + if not isinstance(start, dict) or not isinstance(start.get("id"), str): + malformed = True + continue + flags = ( + start.get("started"), + start.get("completed"), + start.get("truncated"), + start.get("selected"), + start.get("global_deadline_reached"), + ) + if not all(isinstance(flag, bool) for flag in flags): + malformed = True + continue + started, completed, truncated, _, _ = flags + if (completed and (not started or truncated)) or ( + truncated and (not started or completed) + ) or (not started and (completed or truncated)): + issues.append(Issue( + "start_record_mismatch", + f"{start['id']}: inconsistent lifecycle flags", + )) + if malformed: + issues.append(Issue( + "start_record_mismatch", + "a start record is missing its id or boolean lifecycle fields", + )) + selected = [ + start for start in starts + if isinstance(start, dict) and start.get("selected") is True + ] + if len(selected) != 1: + issues.append(Issue( + "termination_aggregate_mismatch", + f"expected one selected start, found {len(selected)}", + )) + elif all(isinstance(start, dict) for start in starts): + aggregate = { + "any_start_truncated": any( + start.get("truncated") is True for start in starts + ), + "all_required_starts_completed": all( + start.get("completed") is True for start in starts + ), + "winning_start_truncated": selected[0].get("truncated") is True, + "global_deadline_reached": any( + start.get("global_deadline_reached") is True for start in starts + ), + } + for name, expected in aggregate.items(): + if termination.get(name) is not expected: + issues.append(Issue( + "termination_aggregate_mismatch", + f"{name} is {termination.get(name)!r}, expected {expected!r}", + )) + solver = algorithm.get("solver") + selected_id = selected[0].get("id", "") + if isinstance(solver, str) and not ( + selected_id == solver or selected_id.startswith(f"{solver}#") + ): + issues.append(Issue( + "winning_start_mismatch", + f"selected start {selected_id!r} does not identify solver {solver!r}", + )) + aggregate_limited = ( + aggregate["global_deadline_reached"] + or aggregate["winning_start_truncated"] + ) + if aggregate["global_deadline_reached"] and not timed_out: + issues.append(Issue( + "legacy_time_limit_mismatch", + "global_deadline_reached requires time_limit_reached", + )) + if aggregate_limited != (timed_out or effort_limited): + issues.append(Issue( + "termination_limit_mismatch", + "selected-start truncation must identify exactly one reached limit", + )) + + status = result["status"] + complete = result["complete"] + expected_feasibility = ( + "infeasible" if status == "infeasible" + else "feasible" if complete and status != "invalid_result" + else "unknown" + ) + expected_termination = ( + "error" if status == "invalid_result" + else "time_limit" if timed_out + else "effort_limit" if effort_limited + else "complete" + ) + expected_optimality = ( + "proven_optimal" if status == "optimal" + else "proven_infeasible" if status == "infeasible" + else "best_found" if not complete and status != "invalid_result" + else "not_proven" + ) + for name, expected in ( + ("feasibility", expected_feasibility), + ("termination", expected_termination), + ("optimality", expected_optimality), + ): + actual = code(name) + if actual != expected: + issues.append(Issue( + "result_fact_mismatch", + f"{name}.code is {actual!r}, expected {expected!r}", + )) + + +#: Ratios in the objective vector are carried as parts per million — see docs/OBJECTIVE.md. +SCORE_SCALE = 1_000_000 + + +def _ratio_ppm(ratio: Fraction) -> int: + """A public ratio in parts per million exactly as every solver converts it. + + The solvers read a support, void-fill reserve or compression ratio as an IEEE double and take + ``floor(ratio * 10^6 + 0.5)`` in double arithmetic (docs/UNITS-AND-NUMERICS.md). That + differs from rounding the exact decimal on about one in a hundred seven-decimal halves + (``0.0001245`` is 124 ppm, not 125), so judging by the exact rational would refuse + placements, and reported reserves, every solver produces. Each step is a + correctly rounded IEEE operation, so the value is the same in every language. + """ + return int(float(ratio) * SCORE_SCALE + 0.5) + + +def _required_support_area(base_area: int, ppm: int) -> int: + """Exact ``floor(base_area * ppm / 10^6)``, the least supported area the ratio allows.""" + return base_area * ppm // SCORE_SCALE + + +def _dimensional_weight_ticks(inner: tuple[int, int, int], divisor: int, length_unit: str, weight_unit: str) -> int: + """The independent counterpart of `packvium.geometry.dimensional_weight` / + `Dimensions::dimensionalWeight`: same exact-integer formula, floor + division, computed here from the container's ticks alone so a mismatched divisor + or unit in any engine's own computation cannot hide behind agreement with itself.""" + length_ticks_per_unit = LENGTH_TICKS[length_unit] + weight_ticks_per_unit = WEIGHT_TICKS[weight_unit] + numerator = (inner[0] * inner[1] * inner[2]) * weight_ticks_per_unit + denominator = (length_ticks_per_unit ** 3) * divisor + return numerator // denominator + + +def objective( + result: dict, + containers: dict[str, ContainerSpec], + items: dict[str, ItemSpec], + clearance: int, + name: str = "default", + dimensional_weight_divisor: int | None = None, + dimensional_weight_length_unit: str = "in", + dimensional_weight_weight_unit: str = "lb", + fabricated: dict[str, ContainerSpec] | None = None, +) -> list[int]: + """Recompute the objective vector from the result alone. + + Derived here from first principles so the *formula* can be checked against every + implementation, independently of which solution that implementation happened to + choose. Two implementations may legitimately return different packings; neither may + misreport the score of the packing it did return. + """ + cost = unused = height = billable = achieved_height = landed = 0 + for packed in result["containers"]: + # A made-to-measure container is scored as the box recomputed for it. + spec = (fabricated or {}).get(packed.get("id")) or containers.get(packed["container_type"]) + if spec is None: + continue + cost += spec.cost_minor + volume = spec.inner[0] * spec.inner[1] * spec.inner[2] + if not volume: + continue + # Independently checked by `_check_container`, including the exact overlap + # deduction for nesting. Re-summing physical item volumes here would + # deliberately disagree with that contract for every valid nested column. + used = int(packed["used_volume_ticks3"]) + unused += (volume - used) * SCORE_SCALE // volume + # The height key is measured on envelope tops, so the clearance margin counts. + top = max((p["position"]["z"]["ticks"] + p["dimensions"]["height"]["ticks"] + clearance + for p in packed["placements"]), default=0) + height += top * SCORE_SCALE // spec.inner[2] + # The raw achieved height in ticks, not a ratio against the + # container's own (possibly loose, "open dimension") declared height. + achieved_height += top + if name in ("shipping_cost", "lowest_landed_cost") and dimensional_weight_divisor is not None: + gross = int(packed.get("gross_weight", {}).get("ticks", 0)) + # A flat bag is billed on the package it conformed to. + billed_size = (_achieved_extent(packed, clearance) if spec.flat_bag + else spec.outer if spec.outer is not None else spec.inner) + dimensional = _dimensional_weight_ticks( + billed_size, dimensional_weight_divisor, + dimensional_weight_length_unit, dimensional_weight_weight_unit, + ) + billed = max(gross, dimensional) + billable += billed + if name == "lowest_landed_cost": + landed += _landed_charge_minor(spec, billed) + common = [len(result["unpacked_items"]), len(result["containers"]), cost, unused, height] + if name == "default": + return common + if name == "lowest_cost": + return [common[0], common[2], common[1], common[3], common[4]] + if name == "shipping_cost": + if dimensional_weight_divisor is None: + raise MalformedResult("shipping_cost objective requires configuration.dimensional_weight_divisor") + return [common[0], billable, common[1], common[3], common[4]] + if name == "lowest_landed_cost": + if dimensional_weight_divisor is None: + raise MalformedResult( + "lowest_landed_cost objective requires configuration.dimensional_weight_divisor" + ) + return [common[0], landed, common[1], common[3], common[4]] + if name == "open_dimension_height": + return [common[0], achieved_height, common[1], common[2], common[3]] + if name == "maximum_value": + # Ranks by the total declared value of unpacked items ahead of + # container count, cost and unused volume -- unpacked_count still leads, + # this only tie-breaks among solutions leaving the same number of items + # unpacked but not the same value forgone. + value_forgone = sum( + (items[unpacked["item_type"]].value or 0) + for unpacked in result["unpacked_items"] + if unpacked["item_type"] in items + ) + return [common[0], value_forgone, common[1], common[2], common[3]] + if name == "maximum_volume": + # The share of the requested item volume left unpacked, rounded up so an + # incomplete answer never scores zero, leads; the piece count follows. + def nominal(spec: "ItemSpec") -> int: + length, width, height = spec.dimensions + return length * width * height + + unpacked_volume = sum( + nominal(items[unpacked["item_type"]]) + for unpacked in result["unpacked_items"] + if unpacked["item_type"] in items + ) + requested = sum(nominal(spec) * spec.quantity for spec in items.values()) + unpacked_ppm = -(-unpacked_volume * SCORE_SCALE // requested) if requested else 0 + return [unpacked_ppm, common[0], common[1], common[2], common[3]] + raise MalformedResult(f"unknown requested objective {name!r}") + + +def _landed_charge_minor(spec: "ContainerSpec", billed_weight_ticks: int) -> int: + """Re-derive one container's carrier charge from the request's own rate card. + + Deliberately a second implementation rather than a shared one: this validator exists to + disagree with the engines when they are wrong, and importing their arithmetic would make + the check tautological. Rounding matches the published contract -- billed weight up to + whole grams because a carrier reads a scale that way, and the fuel surcharge up because a + fractional unit of revenue is not forgiven. + """ + if spec.rate_table is None: + raise MalformedResult( + f"container {spec.id!r} has no rate_table, so lowest_landed_cost cannot price it" + ) + brackets = [int(bound) for bound in spec.rate_table["weight_brackets_g"]] + prices = [int(price) for price in spec.rate_table["prices_minor"]] + grams = -(-billed_weight_ticks // WEIGHT_TICKS["g"]) + for bound, price in zip(brackets, prices): + if grams <= bound: + base = max(price, int(spec.rate_table.get("minimum_charge_minor", 0))) + permille = int(spec.rate_table.get("fuel_surcharge_permille", 0)) + return base + -(-base * permille // 1000) + raise MalformedResult( + f"container {spec.id!r} bills at {grams} g, above its rate table's last bracket " + f"({brackets[-1]} g); the shipment has no published price" + ) + + +def _check_objective(request: dict, result: dict, containers: dict[str, ContainerSpec], + items: dict[str, ItemSpec], clearance: int, issues: list[Issue], + fabricated: dict[str, ContainerSpec] | None = None) -> None: + configuration = request.get("configuration", {}) + selected = configuration.get("objective", "default") + reported_name = result.get("objective") + if ("objective" in configuration or reported_name is not None) and reported_name != selected: + issues.append(Issue( + "objective_mismatch", + f"reported {reported_name!r}, requested {selected!r}", + )) + reported = [int(component) for component in result["score"]] + recomputed = objective( + result, containers, items, clearance, selected, + dimensional_weight_divisor=configuration.get("dimensional_weight_divisor"), + dimensional_weight_length_unit=configuration.get("dimensional_weight_length_unit", "in"), + dimensional_weight_weight_unit=configuration.get("dimensional_weight_weight_unit", "lb"), + fabricated=fabricated, + ) + if reported != recomputed: + issues.append(Issue("score_mismatch", f"reported {reported}, recomputed {recomputed}")) + + +def _check_solver_selection(request: dict, result: dict, issues: list[Issue]) -> None: + selected = request.get("configuration", {}).get("solvers", []) + if not selected: + return + reported = result["algorithm"].get("solver") + base = reported.split(":", 1)[0] if isinstance(reported, str) else reported + if base not in selected: + issues.append(Issue( + "solver_selection_mismatch", + f"reported {reported!r}, requested one of {selected!r}", + )) + + +def _check_groups(request: dict, result: dict, issues: list[Issue]) -> None: + """A group travels together or not at all. + + Members must share one container, and a group must not be half-packed: leaving one + member behind splits it just as surely as putting it in a second container. + """ + groups = {raw["id"]: raw["group"] for raw in request["items"] if raw.get("group") is not None} + if not groups: + return + located: dict[str, set[str]] = {} + packed: dict[str, int] = {} + for container in result["containers"]: + for placement in container["placements"]: + group = groups.get(placement["item_type"]) + if group is None: + continue + located.setdefault(group, set()).add(container["id"]) + packed[group] = packed.get(group, 0) + 1 + expected: dict[str, int] = {} + for raw in request["items"]: + group = raw.get("group") + if group is not None: + expected[group] = expected.get(group, 0) + raw.get("quantity", 1) + for group in sorted(located): + where = located[group] + if len(where) > 1: + issues.append(Issue("group_split", f"{group}: {', '.join(sorted(where))}")) + if packed.get(group, 0) != expected[group]: + issues.append(Issue("group_partially_packed", + f"{group}: {packed.get(group, 0)} of {expected[group]} placed")) diff --git a/src/packvium/revisions.py b/src/packvium/revisions.py index c399fd9..6f5cf48 100644 --- a/src/packvium/revisions.py +++ b/src/packvium/revisions.py @@ -283,7 +283,8 @@ def _validate_placement(event: Mapping[str, Any]) -> None: _require_name(placement.get("item_type"), "placement.item_type") _require_name(placement.get("container_type"), "placement.container_type") if placement.get("orientation") not in ORIENTATIONS: - raise PlanRevisionError("invalid_event", "placement.orientation is one of the six codes") + raise PlanRevisionError("invalid_event", + f"placement.orientation must be one of {json_spelling(list(ORIENTATIONS))}") instance = json_integer(placement.get("container_instance", 1)) if instance is None or instance < 1: raise PlanRevisionError("invalid_event", "placement.container_instance counts from 1") diff --git a/src/packvium/serialization.py b/src/packvium/serialization.py index 71bfff4..d073879 100644 --- a/src/packvium/serialization.py +++ b/src/packvium/serialization.py @@ -14,10 +14,11 @@ from ._canonical_json import json_integer from .extensions import ExtensionRegistry from .fixed_placements import require_fixed_placement_shapes +from ._request_error import ObjectRefusal from .request_errors import InvalidRequestError, check_request from .packer import Packer from .policy import PolicyRuleSet -from .units import Length, Weight +from .units import Length, Weight, output_length_unit def _axles(raw: list | None, unit: str) -> tuple[Axle, Axle] | None: @@ -300,7 +301,7 @@ def pack_from_dict(data: dict, *, result = Packer(config, extensions).pack(items, containers, fixed) result = replace(result, catalog_versions_used=references) output = data.get("output", {}) - return result.to_dict(output.get("length_unit", unit), output.get("weight_unit", "g")) + return result.to_dict(output.get("length_unit", output_length_unit(unit)), output.get("weight_unit", "g")) def _without_null_optionals(data: Mapping[str, Any]) -> dict: @@ -341,6 +342,10 @@ def _as_request_error() -> Iterator[None]: """ try: yield + except ObjectRefusal as error: + # A model's own refusal names a reason for the object API; the JSON rule table did not + # name this value, and every engine reports that as `invalid_value` with no field. + raise InvalidRequestError("invalid_value", "", error.detail) from error except (ValueError, TypeError, KeyError, ArithmeticError) as error: if isinstance(error, InvalidRequestError) or hasattr(error, "code"): raise diff --git a/src/packvium/solvers.py b/src/packvium/solvers.py index f228318..3c2d252 100644 --- a/src/packvium/solvers.py +++ b/src/packvium/solvers.py @@ -16,12 +16,12 @@ # `_grams` rather than a local ceil-div: the round-up from ticks to whole grams is the # published billing rule, and a second copy of it could drift a bracket. from .extensions import UNPRICEABLE_MINOR, _grams -from .constraints import (AxleLoadConstraint, CompatibilityConstraint, ConstraintContext, +from .constraints import (AxleLoadConstraint, CompatibilityConstraint, ConstraintContext, ConstraintResult, ContainerEligibilityConstraint, FloorConstraint, LoadUnit, PlacementConstraint, RIDES_THE_WHOLE_ROUTE, RouteOrderConstraint, StopAccessibilityConstraint, SupportConstraint, TagCountConstraint, TopLoadConstraint, active_constraints, direct_support_view, - load_units, top_loads, usable_volume) + is_position_independent, load_units, top_loads, usable_volume) from . import bounds, hull, made_to_measure from .geometry import (AxisAlignedBox, Dimensions, Point, Rotation, ShapeType, dimensional_weight) @@ -219,12 +219,13 @@ class ContainerState: __slots__ = ("container", "sequence", "placements", "points", "ordered_points", "payload_ticks", "used_volume_ticks", "stack_sensitive", "route_sensitive", "compression_sensitive", "max_x", "max_y", "max_z", "occupied", "bounds", "index", "hull_shapes", - "lattice_summary", "lattice_items") + "lattice_summary", "lattice_items", "_placed") def __init__(self, container: Container, sequence: int): self.container = container self.sequence = sequence self.placements: list[Placement] = [] + self._placed: tuple[Placement, ...] | None = None self.payload_ticks = 0 self.used_volume_ticks = 0 self.stack_sensitive = False @@ -263,6 +264,9 @@ def copy(self) -> "ContainerState": other.container = self.container other.sequence = self.sequence other.placements = list(self.placements) + # The same placements, so the same tuple: a copy that adds nothing keeps serving + # the constraint caches keyed on it. + other._placed = self._placed other.points = dict(self.points) other.ordered_points = list(self.ordered_points) other.payload_ticks = self.payload_ticks @@ -279,6 +283,18 @@ def copy(self) -> "ContainerState": other.lattice_items = self.lattice_items return other + @property + def frozen_placements(self) -> tuple[Placement, ...]: + """`placements` as a tuple that keeps its identity until the next placement. + + The support and corridor caches in `constraints.py` key on the identity of the + placement tuple they are handed. A fresh `tuple()` per candidate search made every + search a miss, and the maximal-space solver searches once per free space. + """ + if self._placed is None: + self._placed = tuple(self.placements) + return self._placed + @property def placement_count(self) -> int: return self.lattice_summary.count if self.lattice_summary is not None else len(self.placements) @@ -311,6 +327,7 @@ def add(self, placement: Placement) -> None: else: self.used_volume_ticks += used_volume_delta(self.placements, placement) self.placements.append(placement) + self._placed = None self.payload_ticks += placement.instance.weight.ticks item = placement.instance.item self.stack_sensitive = self.stack_sensitive or is_stack_sensitive(item) @@ -359,6 +376,7 @@ def add_direct(self, placement: Placement) -> None: else: self.used_volume_ticks += used_volume_delta(self.placements, placement) self.placements.append(placement) + self._placed = None self.payload_ticks += placement.instance.weight.ticks item = placement.instance.item self.stack_sensitive = self.stack_sensitive or is_stack_sensitive(item) @@ -512,6 +530,23 @@ def _axle_balanced_points(state: ContainerState, item: ItemInstance, forms: Sequ return points +def _append_unseen(points: Sequence[Point], extra: Sequence[Point]) -> list[Point]: + """``points`` then the ``extra`` points not already offered, keeping their order (S20). + + A repeated point is the same candidate scanned twice: it can change nothing but the + counted effort. O(k + a) for ``k`` offered and ``a`` extra points. + """ + fresh = {(point.x, point.y, point.z) for point in extra} + fresh.difference_update((point.x, point.y, point.z) for point in points) + appended = [*points] + for point in extra: + key = (point.x, point.y, point.z) + if key in fresh: + fresh.discard(key) + appended.append(point) + return appended + + def _nesting_points(state: ContainerState, item: ItemInstance) -> list[Point]: """Origins where ``item`` sinks into an existing identical footprint. @@ -532,6 +567,23 @@ def _nesting_points(state: ContainerState, item: ItemInstance) -> list[Point]: if z >= 0] +def _once_per_search(evaluate: Callable[[ConstraintContext], ConstraintResult]) -> Callable[[ConstraintContext], ConstraintResult]: + """`evaluate`, asked on the first call and answered from that verdict afterwards. + + Only for a rule `is_position_independent` admits: its verdict reads the item, the + container and the placements, which one candidate search holds fixed. + """ + verdict: ConstraintResult | None = None + + def cached(context: ConstraintContext) -> ConstraintResult: + nonlocal verdict + if verdict is None: + verdict = evaluate(context) + return verdict + + return cached + + def find_candidates(state: ContainerState, item: ItemInstance, config: PackingConfig, constraints: Sequence[PlacementConstraint], stats: SearchStats, deadline: Deadline, max_candidates: int | None = None, points: Sequence[Point] | None = None) -> list[Candidate]: container = state.container if container.max_items is not None and len(state.placements) >= container.max_items: return [] @@ -573,7 +625,7 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo if exact_hull else None) forms.append((rotation, physical, envelope, envelope.length.ticks, envelope.width.ticks, envelope.height.ticks, shape)) - placed = tuple(state.placements) + placed = state.frozen_placements stack_sensitive = (state.stack_sensitive or is_stack_sensitive(item.item) or container.max_stack_density is not None) @@ -589,14 +641,18 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo # Everything that decides whether a rule can fire is fixed for this call, so the chain # is pruned once here rather than answered "allow" once per position. The support rule # stays in regardless: `support_checks` counts every time the chain reaches it. - active = [(constraint, isinstance(constraint, SupportConstraint)) - for constraint in active_constraints(constraints, container, item, stack_sensitive, route_sensitive)] + chain = active_constraints(constraints, container, item, stack_sensitive, route_sensitive) + # A rule that reads no position answers the same at every (point, rotation) of this + # search, so it is asked once, by the first position that reaches it. + active = [(_once_per_search(constraint.evaluate) if is_position_independent(constraint) else constraint.evaluate, + isinstance(constraint, SupportConstraint), type(constraint).__name__) + for constraint in chain] # A built-in support rule that cannot apply to this item allows every position, so a # chain of nothing else would only count: each position adds one support check per # rule, and no context is built for it. Exact types, as in `active_constraints`. inert_chain = all(type(constraint) is SupportConstraint and not constraint.applies_to(item) - for constraint, _ in active) - inert_support_checks = len(active) + for constraint in chain) + inert_support_checks = len(chain) tracing = trace.active() if points is None: if item.item.nesting_height is None: @@ -606,7 +662,7 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo for point in (*state.ordered_points, *_nesting_points(state, item))} ordered = sorted(merged.values(), key=lambda point: (point.z, point.y, point.x))[:config.max_candidate_points] if container.axles is not None: - ordered = [*ordered, *_axle_balanced_points(state, item, forms, limit_x)] + ordered = _append_unseen(ordered, _axle_balanced_points(state, item, forms, limit_x)) else: ordered = sorted(points, key=lambda p: (p.z, p.y, p.x)) candidates: list[Candidate] = [] @@ -669,13 +725,13 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo else: context = ConstraintContext(container, placed, item, point, rotation, physical, envelope, stack_sensitive, route_sensitive) rejected = False - for constraint, counts_support in active: + for evaluate, counts_support, constraint_name in active: if counts_support: stats.support_checks += 1 - result = constraint.evaluate(context) + result = evaluate(context) if not result.allowed: if tracing: - trace.emit({"type": "placement_rejection", "item_id": item.id, "point": {"x": x1, "y": y1, "z": z1}, "rotation": rotation.value, "constraint": type(constraint).__name__, "code": result.code}) + trace.emit({"type": "placement_rejection", "item_id": item.id, "point": {"x": x1, "y": y1, "z": z1}, "rotation": rotation.value, "constraint": constraint_name, "code": result.code}) rejected = True break if rejected: continue @@ -1028,7 +1084,10 @@ def beam_pack(container: Container, sequence: int, items: Sequence[ItemInstance] expansions.extend((child, unplaced) for child in children) else: expansions.append((state, (*unplaced, *batch))) - expansions.sort(key=lambda node: _node_key(node, by_volume=by_volume)) + # A one-node beam has nothing to order, and its key builds an O(m) signature + # string -- once per batch on every width-1 greedy solve. + if len(expansions) > 1: + expansions.sort(key=lambda node: _node_key(node, by_volume=by_volume)) beam = expansions[:branch_width] if exhausted: state, unplaced = beam[0] @@ -1642,6 +1701,10 @@ def pack_one(self, container, sequence, items, config, stats, deadline): solution = self._pack_mode( container, sequence, items, config, stats, deadline, mode, lattices ) + if config.profile.value == "quality" and not deadline.expired: + solution = self._lookahead( + container, sequence, items, config, stats, deadline, mode, lattices, solution + ) reached = reached or solution.time_limit_reached by_volume = _ranks_by_volume(config) if best is None or self._solution_key(solution, by_volume) < self._solution_key(best, by_volume): @@ -1704,9 +1767,9 @@ def _solution_key(solution: SingleContainerSolution, by_volume: bool = False) -> return (len(solution.unpacked), -state.used_volume_ticks, state.max_z, signature) @staticmethod - def _better_block(best, mode, space, space_volume, item_id, rotation, physical, envelope, - nx, ny, nz, count): - """`best`, or this group's winning block when its key ranks strictly ahead.""" + def _block(mode, space, space_volume, item_id, rotation, physical, envelope, + nx, ny, nz, count): + """A group's winning block with the key the mode ranks it by, smaller first.""" used_volume = count * physical.volume space_fill_ppm = used_volume * 1_000_000 // space_volume if mode == "count": @@ -1715,8 +1778,6 @@ def _better_block(best, mode, space, space_volume, item_id, rotation, physical, lead = (-used_volume, -space_fill_ppm, -count) score = (*lead, space.origin.z, space.origin.y, space.origin.x, item_id, rotation.value, nx, ny, nz) - if best is not None and not score < best.score: - return best return _HomogeneousBlock(space, item_id, rotation, physical, envelope, nx, ny, nz, count, used_volume, score) @@ -1748,32 +1809,114 @@ def _pack_mode(self, container, sequence, items, config, stats, deadline, mode, for instance in items: remaining.setdefault(instance.item.id, []).append(instance) forms = {item_id: self._forms(available[0], config) for item_id, available in remaining.items()} - nodes = 0 + solution, _, reached = self._complete( + container, state, spaces, remaining, forms, config, stats, deadline, mode, + lattices, 0, + ) + return SingleContainerSolution( + solution.state, solution.unpacked, False, reached or deadline.expired + ) + + def _complete(self, container, state, spaces, remaining, forms, config, stats, deadline, + mode, lattices, nodes): + """Commit each round's leading block until nothing more fits; mutates its inputs. + + Returns the solution, the mode's node count after it, and whether a limit stopped + it (a stopped round commits nothing).""" reached = False while spaces and any(remaining.values()): - best, nodes, reached = self._next_block( + leading, nodes, reached = self._leading_blocks( container, state, spaces, remaining, forms, config, stats, deadline, mode, - lattices, nodes, + lattices, nodes, 1, ) - if best is None or reached: + if not leading or reached: break - self._commit(state, best, remaining, config, stats) - spaces = subtract_all(spaces, [best.box], stats) + self._commit(state, leading[0], remaining, config, stats) + spaces = subtract_all(spaces, [leading[0].box], stats) unpacked = tuple( instance for item_id in sorted(remaining) for instance in remaining[item_id] ) - return SingleContainerSolution(state, unpacked, False, reached or deadline.expired) + return SingleContainerSolution(state, unpacked, False, reached), nodes, reached + + def _lookahead(self, container, sequence, items, config, stats, deadline, mode, lattices, + greedy): + """`greedy`, or a better answer from completing each round's leading blocks. + + Each round ranks its `BLOCK_LOOKAHEAD_WIDTH` leading blocks, completes each one + greedily on a copy, and commits the block whose completion ranks best. The greedy + answer is the first completion, so the result is never worse than it. The round's + first block is the greedy choice after the block committed last, so its completion is + the one already known and is not recomputed -- unless the greedy pass was stopped by + `container_plan_node_limit`, when the first round completes it under the lookahead's + larger budget. The work is counted against its own + budget of `BLOCK_LOOKAHEAD_NODE_FACTOR * container_plan_node_limit` lattice nodes; + when that runs out the best completion so far stands, and only the deadline or the + effort budget marks the answer as stopped by a limit. + """ + budget = replace(config, container_plan_node_limit=( + config.container_plan_node_limit * BLOCK_LOOKAHEAD_NODE_FACTOR)) + by_volume = _ranks_by_volume(config) + state = ContainerState(container, sequence) + spaces = [Space(Point(0, 0, 0), container.inner_dimensions)] + remaining: dict[str, list[ItemInstance]] = {} + for instance in items: + remaining.setdefault(instance.item.id, []).append(instance) + forms = {item_id: self._forms(available[0], config) for item_id, available in remaining.items()} + best, best_key = greedy, self._solution_key(greedy, by_volume) + known: SingleContainerSolution | None = None if greedy.time_limit_reached else greedy + nodes = 0 + while spaces and any(remaining.values()): + leading, nodes, reached = self._leading_blocks( + container, state, spaces, remaining, forms, budget, stats, deadline, mode, + lattices, nodes, BLOCK_LOOKAHEAD_WIDTH, + ) + if not leading or reached: + break + chosen: tuple[tuple, SingleContainerSolution, _HomogeneousBlock] | None = ( + None if known is None else (self._solution_key(known, by_volume), known, leading[0]) + ) + for block in leading if known is None else leading[1:]: + trial, nodes, reached = self._completion( + container, state, spaces, remaining, forms, budget, stats, deadline, mode, + lattices, nodes, block, + ) + if reached: + break + trial_key = self._solution_key(trial, by_volume) + if chosen is None or trial_key < chosen[0]: + chosen = (trial_key, trial, block) + if reached or chosen is None: + break + chosen_key, known, block = chosen + if chosen_key < best_key: + best, best_key = known, chosen_key + self._commit(state, block, remaining, config, stats) + spaces = subtract_all(spaces, [block.box], stats) + return SingleContainerSolution( + best.state, best.unpacked, False, best.time_limit_reached or deadline.expired + ) + + def _completion(self, container, state, spaces, remaining, forms, config, stats, deadline, + mode, lattices, nodes, block): + """The greedy completion after committing `block`, on copies of the round's state.""" + trial_state = state.copy() + trial_remaining = {item_id: list(available) for item_id, available in remaining.items()} + self._commit(trial_state, block, trial_remaining, config, stats) + return self._complete( + container, trial_state, subtract_all(spaces, [block.box], stats), trial_remaining, + forms, config, stats, deadline, mode, lattices, nodes, + ) - def _next_block(self, container, state, spaces, remaining, forms, config, stats, deadline, - mode, lattices, nodes): - """One round's winning block, the mode's node count after it, and whether a limit - stopped the round (which then commits nothing).""" + def _leading_blocks(self, container, state, spaces, remaining, forms, config, stats, deadline, + mode, lattices, nodes, leaders): + """One round's `leaders` leading blocks, best first, the mode's node count after them, + and whether a limit stopped the round (which then commits nothing).""" node_limit = config.container_plan_node_limit node_ceiling = deadline._search_node_ceiling(stats) by_count = mode == "count" - best: _HomogeneousBlock | None = None + leading: list[_HomogeneousBlock] = [] # Capacity reads only the committed state, so it is fixed for the whole round. types = [] for item_id in sorted(remaining): @@ -1798,7 +1941,7 @@ def _next_block(self, container, state, spaces, remaining, forms, config, stats, # stopping. Only a wall-clock expiry inside a group is seen at the next # group instead, and any stop discards the whole round anyway. if nodes >= node_limit or deadline.expired: - return best, nodes, True + return leading, nodes, True key = (columns, rows, height // step_z, capacity) lattice = lattices.get(key) if lattice is None: @@ -1811,18 +1954,24 @@ def _next_block(self, container, state, spaces, remaining, forms, config, stats, budget = min(budget, node_ceiling - stats.search_nodes_expanded) if group_nodes > budget: stats.search_nodes_expanded += budget - return best, nodes + budget, True + return leading, nodes + budget, True nodes += group_nodes stats.search_nodes_expanded += group_nodes # The key's first field is `-count` or `-used_volume`; a group that - # loses on it loses on the key, so its key is never built. - if count and (best is None - or (-count if by_count else -count * volume) <= best.score[0]): - best = self._better_block( - best, mode, space, space_volume, item_id, rotation, physical, + # loses on it to the last kept block loses on the key, so its key is + # never built. + full = len(leading) == leaders + if count and (not full + or (-count if by_count else -count * volume) <= leading[-1].score[0]): + block = self._block( + mode, space, space_volume, item_id, rotation, physical, envelope, nx, ny, nz, count, ) - return best, nodes, False + if not full or block.score < leading[-1].score: + leading.append(block) + leading.sort(key=lambda kept: kept.score) + del leading[leaders:] + return leading, nodes, False def _commit(self, state, best, remaining, config, stats) -> None: """Place every member of the chosen block, bottom layer first.""" @@ -1944,7 +2093,6 @@ def dfs(index: int, state: ContainerState, reachable: int, state_volume: int) -> return stats.search_nodes_expanded += 1 state_count = len(state.placements) - best_count = len(best.placements) candidate_rank = state_rank(state, state_volume) if candidate_rank < best_rank: best = state @@ -2182,6 +2330,14 @@ def _start_config(order_name: str, config: PackingConfig) -> PackingConfig: return config +#: Leading blocks the `quality` block lookahead completes in each round. Four +#: improved 71 of 73 BR answers and worsened none at 12% more Python CPU; two improved 60 at +#: 6%, eight 72 at 24% (docs/PERFORMANCE-PRACTICE.md). +BLOCK_LOOKAHEAD_WIDTH = 4 +#: The lookahead's lattice-node budget per mode, as a multiple of `container_plan_node_limit`. +#: The largest BR7 instance measured needed 47 times the `quality` default. +BLOCK_LOOKAHEAD_NODE_FACTOR = 64 + #: Lattice nodes the block start added to `fast` and `balanced` may enumerate per mode -- #: the bound `quality` gives the same solver through `container_plan_node_limit`. BLOCK_START_NODE_LIMIT = 100_000 diff --git a/src/packvium/units.py b/src/packvium/units.py index 705182c..4051089 100644 --- a/src/packvium/units.py +++ b/src/packvium/units.py @@ -1,7 +1,8 @@ from __future__ import annotations from ._compat import dataclass -from decimal import Decimal +from ._request_error import refused, refused_type +from decimal import Decimal, InvalidOperation from enum import Enum from fractions import Fraction import re @@ -15,9 +16,12 @@ class Rounding(str, Enum): def _fraction(value: int | str | Decimal | Fraction) -> Fraction: + if isinstance(value, bool): + # `True` is an `int` to Python and `1` to arithmetic, but never a measure anyone wrote. + raise refused_type("a boolean is not a measure") if isinstance(value, float): if not value.is_integer(): - raise TypeError("float measure input is intentionally rejected; use a decimal string") + raise refused_type("float measure input is intentionally rejected; use a decimal string") value = int(value) if isinstance(value, Fraction): return value @@ -41,8 +45,19 @@ def _fraction(value: int | str | Decimal | Fraction) -> Fraction: # silently different accepted range between the two is exactly the kind of divergence # this library exists to avoid. Eighteen digits is far past what a tick can express. if decimal and decimal.group(1) and len(decimal.group(1)) > 18: - raise ValueError("decimal has more fractional digits than a tick can distinguish") - return Fraction(Decimal(text)) + raise refused("wrong_type", "decimal has more fractional digits than a tick can distinguish") + try: + return Fraction(Decimal(text)) + except InvalidOperation as error: + raise refused("wrong_type", f"invalid decimal or fraction: {text}") from error + + +def _non_negative(ticks: int, kind: str) -> int: + """A measure read from the caller's input; `__post_init__` still guards the arithmetic, + where a negative value is a defect rather than a caller's mistake.""" + if ticks < 0: + raise refused("negative_measure", f"{kind} cannot be negative") + return ticks def _rounded(value: Fraction, rounding: Rounding) -> int: @@ -63,6 +78,12 @@ def _rounded(value: Fraction, rounding: Rounding) -> int: return sign * (quotient if quotient % 2 == 0 else quotient + 1) +#: The unit codes the request schema names wherever it asks for a unit by itself (an output +#: unit, a dimensional-weight unit). A measure's own unit may also be spelt out ("inch"). +LENGTH_UNIT_CODES = ("mm", "cm", "m", "in", "ft") +WEIGHT_UNIT_CODES = ("mg", "g", "kg", "oz", "lb") + + @dataclass(frozen=True, order=True, slots=True) class Length: """Exact fixed-point length. One tick equals 1/16000 mm.""" @@ -92,8 +113,8 @@ def __post_init__(self) -> None: def of(cls, value: int | str | Decimal | Fraction, unit: str = "mm", rounding: Rounding = Rounding.NEAREST) -> "Length": key = unit.lower().strip() if key not in cls._MULTIPLIERS: - raise ValueError(f"unsupported length unit: {unit}") - return cls(_rounded(_fraction(value) * cls._MULTIPLIERS[key], rounding)) + raise refused("invalid_unit", f"unsupported length unit: {unit}") + return cls(_non_negative(_rounded(_fraction(value) * cls._MULTIPLIERS[key], rounding), "length")) @classmethod def mm(cls, value: Any, rounding: Rounding = Rounding.NEAREST) -> "Length": @@ -113,7 +134,7 @@ def parse(cls, value: Any, default_unit: str = "mm", rounding: Rounding = Roundi # A fraction in binary floating point is not the decimal the caller wrote; an # integral float is exact, and JavaScript cannot send anything else for `100`. if not value.is_integer(): - raise TypeError("float length input is intentionally rejected; use a decimal string") + raise refused_type("float length input is intentionally rejected; use a decimal string") value = int(value) if isinstance(value, str): match = re.fullmatch(r"(.+?)\s*(mm|cm|m|in|inch|inches|ft|ticks?)", value.strip(), re.I) @@ -164,8 +185,8 @@ def __post_init__(self) -> None: def of(cls, value: Any, unit: str = "g", rounding: Rounding = Rounding.NEAREST) -> "Weight": key = unit.lower().strip() if key not in cls._MULTIPLIERS: - raise ValueError(f"unsupported weight unit: {unit}") - return cls(_rounded(_fraction(value) * cls._MULTIPLIERS[key], rounding)) + raise refused("invalid_unit", f"unsupported weight unit: {unit}") + return cls(_non_negative(_rounded(_fraction(value) * cls._MULTIPLIERS[key], rounding), "weight")) @classmethod def parse(cls, value: Any, default_unit: str = "g", rounding: Rounding = Rounding.NEAREST) -> "Weight": @@ -177,7 +198,7 @@ def parse(cls, value: Any, default_unit: str = "g", rounding: Rounding = Roundin # A fraction in binary floating point is not the decimal the caller wrote; an # integral float is exact, and JavaScript cannot send anything else for `100`. if not value.is_integer(): - raise TypeError("float weight input is intentionally rejected; use a decimal string") + raise refused_type("float weight input is intentionally rejected; use a decimal string") value = int(value) if isinstance(value, str): match = re.fullmatch(r"(.+?)\s*(mg|kg|g|oz|lbs?|ticks?)", value.strip(), re.I) @@ -192,3 +213,13 @@ def decimal(self, unit: str = "g", places: int = 8) -> str: def to_dict(self, unit: str = "g") -> dict[str, int | str]: return {"ticks": self.ticks, "value": self.decimal(unit), "unit": unit} + + +def output_length_unit(request_unit: str) -> str: + """The output unit a request without `output.length_unit` is written in. + + The request's own `units.length`, spelt as the schema's output code: case and surrounding + space are ignored and a spelt-out name folds to its code (`"Inches"` is `in`). A unit no + output code names -- the internal `tick` -- gives `mm`, the default of both blocks.""" + multiplier = Length._MULTIPLIERS.get(request_unit.lower().strip()) + return next((code for code in LENGTH_UNIT_CODES if Length._MULTIPLIERS[code] == multiplier), "mm") diff --git a/tests/test_axle_load.py b/tests/test_axle_load.py index 44c9a9e..5d9b8ce 100644 --- a/tests/test_axle_load.py +++ b/tests/test_axle_load.py @@ -124,3 +124,33 @@ def test_a_weightless_item_is_admitted_exactly_when_the_tare_alone_is(): assert axle_admits_alone((Axle(Length(0), Weight(400)), Axle(Length(100), None)), 800, 100, 0, 10) assert not axle_admits_alone((Axle(Length(0), Weight(399)), Axle(Length(100), None)), 800, 100, 0, 10) + + +def test_axle_points_already_offered_are_not_offered_again(): + # S20: an axle point equal to an ordinary point, or to an earlier axle point, is the + # same candidate scanned twice; it is dropped and every other point keeps its place. + from packvium.solvers import _append_unseen + ordinary = [Point(0, 0, 0), Point(5, 0, 0), Point(0, 5, 0)] + axle = [Point(7, 0, 0), Point(5, 0, 0), Point(7, 5, 0), Point(7, 0, 0), Point(0, 5, 0)] + assert _append_unseen(ordinary, axle) == [*ordinary, Point(7, 0, 0), Point(7, 5, 0)] + + +def test_an_axle_balanced_point_seats_the_envelope_under_a_clearance(): + # S10: feasible centres are 300..305 mm, narrower than the 10 mm clearance, so only an + # envelope origin computed from the envelope's own length lands inside the window. + from packvium.serialization import pack_from_dict + for profile in ("fast", "balanced", "quality"): + result = pack_from_dict({ + "units": {"length": "mm"}, + "configuration": {"solver_profile": profile, "clearance": "10"}, + "items": [{"id": "drum", "allowed_rotations": ["LWH"], + "weight": {"value": "1000", "unit": "g"}, + "dimensions": {"length": "200", "width": "100", "height": "100"}}], + "containers": [{"id": "trailer", + "inner_dimensions": {"length": "1000", "width": "200", "height": "200"}, + "axles": [{"position": "0", "max_load": {"value": "700", "unit": "g"}}, + {"position": "1000", "max_load": {"value": "305", "unit": "g"}}]}], + }) + placements = [p for c in result["containers"] for p in c["placements"]] + assert len(placements) == 1, profile + assert 200 <= float(placements[0]["position"]["x"]["value"]) <= 205, profile diff --git a/tests/test_block_lattice.py b/tests/test_block_lattice.py index 7b2699f..1bbd3c9 100644 --- a/tests/test_block_lattice.py +++ b/tests/test_block_lattice.py @@ -20,13 +20,16 @@ class NodeByNodeBlockSolver(HomogeneousBlockSolver): - """The 1.6.0 round: every lattice node polls the deadline and is counted alone.""" + """The 1.6.0 round: every lattice node polls the deadline and is counted alone. - def _next_block(self, container, state, spaces, remaining, forms, config, stats, deadline, - mode, lattices, nodes): + It keeps every group winner and the `leaders` best of them, unpruned, so the + `quality` lookahead's rounds are checked as well as the greedy one.""" + + def _leading_blocks(self, container, state, spaces, remaining, forms, config, stats, + deadline, mode, lattices, nodes, leaders): node_limit = config.container_plan_node_limit reached = False - best: _HomogeneousBlock | None = None + winners: list[_HomogeneousBlock] = [] for space in spaces: space_volume = space.dimensions.volume for item_id in sorted(remaining): @@ -74,15 +77,15 @@ def _next_block(self, container, state, spaces, remaining, forms, config, stats, if reached: break if group_count: - best = self._better_block( - best, mode, space, space_volume, item_id, rotation, physical, + winners.append(self._block( + mode, space, space_volume, item_id, rotation, physical, envelope, group_nx, group_ny, group_nz, group_count, - ) + )) if reached: break if reached: break - return best, nodes, reached + return sorted(winners, key=lambda block: block.score)[:leaders], nodes, reached def node_by_node_lattice(columns: int, rows: int, layers: int, capacity: int): @@ -191,3 +194,35 @@ def test_a_full_memo_only_recomputes(monkeypatch): monkeypatch.setattr(solvers, "_LATTICE_MEMO_LIMIT", 0) assert outcome(HomogeneousBlockSolver(), box, items, config, unlimited) == remembered == \ outcome(NodeByNodeBlockSolver(), box, items, config, unlimited) + + +def key(solution): + return HomogeneousBlockSolver._solution_key(solution) + + +@pytest.mark.parametrize("seed", range(24)) +def test_the_quality_lookahead_never_ranks_below_the_greedy_pass(seed): + box, items = scene(seed) + greedy = PackingConfig.balanced(container_plan_node_limit=100_000) + looked = HomogeneousBlockSolver().pack_one(box, 1, items, PackingConfig.quality(), SearchStats(), + Deadline(600_000)) + passed = HomogeneousBlockSolver().pack_one(box, 1, items, greedy, SearchStats(), Deadline(600_000)) + assert key(looked) <= key(passed) + + +def test_a_spent_lookahead_budget_keeps_the_best_completion_and_is_no_limit_stop(monkeypatch): + from packvium import solvers + + # Scene 0 at a 5 000-node limit: the greedy pass finishes inside it, the lookahead does + # not finish inside the same 5 000, and finishes inside 64 times that. + box, items = scene(0) + config = PackingConfig.quality(container_plan_node_limit=5_000) + full = HomogeneousBlockSolver().pack_one(box, 1, items, config, SearchStats(), Deadline(600_000)) + monkeypatch.setattr(solvers, "BLOCK_LOOKAHEAD_NODE_FACTOR", 1) + spent = HomogeneousBlockSolver().pack_one(box, 1, items, config, SearchStats(), Deadline(600_000)) + greedy = HomogeneousBlockSolver().pack_one( + box, 1, items, PackingConfig.balanced(container_plan_node_limit=5_000), SearchStats(), + Deadline(600_000)) + assert not greedy.time_limit_reached + assert key(full) < key(spent) < key(greedy) + assert not spent.time_limit_reached diff --git a/tests/test_constraints.py b/tests/test_constraints.py index daec13a..815e478 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -210,13 +210,10 @@ def test_nested_support_replacement_restores_canonical_index_order(): assert graph.children(2) == (0, 1) -def test_a_nesting_predecessor_takes_its_place_in_the_surface_order(): - """A face seen before the predecessor keeps its position, it does not move behind it. - - `direct_support_view` promises placement-index order, and the replacement inserts - the predecessor rather than appending it. Ordering is what makes two engines agree - on which supporter a rejection names, so it is a contract, not a detail. - """ +def test_a_nesting_predecessor_is_the_only_supporter_beside_a_face_at_the_same_height(): + """A shelf whose top is level with the candidate's base but which lies beside it + carries nothing, so the immediate nesting predecessor is the one supporter. Since + the view no longer keeps such level faces as corner surfaces either.""" crate = Item.create("crate", Dimensions.mm(10, 10, 10), quantity=2, nesting_height=Length.mm(5), minimum_support_ratio=1.0) predecessor, candidate = crate.instances() @@ -226,7 +223,6 @@ def test_a_nesting_predecessor_takes_its_place_in_the_surface_order(): support = direct_support_view(placements, candidate, resting.envelope_box) - assert [surface.origin.x for surface in support.surfaces] == [Length.mm(20).ticks, 0] assert [entry.index for entry in support.supporters] == [1] @@ -748,6 +744,15 @@ def test_a_stack_limit_met_exactly_is_not_exceeded(): assert stack_limit_exceeded(column) is None +def test_a_stack_taller_than_the_recursion_limit_is_counted_without_recursing(): + """1100 sheets one tick high: the audit's reproduction (S3) at the constraint level, + where a full solve of the same request takes many seconds. The old walk spent one + frame per level and raised RecursionError here.""" + column = tuple(unit(0, 0, level, 10, 10, 1, stack_limit=5_000, label=f"s{level}") for level in range(1_100)) + assert stacked_counts(column) == [1_099 - level for level in range(1_100)] + assert stack_limit_exceeded(column) is None + + def test_a_third_item_over_the_stack_limit_is_refused(): base = placed(instance_of("base", max_stacked_items=1)) middle = placed(instance_of("middle"), z=160_000) @@ -1060,6 +1065,23 @@ def test_covered_is_satisfied_when_supporters_cover_all_four_corners(): assert SupportConstraint(0.0).evaluate(resting).allowed +def test_covered_counts_only_boxes_the_candidate_actually_rests_on(): + """The audit's three-box case (S4): `a` carries the front half of the candidate, + `b` and `c` touch its two back corners with zero overlap area. Corners touched only + by a zero-area neighbour hang, so this is refused, as the validators refuse it.""" + mm = Length.mm(1).ticks + front, = Item.create("a", Dimensions.mm(10, 5, 10)).instances() + left, = Item.create("b", Dimensions.mm(10, 10, 10)).instances() + right, = Item.create("c", Dimensions.mm(10, 10, 10)).instances() + candidate = instance_of("top", 10, 10, 10, ground_contact_rule="covered") + resting = context(candidate, x=10 * mm, z=10 * mm, placements=( + placed(front, x=10 * mm), placed(left, y=10 * mm), placed(right, x=20 * mm, y=10 * mm), + )) + result = SupportConstraint(0.0).evaluate(resting) + assert not result.allowed and result.code == "ground_contact_violation" + assert result.detail == "covered: 1 supporter(s), not all four corners touched" + + def test_single_rejects_a_candidate_split_across_two_supporters(): left, = Item.create("left", Dimensions.mm(50, 100, 10)).instances() right, = Item.create("right", Dimensions.mm(50, 100, 10)).instances() @@ -1384,6 +1406,19 @@ def test_the_blocked_item_and_its_blocker_are_both_named(): assert "base" in detail and "top" in detail +def test_the_first_blocker_named_is_the_lowest_index_not_the_first_reached(): + """`left` rests on `base` and carries `high`; `right` rests on `base` beside it. Both + `high` and `right` block `base`, and the walk reaches `high` first through `left`. + The detail names the lowest index so PHP and Python agree (S16).""" + scene = ( + unit(0, 0, 0, 20, 10, 10, label="base"), + unit(0, 0, 10, 10, 10, 10, label="left"), + unit(10, 0, 10, 10, 10, 10, label="right"), + unit(0, 0, 20, 10, 10, 10, label="high"), + ) + assert route_order_violated(scene, [0.0, 0.0, 1.0, 2.0]) == ("unloading_order_violation", "base blocked by right") + + # ------------------------------------------------- stop accessibility # # The worked examples in docs/STOP-ACCESSIBILITY.md were derived by hand and confirmed diff --git a/tests/test_fixed_placements.py b/tests/test_fixed_placements.py index aedc965..2d18928 100644 --- a/tests/test_fixed_placements.py +++ b/tests/test_fixed_placements.py @@ -260,7 +260,7 @@ def test_rebalancing_never_moves_a_fixed_item(): ([{"item_type": "cube", "container_type": "box"}], 'fixed_placements[0] needs ["orientation"]'), ([fixed(item_type="")], "fixed_placements[0].item_type is a non-empty string"), ([fixed(container_type=5)], "fixed_placements[0].container_type is a non-empty string"), - ([fixed(orientation="XYZ")], "fixed_placements[0].orientation is one of the six codes"), + ([fixed(orientation="XYZ")], 'fixed_placements[0].orientation must be one of ["LWH","LHW","WLH","WHL","HLW","HWL"]'), ([fixed(instance="1")], "fixed_placements[0].container_instance counts from 1"), ([fixed(instance=True)], "fixed_placements[0].container_instance counts from 1"), ([fixed(instance=1.5)], "fixed_placements[0].container_instance counts from 1"), diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 7ab8a85..ad29393 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -10,7 +10,8 @@ import pytest -from packvium import AxisAlignedBox, Dimensions, Length, Point, Rotation, Weight, dimensional_weight +from packvium import (AxisAlignedBox, Dimensions, InvalidRequestError, Length, Point, Rotation, Weight, + dimensional_weight) def box(x, y, z, length, width, height) -> AxisAlignedBox: @@ -25,6 +26,15 @@ def test_dimensions_must_be_positive(): Dimensions(Length(bad[0]), Length(bad[1]), Length(bad[2])) +def test_a_zero_side_read_from_input_is_a_request_refusal(): + for read in (lambda: Dimensions.mm(1, 0, 1), + lambda: Dimensions.from_dict({"length": "1", "width": "1", "height": "0"})): + with pytest.raises(InvalidRequestError) as caught: + read() + assert (caught.value.reason, caught.value.field) == ("invalid_value", "") + assert str(caught.value) == "all dimensions must be positive" + + def test_derived_measures(): dims = Dimensions.mm(2, 3, 5) assert dims.volume == 32_000 * 48_000 * 80_000 diff --git a/tests/test_models.py b/tests/test_models.py index 4ff3e45..5ff2578 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,8 +9,9 @@ import pytest -from packvium import (AxisAlignedBox, Axle, Container, Dimensions, FixedPlacement, Item, Length, Obstacle, - PackedContainer, PackingRequest, Placement, Point, Rotation, Weight) +from packvium import (AxisAlignedBox, Axle, Container, Dimensions, FixedPlacement, FixedPlacementError, Item, + Length, Obstacle, PackedContainer, Packer, PackingRequest, Placement, Point, Rotation, + Weight) # ---------------------------------------------------------------------------- item @@ -300,5 +301,9 @@ def test_expanding_a_container_packed_without_a_lattice_returns_its_own_placemen def test_a_fixed_placement_counts_container_instances_from_one(): - with pytest.raises(ValueError, match="counts from 1"): - FixedPlacement("a", "box", Point(0, 0, 0), Rotation.LWH, container_instance=0) + """Refused where the placement sits in a request, with the JSON path's refusal.""" + placement = FixedPlacement("a", "box", Point(0, 0, 0), Rotation.LWH, container_instance=0) + with pytest.raises(FixedPlacementError, match="counts from 1") as caught: + Packer().pack([Item.create("a", Dimensions.mm(10, 10, 10))], + [Container.create("box", Dimensions.mm(10, 10, 10))], [placement]) + assert (caught.value.reason, caught.value.field) == ("malformed", "/fixed_placements/0/container_instance") diff --git a/tests/test_packing_sequence.py b/tests/test_packing_sequence.py index 3638854..eff929a 100644 --- a/tests/test_packing_sequence.py +++ b/tests/test_packing_sequence.py @@ -445,6 +445,15 @@ def test_a_synthetic_loading_cycle_is_detected(): assert not graph.is_acyclic() +def test_a_dependency_chain_taller_than_the_recursion_limit_is_checked(): + """An 1100-sheet stack is an 1100-long chain; the old depth-first check recursed + once per level. A cycle behind a tail is still found.""" + chain = tuple(frozenset({level + 1}) for level in range(1_099)) + (frozenset(),) + assert UnloadingDependencyGraph(chain).is_acyclic() + assert LoadingDependencyGraph(chain).is_acyclic() + assert not UnloadingDependencyGraph((frozenset({1}), frozenset({2}), frozenset({1}))).is_acyclic() + + def test_an_unknown_direction_is_rejected_not_treated_as_minus_z(): """The concrete bug the audit reopened this over: an unrecognised direction string used to fall through to `-z` silently. Checked against both graphs and both the diff --git a/tests/test_request_errors.py b/tests/test_request_errors.py index f3f76d4..bad3fae 100644 --- a/tests/test_request_errors.py +++ b/tests/test_request_errors.py @@ -117,7 +117,9 @@ def test_a_request_that_is_not_an_object_is_refused_as_a_whole(): def test_what_the_rules_do_not_name_still_arrives_as_a_request_error(): - error = refusal(lambda d: d["items"][0].update(shape_type="blob")) + # A model rule the schema does not state: an item needs at least one rotation. (An unknown + # shape type was this case until made it a `not_allowed` rule.) + error = refusal(lambda d: d["items"][0].update(allowed_rotations=[])) assert (error.code, error.reason, error.field) == ("invalid_request", "invalid_value", "") @@ -216,3 +218,23 @@ def test_the_known_configuration_fields_are_the_schema_s(): configuration = schema["properties"]["configuration"] assert configuration["additionalProperties"] is False assert set(CONFIGURATION_FIELDS) == set(configuration["properties"]) + + +@pytest.mark.parametrize("request_unit, output_unit", [ + ("cm", "cm"), ("MM", "mm"), ("IN", "in"), ("Inches", "in"), ("millimeter", "mm"), ("tick", "mm"), +]) +def test_without_an_output_block_the_result_is_in_the_request_unit_spelt_as_its_code(request_unit, output_unit): + """The output default is always a unit the schema lists for output.""" + data = request() + data["units"] = {"length": request_unit} + result = pack_from_dict(data) + assert result["containers"][0]["inner_dimensions"]["length"]["unit"] == output_unit + + +@pytest.mark.parametrize("nesting, detail", [ + ("100", "must be less than the item's height"), + ({"value": "11", "unit": "cm"}, "must be less than the item's height"), +]) +def test_a_nesting_height_not_below_the_height_is_refused_at_its_pointer(nesting, detail): + error = refusal(lambda d: d["items"][0].update(nesting_height=nesting)) + assert (error.reason, error.field, error.detail) == ("invalid_value", "/items/0/nesting_height", detail) diff --git a/tests/test_request_objects.py b/tests/test_request_objects.py new file mode 100644 index 0000000..761ea4e --- /dev/null +++ b/tests/test_request_objects.py @@ -0,0 +1,191 @@ +"""A request built from objects is refused as the same request sent as JSON is. + +Each case is one request two ways: built from objects and passed to `Packer.pack`, and spelled +as JSON and passed to `pack_from_dict`. Both must raise `InvalidRequestError` with the same +reason. A value refused once the objects are in a request carries the JSON field too; one an +object's own constructor refuses has no position yet, so its field is empty.""" + +from __future__ import annotations + +from typing import Any, Callable + +import pytest + +from packvium import (Container, Dimensions, EffortBudget, InvalidRequestError, Item, Packer, + PackingConfig, pack_from_dict) +from packvium.flat_bag import FLAT_BAG +from packvium.geometry import Point, Rotation +from packvium.made_to_measure import MadeToMeasure, as_triple +from packvium.models import FixedPlacement, RateTable +from packvium.units import Length + +TOO_LARGE = 2**60 +Json = dict[str, Any] + + +def json_request(edit: Callable[[Json], None] = lambda request: None) -> Json: + request: Json = { + "items": [{"id": "cube", "dimensions": {"length": "100", "width": "100", "height": "100"}}], + "containers": [{"id": "box", "inner_dimensions": {"length": "200", "width": "100", "height": "200"}}], + "configuration": {"solver_profile": "fast"}, + } + edit(request) + return request + + +def cube(**fields: Any) -> Item: + return Item.create("cube", Dimensions.mm(100, 100, 100), **fields) + + +def box(**fields: Any) -> Container: + return Container.create("box", Dimensions.mm(200, 100, 200), **fields) + + +def packed(items: list[Item] | None = None, containers: list[Container] | None = None, + config: PackingConfig | None = None, + fixed_placements: tuple[FixedPlacement, ...] = ()) -> Callable[[], Any]: + return lambda: Packer(config or PackingConfig.fast()).pack(items or [cube()], containers or [box()], + fixed_placements) + + +def fixed(**fields: Any) -> tuple[FixedPlacement, ...]: + return (FixedPlacement(**{"item_id": "cube", "container_id": "box", "position": Point(0, 0, 0), + "rotation": Rotation.LWH, **fields}),) + + +def json_fixed(**fields: Any) -> Callable[[Json], None]: + placement = {"item_type": "cube", "container_type": "box", "container_instance": 1, + "position": {"x": "0", "y": "0", "z": "0"}, "orientation": "LWH", **fields} + return lambda r: r.update(fixed_placements=[placement]) + + +def json_refusal(edit: Callable[[Json], None]) -> InvalidRequestError: + with pytest.raises(InvalidRequestError) as caught: + pack_from_dict(json_request(edit)) + return caught.value + + +def object_refusal(build: Callable[[], Any]) -> InvalidRequestError: + with pytest.raises(InvalidRequestError) as caught: + build() + return caught.value + + +def machine(cost: int = 0) -> MadeToMeasure: + return MadeToMeasure(minimum=as_triple(Dimensions.mm(100, 100, 100)), + increment=as_triple(Dimensions.mm(50, 50, 50)), cost_per_square_metre_minor=cost) + + +JSON_MACHINE = {"minimum": {"length": "100", "width": "100", "height": "100"}, + "increment": {"length": "50", "width": "50", "height": "50"}} + +#: Refused once the objects form a request: same reason and same field as JSON. +IN_A_REQUEST = [ + ("quantity past 2^53 - 1", packed([cube(quantity=TOO_LARGE)]), + lambda r: r["items"][0].update(quantity=TOO_LARGE)), + ("repeated item id", packed([cube(), cube()]), + lambda r: r["items"].append(dict(r["items"][0]))), + ("repeated container id", packed(containers=[box(), box()]), + lambda r: r["containers"].append(dict(r["containers"][0]))), + ("empty stacking group", packed([cube(stacking_group="")]), + lambda r: r["items"][0].update(stacking_group="")), + ("max_stacked_items past 2^53 - 1", packed([cube(max_stacked_items=TOO_LARGE)]), + lambda r: r["items"][0].update(max_stacked_items=TOO_LARGE)), + ("container cost past 2^53 - 1", packed(containers=[box(cost_minor=TOO_LARGE)]), + lambda r: r["containers"][0].update(cost_minor=TOO_LARGE)), + ("tag limit past 2^53 - 1", packed(containers=[box(tag_limits={"fragile": TOO_LARGE})]), + lambda r: r["containers"][0].update(tag_limits={"fragile": TOO_LARGE})), + ("rate table price past 2^53 - 1", + packed(containers=[box(rate_table=RateTable((1000,), (TOO_LARGE,)))]), + lambda r: r["containers"][0].update(rate_table={"weight_brackets_g": [1000], "prices_minor": [TOO_LARGE]})), + ("time limit past 2^53 - 1", packed(config=PackingConfig.fast(time_limit_ms=TOO_LARGE)), + lambda r: r["configuration"].update(time_limit_ms=TOO_LARGE)), + ("effort limit past 2^53 - 1", + packed(config=PackingConfig.fast(effort_budget=EffortBudget(max_restarts=TOO_LARGE))), + lambda r: r["configuration"].update(effort_budget={"max_restarts": TOO_LARGE})), + ("made to measure beside outer dimensions", + packed(containers=[box(made_to_measure=machine(), outer_dimensions=Dimensions.mm(210, 110, 210))]), + lambda r: r["containers"][0].update(made_to_measure=JSON_MACHINE, + outer_dimensions={"length": "210", "width": "110", "height": "210"})), + ("made to measure priced past 2^53 - 1", packed(containers=[box(made_to_measure=machine(cost=10**18))]), + lambda r: r["containers"][0].update(made_to_measure={**JSON_MACHINE, "cost_per_square_metre_minor": 10**18})), + # The one bag exclusion `Container` itself does not refuse. + ("made to measure beside a flat bag", + packed(containers=[box(deformable_boundary=FLAT_BAG, made_to_measure=machine())]), + lambda r: r["containers"][0].update(deformable_boundary={"kind": FLAT_BAG}, made_to_measure=JSON_MACHINE)), + ("item id not a string", packed([Item.create(5, Dimensions.mm(100, 100, 100))]), + lambda r: r["items"][0].update(id=5)), + ("container id not a string", packed(containers=[Container.create(5, Dimensions.mm(200, 100, 200))]), + lambda r: r["containers"][0].update(id=5)), + ("fixed placement item id not a string", packed(fixed_placements=fixed(item_id=5)), json_fixed(item_type=5)), + ("fixed placement rotation not a Rotation", packed(fixed_placements=fixed(rotation="XYZ")), + json_fixed(orientation="XYZ")), + ("fixed placement instance zero", packed(fixed_placements=fixed(container_instance=0)), + json_fixed(container_instance=0)), + ("fixed placement position not a point", packed(fixed_placements=fixed(position=(0, 0, 0))), + json_fixed(position=[0, 0, 0])), + ("fixed placement coordinate not ticks", packed(fixed_placements=fixed(position=Point(1.5, 0, 0))), + json_fixed(position={"x": 1.5, "y": "0", "z": "0"})), +] + +#: Refused by an object's own constructor: same reason as JSON, and no field yet. +ON_ITS_OWN = [ + ("zero quantity", lambda: cube(quantity=0), lambda r: r["items"][0].update(quantity=0)), + ("negative side", lambda: Dimensions.mm("-1", 100, 100), + lambda r: r["items"][0]["dimensions"].update(length="-1")), + ("unknown unit", lambda: Length.of(1, "furlong"), lambda r: r.update(units={"length": "furlong"})), + ("boolean side", lambda: Dimensions.mm(True, 100, 100), + lambda r: r["items"][0]["dimensions"].update(length=True)), + ("support ratio above one", lambda: cube(minimum_support_ratio=2), + lambda r: r["items"][0].update(minimum_support_ratio=2)), + ("zero container quantity", lambda: box(quantity=0), lambda r: r["containers"][0].update(quantity=0)), + ("too few candidate points", lambda: PackingConfig.fast(max_candidate_points=8), + lambda r: r["configuration"].update(max_candidate_points=8)), + ("zero effort limit", lambda: EffortBudget(max_restarts=0), + lambda r: r["configuration"].update(effort_budget={"max_restarts": 0})), +] + + +@pytest.mark.parametrize("name, build, edit", IN_A_REQUEST, ids=[case[0] for case in IN_A_REQUEST]) +def test_a_request_of_objects_is_refused_with_the_json_reason_and_field(name: str, build, edit) -> None: + from_objects, from_json = object_refusal(build), json_refusal(edit) + assert (from_objects.code, from_objects.reason, from_objects.field) == ( + from_json.code, from_json.reason, from_json.field) + assert str(from_objects) == str(from_json) + + +@pytest.mark.parametrize("name, build, edit", ON_ITS_OWN, ids=[case[0] for case in ON_ITS_OWN]) +def test_an_object_refuses_with_the_json_reason_and_no_field(name: str, build, edit) -> None: + from_objects, from_json = object_refusal(build), json_refusal(edit) + assert (from_objects.code, from_objects.reason, from_objects.field) == ( + from_json.code, from_json.reason, "") + + +def test_a_quantity_past_the_exact_range_is_refused_before_any_solve() -> None: + """It used to be expanded into that many instances, which never finished.""" + error = object_refusal(packed([cube(quantity=TOO_LARGE)])) + assert error.field == "/items/0/quantity" + + +def test_accepted_stacking_groups_are_checked_in_code_point_order() -> None: + """A set has no order, so hash order must not pick which value an error names.""" + error = object_refusal(packed([cube(accepts_stacking_groups=frozenset({"pallet", "", "box"}))])) + assert (error.reason, error.field) == ("invalid_value", "/items/0/accepts_stacking_groups/0") + + +def test_a_mistyped_measure_is_still_a_type_error() -> None: + with pytest.raises(TypeError): + Length.mm(1.5) + + +def test_a_constructor_refusal_keeps_its_own_message() -> None: + """A wrapper that quotes the text (a commerce document) reads what it always read.""" + assert str(object_refusal(lambda: cube(quantity=0))) == "item quantity must be positive" + + +def test_the_json_path_still_reports_what_its_rules_do_not_name_as_invalid_value() -> None: + """A model rule the JSON table lacks is answered as every engine answers it.""" + error = json_refusal(lambda r: r["containers"][0].update( + rate_table={"weight_brackets_g": [5000, 1000], "prices_minor": [500, 900]})) + assert (error.reason, error.field) == ("invalid_value", "") + assert str(error) == "invalid_request: rate_table weight brackets must be strictly ascending" diff --git a/tests/test_request_objects_corpus.py b/tests/test_request_objects_corpus.py new file mode 100644 index 0000000..71ec9eb --- /dev/null +++ b/tests/test_request_objects_corpus.py @@ -0,0 +1,210 @@ +"""Every malformed request of the request-errors corpus is refused on the object path +with the refusal the JSON path gives. + +Each case of `conformance/request_errors/corpus.json` (written from the harness by +`scripts/generate_request_error_corpus.py`) is built as objects with the deserializer's own +builders, but without the JSON rules, and passed to `Packer.pack`. A refusal while an object is +built must carry the JSON path's exception type, code and reason with an empty field; a refusal +from `Packer.pack` must carry its field and message too (docs/ERRORS.md, "A request built from +objects"). A case that has no object form, or whose difference is kept on purpose, is listed +below with why; a listed case that starts matching fails, so it has to leave the list. +""" + +from __future__ import annotations + +import copy +import importlib.util +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +import pytest + +from packvium import InvalidRequestError, Packer, pack_from_dict +from packvium.geometry import Point, Rotation +from packvium.models import FixedPlacement +from packvium.serialization import (UnsupportedFeatureError, _config, _container, _item, + _point_ticks, _without_null_optionals) + +ROOT = Path(__file__).resolve().parents[2] +CORPUS = ROOT / "conformance" / "request_errors" / "corpus.json" +GENERATOR = ROOT / "scripts" / "generate_request_error_corpus.py" + +pytestmark = pytest.mark.skipif(not CORPUS.is_file(), reason="the corpus lives in the workspace only") + +_UNKNOWN_KEY = "an object has no place for a key its class does not declare" +_COERCED = "the deserializer coerces it, so the objects it builds are a different, valid request" +_CONSTRUCTOR_FIRST = ("two faults: a constructor refuses its own value as the object is built, " + "before Packer.pack walks the request in the JSON order") +_NEGATIVE_POSITION = ("a Point cannot hold a negative coordinate; the measure it is read with " + "refuses it as negative_measure, where the JSON path names the placement") + +_AS_LETTERS = "the deserializer reads text as the sequence of its letters, so the objects hold other values" +_OUTPUT = "the output units are arguments of PackingResult.to_dict, not part of the objects packed" + +#: Cases with no object form: the value cannot be held, or the deserializer changes it. +NOT_OBJECTS: dict[str, str] = { + "request-not-object": "a request that is not an object has no objects", + "items-missing": "Packer.pack takes the items as an argument", + "items-not-list": "Packer.pack takes the items as a sequence of Item", + "item-not-object": "an item that is not an object is not an Item", + "containers-missing": "Packer.pack takes the containers as an argument", + "configuration-not-object": "a configuration that is not an object is not a PackingConfig", + "effort-not-object": "an effort budget that is not an object is not an EffortBudget", + "container-made-to-measure-not-object": "a machine that is not an object is not a MadeToMeasure", + "container-deformable-boundary-not-object": "the object holds the bag's kind, not a block", + "container-deformable-boundary-kind-missing": "the object holds the bag's kind, not a block", + "item-id-missing": "Item requires an id", + "item-dimensions-missing": "Item requires dimensions", + "item-side-missing": "Dimensions requires all three sides", + "container-made-to-measure-minimum-missing": "MadeToMeasure requires a minimum", + "container-made-to-measure-increment-missing": "MadeToMeasure requires an increment", + "rate-brackets-not-list": "RateTable holds its brackets as a sequence", + "solver-profile-unknown": "SolverProfile has no such member", + "configuration-key-unknown-before-bad-value": "SolverProfile has no such member, and " + _UNKNOWN_KEY, + "configuration-key-unknown": _UNKNOWN_KEY, + "configuration-key-unknown-null": _UNKNOWN_KEY, + "configuration-key-unknown-escaped": _UNKNOWN_KEY, + "configuration-keys-unknown-first-in-code-point-order": _UNKNOWN_KEY, + "effort-key-unknown": _UNKNOWN_KEY, + "container-deformable-boundary-key-unknown": _UNKNOWN_KEY, + "container-made-to-measure-key-unknown": _UNKNOWN_KEY, + "unknown-configuration-key-before-its-values": _UNKNOWN_KEY, + "unsupported-before-units": "an unsupported field has no object form", + "unsupported-before-an-item": "an unsupported field has no object form", + "container-flat-bag-with-access-directions": "an empty tuple is how an object leaves the field out", + "item-id-number": _COERCED, + "item-quantity-text": _COERCED, + "item-accepts-stacking-groups-not-list": _COERCED, + "container-access-directions-not-list": _COERCED, + "fixed-position-unparsable": "a Point holds integer ticks, not text", + **{f"fixed_placements.position.{axis}={value}": _NEGATIVE_POSITION + for axis in ("x", "y", "z") for value in ('"-1"', "-1")}, + # A closed vocabulary the object holds as an enum member cannot hold a stranger. + "item-allowed-rotations-not-list": _AS_LETTERS, + "item-allowed-rotation-unknown": "Rotation has no such member", + "item-allowed-rotation-lowercase": "Rotation has no such member", + "item-shape-type-unknown": "ShapeType has no such member", + "fixed-orientation-unknown": "Rotation has no such member", + "configuration-solvers-not-list": _AS_LETTERS, + "output-not-object": _OUTPUT, + "output-length-unit-unknown": _OUTPUT, + "output-weight-unit-unknown": _OUTPUT, + "output-before-configuration": _OUTPUT, +} + +#: Cases the object path answers differently on purpose (docs/ERRORS.md). +DIFFERENT_ON_PURPOSE: dict[str, str] = { + "configuration-objective-unknown": "an unknown objective keeps UnknownObjectiveError", + "configuration-solver-unknown": "an unknown solver keeps UnknownSolverError", + "container-access-direction-unknown": "an unknown direction keeps InvalidDirectionError", + "item-accepts-stacking-groups-entry-empty": "a set's index counts in code-point order", + "an-item-id-before-its-quantity": _CONSTRUCTOR_FIRST, + "item-ids-before-containers": _CONSTRUCTOR_FIRST, + "container-ids-before-fixed-placements": _CONSTRUCTOR_FIRST, +} + +#: Pinned, so the skip set cannot grow without this line changing too. +SKIPPED = 57 + + +@dataclass(frozen=True) +class Refusal: + error: Optional[Exception] + stage: str + + def describe(self) -> str: + if self.error is None: + return f"{self.stage}: accepted" + fields = (getattr(self.error, "code", None), getattr(self.error, "reason", None), + getattr(self.error, "field", None), str(self.error)) + return f"{self.stage}: {type(self.error).__name__}{fields}" + + +def corpus() -> list[tuple[str, Any]]: + # Parametrisation reads the corpus at collection, before `pytestmark` skips the module. + if not CORPUS.is_file(): + return [] + return [(case["name"], case["request"]) for case in json.loads(CORPUS.read_text(encoding="utf-8"))["cases"]] + + +def json_refusal(request: Any) -> Exception: + with pytest.raises((InvalidRequestError, UnsupportedFeatureError)) as caught: + pack_from_dict(copy.deepcopy(request)) + return caught.value + + +def fixed_placement(raw: dict[str, Any], unit: str) -> FixedPlacement: + """`serialization._fixed_placements` without the JSON shape rules in front of it.""" + return FixedPlacement(item_id=raw["item_type"], container_id=raw["container_type"], + position=Point(*_point_ticks(raw.get("position", {}), unit)), + rotation=Rotation(raw["orientation"]), + container_instance=raw.get("container_instance", 1)) + + +def object_refusal(request: Any) -> Refusal: + try: + data = _without_null_optionals(copy.deepcopy(request)) + unit = (data.get("units") or {}).get("length", "mm") + config = _config(data.get("configuration") or {}, unit) + items = [_item(raw, unit) for raw in data["items"]] + containers = [_container(raw, unit) for raw in data["containers"]] + fixed = [fixed_placement(raw, unit) for raw in data.get("fixed_placements") or []] + except Exception as error: # what the objects could not be built from is the finding + return Refusal(error, "constructor") + try: + Packer(config).pack(items, containers, fixed) + except Exception as error: + return Refusal(error, "pack") + return Refusal(None, "pack") + + +def same_refusal(from_json: Exception, from_objects: Refusal) -> bool: + error = from_objects.error + if not isinstance(error, InvalidRequestError) or not isinstance(error, type(from_json)): + return False + if (error.code, error.reason) != (from_json.code, from_json.reason): + return False + if from_objects.stage == "constructor": + return error.field == "" + return (error.field, str(error)) == (from_json.field, str(from_json)) + + +def covered() -> list[tuple[str, Any]]: + return [(name, request) for name, request in corpus() if name not in NOT_OBJECTS | DIFFERENT_ON_PURPOSE] + + +def skipped() -> list[tuple[str, Any]]: + return [(name, request) for name, request in corpus() if name in NOT_OBJECTS | DIFFERENT_ON_PURPOSE] + + +def test_the_corpus_is_the_harness_corpus() -> None: + spec = importlib.util.spec_from_file_location("generate_request_error_corpus", GENERATOR) + assert spec is not None and spec.loader is not None + generator = importlib.util.module_from_spec(spec) + spec.loader.exec_module(generator) + assert CORPUS.read_text(encoding="utf-8") == generator.render(generator.build()), ( + "conformance/request_errors/corpus.json has drifted from the harness; run " + "scripts/generate_request_error_corpus.py --write") + + +def test_the_skip_set_is_pinned_and_names_only_corpus_cases() -> None: + names = {name for name, _ in corpus()} + assert set(NOT_OBJECTS).isdisjoint(DIFFERENT_ON_PURPOSE) + assert set(NOT_OBJECTS) | set(DIFFERENT_ON_PURPOSE) <= names + assert len(NOT_OBJECTS) + len(DIFFERENT_ON_PURPOSE) == SKIPPED + assert len(covered()) == len(names) - SKIPPED + + +@pytest.mark.parametrize("name, request_", covered(), ids=[name for name, _ in covered()]) +def test_the_object_path_refuses_as_the_json_path_does(name: str, request_: Any) -> None: + from_json, from_objects = json_refusal(request_), object_refusal(request_) + assert same_refusal(from_json, from_objects), ( + f"json: {Refusal(from_json, 'json').describe()}; objects: {from_objects.describe()}") + + +@pytest.mark.parametrize("name, request_", skipped(), ids=[name for name, _ in skipped()]) +def test_a_skipped_case_still_differs(name: str, request_: Any) -> None: + """A skip that starts matching has been fixed, and must leave its list.""" + assert not same_refusal(json_refusal(request_), object_refusal(request_)) diff --git a/tests/test_result_validation.py b/tests/test_result_validation.py new file mode 100644 index 0000000..70b9440 --- /dev/null +++ b/tests/test_result_validation.py @@ -0,0 +1,87 @@ +"""`packvium.validate(request, result)`, the reference validator, shipped in the package. + +The validator checks the engines, so its worth is that it shares no code with them. These tests +hold the module to importing nothing else from `packvium`, and pin the public entry point. +""" + +from __future__ import annotations + +import ast +import copy +from pathlib import Path + +import pytest + +import packvium +from packvium import MalformedResult, pack_from_dict, validate + +MODULE = Path(packvium.__file__).with_name("result_validation.py") + +REQUEST = { + "items": [{"id": "cube", "quantity": 2, "dimensions": {"length": "10", "width": "10", "height": "10"}}], + "containers": [{"id": "box", "inner_dimensions": {"length": "20", "width": "10", "height": "10"}}], +} + + +#: The Python-version dataclass shim every package model routes through; it holds no engine code. +ALLOWED = {"_compat"} + + +def test_the_validator_imports_nothing_from_the_engine() -> None: + tree = ast.parse(MODULE.read_text(encoding="utf-8")) + imported = [node for node in ast.walk(tree) if isinstance(node, (ast.Import, ast.ImportFrom))] + from_packvium = [ + ast.dump(node) for node in imported + if isinstance(node, ast.ImportFrom) + and (node.level > 0 or (node.module or "").startswith("packvium")) + and (node.module or "").removeprefix("packvium.") not in ALLOWED + or isinstance(node, ast.Import) and any(alias.name.startswith("packvium") for alias in node.names) + ] + assert from_packvium == [] + + +def test_a_result_the_engine_packed_is_valid() -> None: + assert validate(REQUEST, pack_from_dict(REQUEST)) == [] + + +def test_two_items_in_one_place_are_a_collision() -> None: + result = pack_from_dict(REQUEST) + placements = result["containers"][0]["placements"] + placements[1]["position"] = copy.deepcopy(placements[0]["position"]) + assert [issue.code for issue in validate(REQUEST, result)] == ["collision"] + + +def test_a_result_of_the_wrong_shape_is_refused_rather_than_judged() -> None: + with pytest.raises(MalformedResult): + validate(REQUEST, {"containers": "none"}) + + +def test_the_conformance_suite_runs_this_same_module() -> None: + import sys + + conformance = Path(__file__).resolve().parents[2] / "conformance" + if not (conformance / "validator.py").is_file(): + pytest.skip("the conformance suite lives in the workspace only") + sys.path.insert(0, str(conformance)) + try: + import validator + finally: + sys.path.remove(str(conformance)) + assert validator.validate is validate + + +def test_an_issue_reads_as_its_code_and_detail() -> None: + from packvium.result_validation import Issue + + assert str(Issue("collision", "a#1 with a#2")) == "collision: a#1 with a#2" + + +def test_the_corridor_replay_unloads_the_near_box_first_and_refuses_a_later_one_in_the_way() -> None: + """Two boxes in a row with the only door at x = 0: the near one must leave first.""" + from packvium.result_validation import Box, corridor_replay_succeeds + + boxes = {"near": Box(0, 0, 0, 10, 10, 10), "far": Box(10, 0, 0, 10, 10, 10)} + inner = (20, 10, 10) + assert corridor_replay_succeeds(boxes, {"near": 0, "far": 0}, inner, ("-x",)) + assert corridor_replay_succeeds(boxes, {"near": 0, "far": 1}, inner, ("-x",)) + assert not corridor_replay_succeeds(boxes, {"near": 1, "far": 0}, inner, ("-x",)) diff --git a/tests/test_result_validation_corpus.py b/tests/test_result_validation_corpus.py new file mode 100644 index 0000000..a301c2a --- /dev/null +++ b/tests/test_result_validation_corpus.py @@ -0,0 +1,60 @@ +"""The reference validator gives its recorded verdict on every pair of the shared corpus. + +`conformance/result_validation/corpus.json` (written by +`scripts/generate_result_validation_corpus.py`) is the subset of the cross-language +result-validation corpus that reaches every arc, issue code and refusal kind the full corpus +reaches in this module, each pair with the outcome `conformance/result_validation/run.py` +recorded for it. Reading the documents from their JSON text, as the harness does, this module +must answer each one the same way: the same issues (code and detail, in order), or the same +refusal. A change to a verdict is a change to the contract every port is held to, so the corpus +is regenerated with it, never edited by hand. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import pytest + +from packvium import MalformedResult, validate + +CORPUS = Path(__file__).resolve().parents[2] / "conformance" / "result_validation" / "corpus.json" + +pytestmark = pytest.mark.skipif(not CORPUS.is_file(), reason="the corpus lives in the workspace only") + + +def _corpus() -> list[dict[str, Any]]: + decoded = json.loads(CORPUS.read_text(encoding="utf-8")) + assert decoded["format"] == "packvium-result-validation-corpus/v1" + cases: list[dict[str, Any]] = decoded["cases"] + return cases + + +def _outcome(request_text: str, result_text: str) -> dict[str, Any]: + """The harness's `reference_outcome`, so both read the corpus the same way.""" + try: + request, result = json.loads(request_text), json.loads(result_text) + return {"issues": [{"code": issue.code, "detail": str(issue.detail)} for issue in validate(request, result)]} + except MalformedResult as refusal: + return {"refusal": {"kind": "contract", "message": str(refusal)}} + except Exception as failure: # noqa: BLE001 -- any other failure is the reference refusing + return {"refusal": {"kind": "shape", "message": f"{type(failure).__name__}: {failure}"}} + + +def test_the_corpus_reaches_every_issue_code_the_module_can_raise() -> None: + module = Path(validate.__code__.co_filename).read_text(encoding="utf-8") + raisable = set(re.findall(r'Issue\(\s*"([a-z_0-9]+)"', module)) + raised = {issue["code"] for case in _corpus() for issue in case["expected"].get("issues", ())} + assert raisable - raised == set() + + +def test_every_pair_gets_its_recorded_verdict() -> None: + disagreements = [ + (index, case["expected"], outcome) + for index, case in enumerate(_corpus()) + if (outcome := _outcome(case["request"], case["result"])) != case["expected"] + ] + assert disagreements == [] diff --git a/tests/test_solver_edges.py b/tests/test_solver_edges.py index db7e0e0..f391195 100644 --- a/tests/test_solver_edges.py +++ b/tests/test_solver_edges.py @@ -394,6 +394,13 @@ def blocks(box: Container, instances, deadline: Deadline | None = None, **config SearchStats(), deadline or generous()) +def greedy_blocks(box: Container, instances, **config): + """The block solver without `quality`'s lookahead: one greedy pass per mode.""" + config.setdefault("container_plan_node_limit", 100_000) + return HomogeneousBlockSolver().pack_one(box, 1, instances, PackingConfig.balanced(**config), + SearchStats(), generous()) + + def test_the_block_solver_places_nothing_once_the_deadline_has_passed(): instances = items_of([item("a", 50, 50, 50, quantity=2)]) solution = blocks(container("c", 100, 100, 100), instances, expired()) @@ -524,7 +531,9 @@ def _resting_share(placements, placement) -> float: def test_a_block_set_on_a_smaller_one_reports_the_support_it_really_has(): - solution = blocks(container("crate", 100, 100, 100, quantity=1), _a_base_and_three_tops()) + # The greedy pass sets the base on two tops; `quality`'s lookahead finds a layout with + # no overhang at all, so the greedy pass is the one that exercises the report. + solution = greedy_blocks(container("crate", 100, 100, 100, quantity=1), _a_base_and_three_tops()) base, = [p for p in solution.state.placements if p.instance.item.id == "base"] assert base.envelope_origin.z > 0 assert base.support_ratio == pytest.approx(5 / 6) @@ -532,6 +541,15 @@ def test_a_block_set_on_a_smaller_one_reports_the_support_it_really_has(): assert {p.support_ratio for p in solution.state.placements if p.envelope_origin.z == 0} == {1.0} +def test_the_quality_lookahead_finds_a_layout_with_nothing_overhanging(): + box = container("crate", 100, 100, 100, quantity=1) + greedy = greedy_blocks(box, _a_base_and_three_tops()) + looked = blocks(box, _a_base_and_three_tops()) + assert not looked.unpacked and not looked.time_limit_reached + assert {p.support_ratio for p in looked.state.placements} == {1.0} + assert looked.state.max_z < greedy.state.max_z + + def test_the_block_solver_leaves_a_request_that_asks_for_support_to_the_per_item_search(): solution = blocks(container("crate", 100, 100, 100, quantity=1), _a_base_and_three_tops(), minimum_support_ratio=1.0) diff --git a/tests/test_solvers.py b/tests/test_solvers.py index eb80891..1416410 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -499,6 +499,14 @@ def test_the_sequence_is_pinned_against_the_other_implementations(): assert DeterministicRandom(7).shuffled(list(range(6))) == [1, 2, 4, 3, 5, 0] +def test_a_negative_seed_shuffles_as_php_does(): + """Seed -7 reduces to its residue modulo 2^31 in both languages. The mirror of + this test lives in packvium-php/tests/SolverTest.php.""" + values = list(range(10)) + assert DeterministicRandom(-7).shuffled(values) == [5, 0, 3, 4, 8, 7, 6, 9, 2, 1] + assert DeterministicRandom(-7).shuffled(values) == DeterministicRandom(2**31 - 7).shuffled(values) + + def test_degenerate_bounds_are_answered_without_drawing(): rng = DeterministicRandom(3) assert rng.next_int(0) == 0