diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 0d95b94..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index bb1897f..c04cba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/) and thi adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the caveat that the public API is not frozen until `1.0.0`. Pin an exact version. +## [0.1.2] + +An additive documentation and integration release. No packing request/result field or +solver algorithm changed. + +### Added + +- **Runnable examples and guided capability maps.** Python, PHP and Node now ship worked + examples covering every objective and the major constraint, units, serialization, + nested-packing and commerce paths. Their printed answers are retained and checked on + every release, and each package executes its own examples in its test suite. +- **A versioned carrier-connector contract and reference implementation in the public + workspace.** Connectors prepare ordinary rate-table data before a deterministic solve; + no network call or carrier module enters a packing engine. The contract, offline replay + harness, registry and synthetic carrier are application components rather than new + packing-package API fields. + +### Changed + +- Every package README now links the whole Packvium family — Python, PHP, Rust, Node, + browser, PHP FFI bridge and Python native selector — and the PyPI, npm, Packagist and + crates.io manifests carry repository, homepage and keyword metadata. The PyPI page shows + the same README as GitHub and states the real Python floor, 3.9. + +### Fixed + +- Connector responses are revalidated at runtime and bound to the registered carrier and + requested service. Incomplete brackets, cross-currency price comparison and mutable + replay/value-object state are refused instead of silently producing a wrong price. +- Linux x86_64 and ARM64 evidence follows the declared suite version, preventing a + current gate from rewriting a previous release's receipt. +- PHP 7.4 artifact generation safely completes the locked downgrade tool's partial-write + case and still fails closed if its single retry does not finish. + ## [0.1.1] A patch over `0.1.0`. Every package is released together at the new version, including diff --git a/README.md b/README.md index 1547139..f590e70 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ Deterministic 3D cartonization and rectangular bin packing. Pure Python, **no runtime dependencies**, exact integer geometry. -> **Version 0.1.1 — early release.** The public API is not frozen; pin an exact version. -> Read [docs/GUARANTEES.md](docs/GUARANTEES.md) before relying on a result. +> **Version 0.1.2 — early release.** The public API is not frozen; pin an exact version. +> Read [docs/GUARANTEES.md](https://github.com/toxakara/packvium-python/blob/main/docs/GUARANTEES.md) before relying on a result. ```bash pip install packvium @@ -42,18 +42,28 @@ echo '{"items":[{"id":"box","quantity":8,"dimensions":{"length":"50","width":"50 ## Examples -Runnable, in [`examples/`](examples). Each one is a single file you can read top to bottom -and execute without a project around it. +Runnable, in [`examples/`](https://github.com/toxakara/packvium-python/tree/main/examples). Each one is a single file you can read top to bottom +and execute without a project around it. Every one of them is executed by the test suite +on each release, so none of them can quietly stop working. + +New here? Read `basic.py`, then `objectives.py` — between them they cover what most +callers need. `units.py` and `serialization.py` explain the two design choices that +surprise people. `extensions.py` is last on purpose: reach for it only after the fields +in `constraints.py` have failed you. | File | What it shows | | --- | --- | -| [`basic.py`](examples/basic.py) | The smallest useful call: items in, placements out. | -| [`constraints.py`](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. | -| [`nested.py`](examples/nested.py) | Units into cartons, cartons onto a pallet, in one call. | -| [`commerce.py`](examples/commerce.py) | Rate a shipment, apply an eligibility rule, and pin a catalog version. | +| [`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. | +| [`constraints.py`](https://github.com/toxakara/packvium-python/blob/main/examples/constraints.py) | Upright-only, floor-only, non-stackable, top-load limits, and tags that keep two items out of the same box — plus how to read the reason an item was refused. | +| [`units.py`](https://github.com/toxakara/packvium-python/blob/main/examples/units.py) | Why there are no floats anywhere: fractional inches, exact ticks, and the one-tick difference between a fit and a refusal. | +| [`serialization.py`](https://github.com/toxakara/packvium-python/blob/main/examples/serialization.py) | The same request as JSON, the result in full, and exactly which mistakes are refused and which are silently ignored. | +| [`nested.py`](https://github.com/toxakara/packvium-python/blob/main/examples/nested.py) | Units into cartons, cartons onto a pallet, in one call. | +| [`commerce.py`](https://github.com/toxakara/packvium-python/blob/main/examples/commerce.py) | Rate a shipment, apply an eligibility rule, and pin a catalog version. | +| [`extensions.py`](https://github.com/toxakara/packvium-python/blob/main/examples/extensions.py) | A rule the schema has no field for — and an honest account of what you give up by writing one. | ```bash -python3 examples/constraints.py +PYTHONPATH=src python3 examples/objectives.py ``` ## What it does @@ -77,25 +87,35 @@ python3 examples/constraints.py | Document | Covers | | --- | --- | -| [docs/GUARANTEES.md](docs/GUARANTEES.md) | What is promised and what is not. Start here. | -| [docs/PUBLIC-API.md](docs/PUBLIC-API.md) | Inputs, outputs and status semantics. | -| [docs/UNITS-AND-NUMERICS.md](docs/UNITS-AND-NUMERICS.md) | Units, accepted input forms, rounding policy. | +| [docs/GUARANTEES.md](https://github.com/toxakara/packvium-python/blob/main/docs/GUARANTEES.md) | What is promised and what is not. Start here. | +| [docs/PUBLIC-API.md](https://github.com/toxakara/packvium-python/blob/main/docs/PUBLIC-API.md) | Inputs, outputs and status semantics. | +| [docs/UNITS-AND-NUMERICS.md](https://github.com/toxakara/packvium-python/blob/main/docs/UNITS-AND-NUMERICS.md) | Units, accepted input forms, rounding policy. | ## Requirements -Python 3.10 or newer. No dependencies. +Python 3.9 or newer. No dependencies. + +## The Packvium family -## Other ports exist +One request and result contract, implemented independently in four engines (Rust, +Python, PHP, JavaScript) and held to identical placements on a shared fixture set. +Pick the package for your stack; mixing them in one system is safe. -The same request and result contract is implemented independently in PHP and Rust, and -all three are held to producing identical placements on a shared fixture set. If your -stack spans languages, you can compute a packing on any of them and get the same answer. +| Package | Install | Source | +| --- | --- | --- | +| Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) | +| PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) | +| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "0.1"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | +| 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) | +| Python native selector — `packvium-native` | from source until the native wheels ship | [packvium-python-adapter](https://github.com/toxakara/packvium-python-adapter) | ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md). Security reports go through the process in -[SECURITY.md](SECURITY.md), not public issues. +See [CONTRIBUTING.md](https://github.com/toxakara/packvium-python/blob/main/CONTRIBUTING.md). Security reports go through the process in +[SECURITY.md](https://github.com/toxakara/packvium-python/blob/main/SECURITY.md), not public issues. ## License -MIT. See [LICENSE](LICENSE). +MIT. See [LICENSE](https://github.com/toxakara/packvium-python/blob/main/LICENSE). diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index e0a8728..499404a 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -48,7 +48,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `0.1.1` is an early release. The public API is not yet frozen: field names, +Version `0.1.2` is an early release. The public API is not yet frozen: field names, status codes and the objective vector may change before `1.0.0`. Pin an exact version. The algorithm complexities documented in `ALGORITHMS-AND-COMPLEXITY.md` are design diff --git a/examples/basic.py b/examples/basic.py index cd3de46..6b531ab 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -1,7 +1,43 @@ +"""The smallest useful call: some items, some boxes, one answer. + +Run it: + + PYTHONPATH=src python3 examples/basic.py + +Three things are worth noticing in eight lines of code. + +`Dimensions.mm` and `Dimensions.inches` are both exact -- "4 in" is not converted to a +rounded number of millimetres, it is stored as an exact tick count, so an imperial spec +sheet and a metric container agree without a tolerance to tune (see units.py). + +`keep_upright` is a rule, not a hint. The mug will never be laid on its side, and if +that makes it not fit you are told which item failed and why, rather than getting a +plausible-looking arrangement that spills coffee. + +`cost_minor` is what the box costs *you*, in minor currency units. The default objective +ignores it -- it opens as few containers as possible and packs them tightly. Ranking by +packaging cost, by carrier-billed weight or by actual money is one setting away; that is +what objectives.py is for. +""" + from packvium import Container, Dimensions, Item, Packer, PackingConfig result = Packer(PackingConfig.balanced()).pack( - [Item.create("book", Dimensions.mm("210", "140", "30"), "450 g", quantity=4), Item.create("mug", Dimensions.inches("4", "4", "5"), "12 oz", quantity=2, keep_upright=True)], - [Container.create("box-m", Dimensions.mm("400", "300", "250"), max_payload="20 kg", cost_minor=180), Container.create("box-l", Dimensions.mm("500", "400", "350"), max_payload="30 kg", cost_minor=250)], + [ + Item.create("book", Dimensions.mm("210", "140", "30"), "450 g", quantity=4), + Item.create("mug", Dimensions.inches("4", "4", "5"), "12 oz", quantity=2, keep_upright=True), + ], + [ + Container.create("box-m", Dimensions.mm("400", "300", "250"), max_payload="20 kg", cost_minor=180), + Container.create("box-l", Dimensions.mm("500", "400", "350"), max_payload="30 kg", cost_minor=250), + ], ) -print(result.to_dict()) + +print("status ", result.status.value) +print("containers", [c.container.id for c in result.containers]) +print("packed ", sum(len(c.placements) for c in result.containers), "of 6") +print("unpacked ", [(u.instance.item.id, u.reason) for u in result.unpacked]) +print("score ", result.score, " <- lexicographic, exact integers, lower is better") +print() +print("the full result as a plain dict is what serialization.py explores:") +print(sorted(result.to_dict())) diff --git a/examples/constraints.py b/examples/constraints.py index e6ecfa6..ed13fa7 100644 --- a/examples/constraints.py +++ b/examples/constraints.py @@ -19,6 +19,7 @@ Length, Packer, PackingConfig, + Rotation, explain_unpacked_item, ) @@ -116,3 +117,88 @@ def millimetres(ticks: int) -> str: print(f" {unpacked.instance.item.id:12s} {explain_unpacked_item(unpacked)}") else: print("\neverything fitted -- widen the crate or add items to see a refusal explained") + + + + +# ------------------------------------------------------------- one rule at a time +# +# The four rules below are each shown twice: the same items, the same container, once +# without the rule and once with it. A constraint you cannot watch change the answer is +# one the reader has to take on faith, and the pair makes the rule -- rather than the +# geometry -- provably the reason. +# +# Note what "the rule bit" looks like. Only sometimes is it a refusal; more often the +# solver satisfies the rule by opening another container, which costs money and is the +# answer you actually wanted to see coming. So both numbers are printed. + +def compare(rule: str, without: list[Item], with_rule: list[Item], containers: list[Container]) -> None: + print(f"\n{rule}") + for label, variant in (("without the rule", without), ("with the rule ", with_rule)): + outcome = Packer(PackingConfig.balanced()).pack(variant, containers) + placements = sum(len(container.placements) for container in outcome.containers) + print( + f" {label}: {len(outcome.containers)} container(s), " + f"{placements} placed, {len(outcome.unpacked)} refused" + ) + for unpacked in outcome.unpacked: + print(f" {explain_unpacked_item(unpacked)}") + + +shelf = [Container.create("shelf", Dimensions.mm("800", "400", "500"), max_payload="40 kg")] + +# `allowed_rotations` narrows the six orientations to the ones you permit, and +# `Rotation.upright()` is the pair that keeps the item's own height vertical -- what you +# want for anything with a printed face or an open top. The pole is 700 mm tall and the +# shelf is 500 mm deep, so it fits only by being laid down, which is what this forbids. +pole = Dimensions.mm("90", "90", "700") +compare( + "allowed_rotations -- a pole that only fits lying down, forbidden from lying down", + [Item.create("pole", pole, "1 kg")], + [Item.create("pole", pole, "1 kg", allowed_rotations=Rotation.upright())], + shelf, +) + +# `max_stacked_items` caps how many units may sit above one item -- a pallet-pattern +# rule ("three high, no more"), not a weight limit. The column below is one tin wide, so +# height is the only way to fit more, and the second container is the price of the cap. +column = [Container.create("column", Dimensions.mm("160", "160", "600"), max_payload="40 kg")] +tin = Dimensions.mm("150", "150", "120") +compare( + "max_stacked_items -- five tins fit in one column; three-high needs two columns", + [Item.create("tin", tin, "800 g", quantity=5)], + [Item.create("tin", tin, "800 g", quantity=5, max_stacked_items=3)], + column, +) + +# `minimum_support_ratio` is how much of an item's base must rest on something solid. +# The plinth stands on the floor and covers a quarter of the ledge, and the ledge is too +# shallow for the slab to stand on edge -- so the only place the slab fits is perched on +# the plinth, on a quarter of its base. At 0.9 that is refused and a second ledge opens. +ledge = [Container.create("ledge", Dimensions.mm("400", "400", "350"), max_payload="40 kg")] +plinth = Item.create("plinth", Dimensions.mm("200", "200", "300"), "5 kg", must_be_on_floor=True) +slab = Dimensions.mm("400", "400", "60") +compare( + "minimum_support_ratio -- a slab perched on a quarter of its base", + [plinth, Item.create("slab", slab, "9 kg")], + [plinth, Item.create("slab", slab, "9 kg", minimum_support_ratio=0.9)], + ledge, +) + +# `group` is atomic: every member ships in one container or none of them does. The third +# part is deliberately too long for the shelf, so it takes the other two down with it +# rather than shipping two thirds of an assembly nobody can use. +parts = [ + Dimensions.mm("200", "200", "100"), + Dimensions.mm("200", "200", "100"), + Dimensions.mm("900", "100", "100"), +] +compare( + "group -- one member cannot be placed, so none of them is", + [Item.create(f"kit-{n}", d, "2 kg") for n, d in enumerate(parts, start=1)], + [Item.create(f"kit-{n}", d, "2 kg", group="assembly") for n, d in enumerate(parts, start=1)], + shelf, +) + +# Every reason code above is a fact about the request, not a solver failure -- which is +# why `explain_unpacked_item` can turn it into a sentence a customer is allowed to read. diff --git a/examples/extensions.py b/examples/extensions.py new file mode 100644 index 0000000..50424c8 --- /dev/null +++ b/examples/extensions.py @@ -0,0 +1,121 @@ +"""Extension points: rules the schema does not have a field for. + +Run it: + + PYTHONPATH=src python3 examples/extensions.py + +Most packing rules are already fields on `Item` and `Container` -- see constraints.py. +This example is about the rules that are not, and it is deliberately paired with an +honest warning about what you are giving up by using one. + +**An extension point is an in-process, one-language interface.** A custom constraint has +no representation on the wire, so an engine driven over JSON cannot see it, and the +cross-language conformance harness cannot check that four implementations agree about it. +Use these to specialise one application in one language. A rule that must hold for every +caller of every binding belongs in the request as data instead -- as a `policy` rule, a +tag, or a field. docs/EXTENDING.md carries the full reasoning. +""" + +from packvium import Container, Dimensions, Item, Length, Packer, PackingConfig +from packvium.constraints import ConstraintContext, ConstraintResult +from packvium.extensions import DefaultSolutionScorer, ExtensionRegistry + +# --------------------------------------------------------------------------------- +# A custom placement constraint. `max_top_load` caps what may rest on an item, and +# `must_be_on_floor` pins one to the bottom -- but neither says "nothing fragile above +# waist height, because that is where it gets knocked off a trolley". That is a real +# warehouse rule with no field, so it is a constraint. +# +# A constraint answers about *one candidate position*. It never searches; it is asked +# many thousands of times per solve, so keep it O(1) in the number of placements +# wherever you can. This one is: it looks at the candidate's z coordinate and nothing else. +# --------------------------------------------------------------------------------- +class FragileHeightLimit: + """Refuse to place a `fragile`-tagged item with its base above `limit`.""" + + def __init__(self, limit: Length, tag: str = "fragile") -> None: + self.limit = limit + self.tag = tag + + def evaluate(self, context: ConstraintContext) -> ConstraintResult: + if self.tag not in context.item.item.tags: + return ConstraintResult.allow() + # `Point` carries raw tick counts, not `Length` objects -- it is built once per + # candidate and this is the hot path. + if context.point.z <= self.limit.ticks: + return ConstraintResult.allow() + # The code is yours. It travels into the unpacked reason, so make it something a + # human reading a failed order will understand. + return ConstraintResult.reject( + "fragile_too_high", + f"base at {Length(context.point.z).decimal('mm')}mm is above the " + f"{self.limit.decimal('mm')}mm limit for {self.tag!r} items", + ) + + +# The footprint is deliberately only as wide as one crate, so the column has to grow +# upwards and the rule has something to refuse. A rule that never fires teaches nothing. +items = [ + Item.create("crate", Dimensions.mm("400", "400", "300"), "12 kg", quantity=3), + Item.create("vase", Dimensions.mm("400", "400", "200"), "2 kg", quantity=2, tags={"fragile"}), +] +containers = [Container.create("column", Dimensions.mm("400", "400", "1300"), max_payload="200 kg", quantity=1)] + +unrestricted = Packer(PackingConfig.balanced()).pack(items, containers) +highest_vase = max( + p.position.z for c in unrestricted.containers for p in c.placements if p.instance.item.id == "vase" +) +print("without the rule, the highest vase sits at", Length(highest_vase).decimal("mm"), "mm") + +restricted = Packer( + PackingConfig.balanced(), + ExtensionRegistry(placement_constraints=(FragileHeightLimit(Length.parse("400 mm")),)), +).pack(items, containers) + +placed_vases = [p for c in restricted.containers for p in c.placements if p.instance.item.id == "vase"] +print("with a 400mm limit, vases sit at", + sorted(Length(p.position.z).decimal("mm") for p in placed_vases), + "and", len(restricted.unpacked), "were left behind") +for unpacked in restricted.unpacked: + print(" left behind:", unpacked.instance.item.id, "->", unpacked.reason, unpacked.details) + +# --------------------------------------------------------------------------------- +# A custom solution scorer. The six built-in objectives rank by space, packaging cost, +# billed weight, landed money, stack height or value. None of them cares whether the +# weight is spread *evenly across the containers* -- which is what a two-person lift or +# a van's axle balance actually depends on, and is nowhere in the request. +# +# Return a tuple of exact integers, lower is better, compared lexicographically. Keep +# `unpacked_count` first unless you genuinely mean "leave items behind to score better", +# and fall back to the canonical vector for everything your rule does not care about -- +# otherwise two equally-balanced solutions are ranked by luck. +# --------------------------------------------------------------------------------- +class EvenlyLoadedContainers: + """Prefer solutions whose containers weigh about the same.""" + + def score(self, solution) -> tuple[int, ...]: + loads = [sum(p.instance.item.weight.ticks for p in c.placements) for c in solution.containers] + spread = max(loads) - min(loads) if loads else 0 + return (len(solution.unpacked), spread) + DefaultSolutionScorer().score(solution)[1:] + + +# Two heavy items and two light ones, two boxes, two slots each. Every arrangement uses +# the same volume, so the built-in objectives are indifferent -- and land on both anvils +# in one box. That is a 20kg box and a 2kg box. +lopsided_items = [ + Item.create("anvil", Dimensions.mm("200", "200", "200"), "10 kg", quantity=2), + Item.create("pillow", Dimensions.mm("200", "200", "200"), "1 kg", quantity=2), +] +two_boxes = [Container.create("box", Dimensions.mm("400", "200", "200"), max_payload="50 kg", quantity=2)] + +print() +for label, scorer in (("default", None), ("evenly loaded", EvenlyLoadedContainers())): + result = Packer(PackingConfig.balanced(), solution_scorer=scorer).pack(lopsided_items, two_boxes) + contents = [sorted(p.instance.item.id for p in c.placements) for c in result.containers] + weights = [c.payload_weight.decimal("kg") + " kg" for c in result.containers] + print(f"{label:>15}: {contents} -> {weights}") + +print() +print("Neither rule above exists on the wire. Hand the same request to the Rust or") +print("JavaScript engine and you get the unrestricted answer -- which is exactly why a") +print("rule everyone must obey belongs in the request as data, not in a class.") diff --git a/examples/nested.py b/examples/nested.py index c99bf8f..ee43ce6 100644 --- a/examples/nested.py +++ b/examples/nested.py @@ -54,7 +54,10 @@ cost_minor=180, ), ), - config=PackingConfig.balanced(), + # An example must not change answer merely because it is executed under a + # profiler or coverage tool. One deterministic start is sufficient to teach + # nested packing, and the generous wall-clock value remains only a safety fuse. + config=PackingConfig.fast(time_limit_ms=60_000), ), # Level 2: put those cartons on a pallet. The deck is the inner dimension and the # usable stack height is the rest. diff --git a/examples/objectives.py b/examples/objectives.py new file mode 100644 index 0000000..3ade749 --- /dev/null +++ b/examples/objectives.py @@ -0,0 +1,118 @@ +"""Objectives: six ways to be "best", and the scenes where they disagree. + +Run it: + + PYTHONPATH=src python3 examples/objectives.py + +Every solve returns the arrangement that scores best -- but "best" is a choice, and it is +the one setting most likely to make the library look wrong when it is merely answering a +different question than you meant to ask. This example builds scenes where two objectives +genuinely pick different containers, so the difference is visible rather than asserted. + +The score is always a lexicographic vector of exact integers, never a float, and its first +key is always `unpacked_count`: no objective will ever leave an item behind to save money. +Ratios are parts per million. See docs/OBJECTIVE.md for the full key ordering. +""" + +from packvium import Container, Dimensions, Item, Packer, PackingConfig +from packvium.models import RateTable, UnratedWeightError + +WIDGETS = [Item.create("widget", Dimensions.mm("100", "100", "100"), "500 g", quantity=8)] + + +def solve(config: PackingConfig, containers) -> tuple[str, tuple[int, ...]]: + result = Packer(config).pack(WIDGETS, containers) + return (result.containers[0].container.id if result.containers else "none", result.score) + + +# --------------------------------------------------------------------------------- +# `default` -- fewest containers, then tightest fit. The objective you want when the +# containers are interchangeable and you are simply trying not to open another box. +# --------------------------------------------------------------------------------- +snug = Container.create("snug", Dimensions.mm("300", "300", "300"), max_payload="20 kg", cost_minor=500) +roomy = Container.create("roomy", Dimensions.mm("400", "400", "400"), max_payload="20 kg", cost_minor=150) + +print("default ", solve(PackingConfig.balanced(), [snug, roomy])) + +# --------------------------------------------------------------------------------- +# `lowest_cost` -- the cheapest *packaging*. `cost_minor` is what the box itself costs +# you, so this is the objective for a warehouse buying cartons, not for a shipper paying +# a carrier. Here it prefers the roomy box precisely because the snug one costs more. +# --------------------------------------------------------------------------------- +print("lowest_cost ", solve(PackingConfig(objective="lowest_cost"), [snug, roomy])) + +# --------------------------------------------------------------------------------- +# `shipping_cost` -- carrier-billable *weight*. Billed weight is the greater of actual +# gross weight and dimensional weight, so a big light box can bill more than a small +# heavy one. That is the whole reason this objective is not the same as `lowest_cost`: +# the roomy box is cheaper to buy and dearer to ship. +# +# It needs a divisor. Without one the library refuses rather than guessing, because a +# wrong divisor silently misprices every shipment. +# --------------------------------------------------------------------------------- +by_weight = PackingConfig( + objective="shipping_cost", + dimensional_weight_divisor=5000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", +) +print("shipping_cost ", solve(by_weight, [snug, roomy])) + +# --------------------------------------------------------------------------------- +# `lowest_landed_cost` -- carrier-billable *money*. This is where a rate card enters the +# request as data. It exists because weight and money do not always agree: a bracket +# step, or a minimum charge, can make the cheaper shipment the heavier one. +# +# Below, the roomy box bills heavier (12,800 g dimensional against the snug box's 5,400) +# and yet costs less, because the snug box's carrier charges a steep first bracket. Rank +# by weight and you pick the wrong box; rank by money and you pick the right one. +# --------------------------------------------------------------------------------- +dear_per_gram = Container.create( + "snug", Dimensions.mm("300", "300", "300"), max_payload="20 kg", + rate_table=RateTable(weight_brackets_g=(6_000, 20_000), prices_minor=(2_400, 3_100)), +) +cheap_per_gram = Container.create( + "roomy", Dimensions.mm("400", "400", "400"), max_payload="20 kg", + rate_table=RateTable(weight_brackets_g=(6_000, 20_000), prices_minor=(900, 1_500)), +) +by_money = PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", +) +print("landed_cost ", solve(by_money, [dear_per_gram, cheap_per_gram])) + +# A rate card that stops short of the shipment is a refusal, never a silent clamp to the +# top bracket. You would otherwise be quoted a price the carrier never published. +too_narrow = Container.create( + "roomy", Dimensions.mm("400", "400", "400"), max_payload="20 kg", + rate_table=RateTable(weight_brackets_g=(2_000,), prices_minor=(900,)), +) +try: + solve(by_money, [too_narrow]) +except UnratedWeightError as refusal: + print("landed_cost* ", "refused:", refusal) + +# --------------------------------------------------------------------------------- +# `open_dimension_height` -- pack into the shortest stack. For a container with no lid, +# or a pallet whose height you are trying to keep under a doorway. +# --------------------------------------------------------------------------------- +print("open_dimension ", solve(PackingConfig(objective="open_dimension_height"), [snug, roomy])) + +# --------------------------------------------------------------------------------- +# `maximum_value` -- when not everything fits, leave the *cheap* things behind. Ranked by +# value forgone, after unpacked count. Note the honest limitation: this orders by value, +# it does not solve the knapsack problem to optimality. See docs/LIMITATIONS-AND-ROADMAP.md. +# --------------------------------------------------------------------------------- +# `quantity=1` is what makes this a choice at all: with an unlimited supply of boxes the +# packer simply opens a second one and nothing is left behind. +tiny = [Container.create("tiny", Dimensions.mm("200", "100", "100"), max_payload="20 kg", quantity=1)] +mixed = [ + Item.create("gold", Dimensions.mm("100", "100", "100"), "500 g", quantity=2, value=90_000), + Item.create("gravel", Dimensions.mm("100", "100", "100"), "500 g", quantity=2, value=10), +] +result = Packer(PackingConfig(objective="maximum_value")).pack(mixed, tiny) +kept = sorted(p.instance.item.id for c in result.containers for p in c.placements) +left = sorted(u.instance.item.id for u in result.unpacked) +print("maximum_value ", "packed:", kept, "left behind:", left) diff --git a/examples/serialization.py b/examples/serialization.py new file mode 100644 index 0000000..e30431b --- /dev/null +++ b/examples/serialization.py @@ -0,0 +1,152 @@ +"""Serialization: the same request as JSON, and what comes back. + +Run it: + + PYTHONPATH=src python3 examples/serialization.py + +Everything the library can do is reachable over one JSON document, and that is not a +convenience wrapper -- it is the contract four independent implementations are held to. +The Python, PHP, Rust and JavaScript engines read this exact shape and are checked +against each other on a shared fixture corpus, so a request you build here is a request +you can hand to any of them. + +Two consequences worth knowing: + +- lengths and weights travel as *decimal strings*, never as floats, so "12 3/8 in" + survives the trip intact (see units.py for why that matters); +- a field this engine has deliberately not implemented yet is refused by name, never + quietly ignored -- but a key the parser simply does not recognise *is* ignored. The + difference matters, and the last section shows both. +""" + +import json + +from packvium import pack_from_dict +from packvium.serialization import UNSUPPORTED_FIELDS, UnsupportedFeatureError, reject_unsupported + +# --------------------------------------------------------------------------------- +# A request is a plain dict. This one is the whole vocabulary in miniature: units, +# solver configuration, items with rules, and containers with a carrier rate card. +# --------------------------------------------------------------------------------- +request = { + "units": {"length": "mm"}, + "configuration": { + "objective": "lowest_landed_cost", + "dimensional_weight_divisor": 5000, + "dimensional_weight_length_unit": "cm", + "dimensional_weight_weight_unit": "kg", + "profile": "balanced", + "seed": 42, + "top_k": 2, + }, + "items": [ + { + "id": "book", + "quantity": 6, + "dimensions": {"length": "210", "width": "140", "height": "30"}, + "weight": "450 g", + }, + { + "id": "mug", + "quantity": 2, + "dimensions": {"length": "100", "width": "100", "height": "120"}, + "weight": "380 g", + "keep_upright": True, + "max_top_load": "1 kg", + }, + ], + "containers": [ + { + "id": "box-m", + "inner_dimensions": {"length": "400", "width": "300", "height": "250"}, + "max_payload": "20 kg", + "cost_minor": 180, + "rate_table": { + "weight_brackets_g": [5_000, 10_000, 30_000], + "prices_minor": [890, 1_240, 2_050], + "minimum_charge_minor": 650, + "fuel_surcharge_permille": 78, + }, + }, + ], +} + +result = pack_from_dict(request) + +# --------------------------------------------------------------------------------- +# The result is a plain dict too, and it is deliberately verbose: every placement has +# exact coordinates, every unplaced item has a structured reason, and the algorithm +# report says which solver won and what it spent getting there. +# --------------------------------------------------------------------------------- +print("status ", result["status"]) +print("score ", result["score"], " <- lexicographic, exact integers, cheapest first") +print("solver ", result["algorithm"]["solver"], "in", result["algorithm"]["duration_ms"], "ms") +print("containers ", [c["container_type"] for c in result["containers"]]) +print("placed ", sum(len(c["placements"]) for c in result["containers"])) +print("unplaced ", [(u["item_id"], u["reason"]) for u in (result.get("unpacked_items") or ())]) + +first = result["containers"][0]["placements"][0] +print() +print("one placement, in full:") +print(json.dumps(first, indent=2)[:400], "...") + +# --------------------------------------------------------------------------------- +# `top_k` asks for runners-up. They are real alternative arrangements, already scored +# and already validated -- useful when you want to show a human a choice rather than a +# verdict. +# --------------------------------------------------------------------------------- +# Two alternatives can share a score and still be different arrangements -- equal cost, +# different geometry. Compare their placements, not their scores, when showing a choice. +print() +print("alternatives:", len(result.get("alternatives") or ())) +for alternative in result.get("alternatives") or (): + positions = [(p["item_id"], p["position"]["x"]["value"]) for c in alternative["containers"] for p in c["placements"]] + print(" score", alternative["score"], "first two placements", positions[:2]) + +# --------------------------------------------------------------------------------- +# What is refused, and what is not. Worth knowing exactly, because the two look alike +# from the outside. +# +# A key the parser does not recognise is *ignored*. Misspell `keep_upright` and you get +# a silently unrotated mug, not an error -- the strictness lives in the request JSON +# Schema, which sets `additionalProperties: false` and ships with the project rather +# than with this package. Validate against it if you want typo protection; the library +# alone will not give you any. See docs/SERIALIZATION.md. +# --------------------------------------------------------------------------------- +print() +typo = json.loads(json.dumps(request)) +typo["items"][1]["keep_uprght"] = True +print("misspelled field:", pack_from_dict(typo)["status"], "-- accepted; the misspelling is invisible to the parser,") +print(" so `keep_upright` was never applied to the mug") + +# An unknown *value* where the engine has to choose a behaviour is a different matter. +# There is no sensible default for "rank by something I have never heard of". +bad_objective = json.loads(json.dumps(request)) +bad_objective["configuration"]["objective"] = "cheapest" +try: + pack_from_dict(bad_objective) +except ValueError as refusal: + print("unknown objective:", str(refusal)[:100]) + +# And a field this engine has named as not-yet-implemented is refused explicitly, so a +# request written for a newer engine fails loudly instead of being half-honoured. The +# list is empty right now, which is what "this engine is caught up" looks like. +print("fields this engine refuses by name:", + {scope: fields for scope, fields in UNSUPPORTED_FIELDS.items() if fields} or "none") +from_the_future = json.loads(json.dumps(request)) +from_the_future["items"][0]["shape_type"] = "convex_hull" +try: + reject_unsupported(from_the_future, {"item": ("shape_type",), "request": (), "configuration": (), "container": ()}) +except UnsupportedFeatureError as refusal: + print(" what it looks like when one is:", str(refusal)[:110]) + +# --------------------------------------------------------------------------------- +# The same document drives the command line, which reads a request on stdin and writes +# a result on stdout -- which is how the cross-language conformance harness talks to +# every engine, and how you would call this from a language with no binding yet: +# +# echo '' | python3 -m packvium +# +# --------------------------------------------------------------------------------- +print() +print("the CLI takes exactly the document above: echo '...' | python3 -m packvium") diff --git a/examples/units.py b/examples/units.py new file mode 100644 index 0000000..718ce4b --- /dev/null +++ b/examples/units.py @@ -0,0 +1,96 @@ +"""Units: why there are no floats anywhere, and what you can type. + +Run it: + + PYTHONPATH=src python3 examples/units.py + +Packing is arithmetic about physical space, and floating point is the wrong tool for it: +`0.1 + 0.2` is famously not `0.3`, and a box that "almost" fits either fits or does not. +So every length and weight in this library is an exact integer count of ticks -- +1/16000 mm for length, 1/8 microgram for weight -- and nothing in the request, the search +or the result is ever a float. + +That choice is invisible until it saves you. This example shows where it does. +""" + +from fractions import Fraction + +from packvium import Container, Dimensions, Item, Length, Packer, PackingConfig, Weight, dimensional_weight + +# --------------------------------------------------------------------------------- +# What you can type. Integers, decimals, fractions and mixed fractions, in mm, cm, m, +# in and ft -- because a spec sheet says "12 3/8 in" and retyping that as 12.375 is a +# transcription step where mistakes live. +# --------------------------------------------------------------------------------- +for text in ("30", "30.5 mm", "3/16 in", "12 3/8 in", "2 ft", "1.5 m"): + length = Length.parse(text) + print(f"{text:>12} -> {length.ticks:>12} ticks = {length.decimal('mm')} mm") + +print() +for text in ("450 g", "12 oz", "1.5 kg", "2 3/4 lb"): + weight = Weight.parse(text) + print(f"{text:>12} -> {weight.ticks:>14} ticks = {weight.decimal('g')} g") + +# --------------------------------------------------------------------------------- +# Common fractional inches are exact, not rounded. 1/16000 mm was chosen so that every +# binary fraction of an inch down to 1/128 lands on a whole number of ticks -- which is +# what makes an imperial spec survive a conversion to millimetres and back. +# --------------------------------------------------------------------------------- +print() +print("1 inch is", Length.TICKS_PER_INCH, "ticks, so 1/128 in is", Length.TICKS_PER_INCH // 128, "ticks exactly") +sixteenth = Length.parse("1/16 in") +print("1/16 in as a fraction of a mm:", sixteenth.as_fraction("mm"), "==", Fraction(sixteenth.ticks, Length.TICKS_PER_MM)) + +# 128 sixteenth-of-a-128th steps really do add back up to one inch: +print("128 x (1/128 in) == 1 in ?", + Length(Length.parse("1/128 in").ticks * 128) == Length.parse("1 in")) + +# And the honest other half: a *third* of an inch is not a binary fraction, so it does +# not land on a whole tick. The library rounds it, deterministically and visibly, rather +# than carrying an error that only shows up as a box that "almost" fits. +third = Length.parse("1/3 in") +print(" 3 x (1/3 in) == 1 in ?", Length(third.ticks * 3) == Length.parse("1 in"), + f"-- 1/3 in is {Length.TICKS_PER_INCH}/3 ticks, which is not an integer") + +# --------------------------------------------------------------------------------- +# Where floats would actually bite. The classic case, and the same three tenths handled +# as lengths instead. +# --------------------------------------------------------------------------------- +print() +print("0.1 + 0.2 == 0.3 in floats:", 0.1 + 0.2 == 0.3) +print("the same three tenths as lengths:", + Length.parse("0.1 mm").ticks + Length.parse("0.2 mm").ticks == Length.parse("0.3 mm").ticks) + +# --------------------------------------------------------------------------------- +# The same exactness decides whether something fits. A 100mm cube into a 100mm cube is a +# fit, not a coin toss -- and one tick over is a refusal, with no tolerance to tune. +# --------------------------------------------------------------------------------- +print() +opening = Dimensions.mm("100", "100", "100") +print("exactly 100mm fits: ", Dimensions.mm("100", "100", "100").fits_inside(opening)) +one_tick_over = Dimensions(Length(opening.length.ticks + 1), opening.width, opening.height) +print("one tick over does not: ", one_tick_over.fits_inside(opening)) + +# --------------------------------------------------------------------------------- +# Dimensional weight, the number carriers actually bill on. Volume divided by a divisor, +# rounded *up* -- never down, and never through a float. This is the same helper the +# `shipping_cost` and `lowest_landed_cost` objectives use, so a quote you compute here +# and a container the solver picks cannot disagree about what is being priced. +# --------------------------------------------------------------------------------- +print() +box = Dimensions.mm("400", "400", "400") +billed = dimensional_weight(box, divisor=5000, length_unit="cm", weight_unit="kg") +print("400mm cube = 64,000 cm^3 / 5,000 =", billed.decimal("kg"), "kg dimensional weight") + +# --------------------------------------------------------------------------------- +# And it survives a round trip through JSON, because the wire format carries the decimal +# string rather than a float. What you typed is what the other language reads. +# --------------------------------------------------------------------------------- +print() +print("on the wire:", Length.parse("12 3/8 in").to_dict(), Weight.parse("2 3/4 lb").to_dict()) + +result = Packer(PackingConfig.balanced()).pack( + [Item.create("shelf", Dimensions.inches("12 3/8", "9 1/2", "3/4"), "2 3/4 lb", quantity=3)], + [Container.create("carton", Dimensions.inches("13", "10", "4"), max_payload="20 lb")], +) +print("packed", sum(len(c.placements) for c in result.containers), "shelves,", len(result.unpacked), "left over") diff --git a/pyproject.toml b/pyproject.toml index 5bd7e2a..976585a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "packvium" -version = "0.1.1" +version = "0.1.2" description = "Deterministic, extensible 3D cartonization and rectangular bin-packing library" readme = "README.md" requires-python = ">=3.9" @@ -25,6 +25,13 @@ classifiers = [ "Typing :: Typed", ] +[project.urls] +Homepage = "https://github.com/toxakara/packvium-python" +Source = "https://github.com/toxakara/packvium-python" +Documentation = "https://github.com/toxakara/packvium-python/tree/main/docs" +Changelog = "https://github.com/toxakara/packvium-python/blob/main/CHANGELOG.md" +Issues = "https://github.com/toxakara/packvium-python/issues" + [project.scripts] packvium = "packvium.__main__:main" diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..f89d06a --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,120 @@ +"""Every shipped example actually runs. + +An example nobody executes is documentation that rots silently: the API moves, the +example keeps compiling in a reader's head, and the first person to paste it discovers +it stopped working three releases ago. So the whole `examples/` directory is a test +target -- each file is run as a real subprocess, exactly the way the README tells a +reader to run it. + +The checks beyond "exit 0" are deliberate. An example that prints nothing has nothing to +teach, and one whose docstring does not say how to run it makes the reader guess at the +PYTHONPATH. +""" + +from __future__ import annotations + +import ast +import os +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +PACKAGE = Path(__file__).resolve().parent.parent +EXAMPLES = PACKAGE / "examples" +EXAMPLE_FILES = sorted(EXAMPLES.glob("*.py")) + + +def example_ids() -> list[str]: + return [path.name for path in EXAMPLE_FILES] + + +def test_the_examples_directory_is_not_empty(): + """Guards the parametrization itself: a glob that matched nothing would make every + test below vacuously pass.""" + assert EXAMPLE_FILES, f"no examples found under {EXAMPLES}" + + +@pytest.fixture(scope="module") +def run_example(): + environment = {**os.environ, "PYTHONPATH": str(PACKAGE / "src"), "PYTHONIOENCODING": "utf-8"} + + def run(path: Path) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(path)], cwd=PACKAGE, env=environment, + capture_output=True, text=True, timeout=180, + ) + + return run + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=example_ids()) +def test_an_example_runs_to_completion(path, run_example): + finished = run_example(path) + assert finished.returncode == 0, f"{path.name} exited {finished.returncode}:\n{finished.stderr}" + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=example_ids()) +def test_an_example_prints_something(path, run_example): + assert run_example(path).stdout.strip(), f"{path.name} produced no output" + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=example_ids()) +def test_an_example_raises_nothing_it_did_not_mean_to(path, run_example): + """Several examples deliberately catch and print a refusal -- that is the lesson. + What must not appear is an *uncaught* one.""" + assert "Traceback (most recent call last)" not in run_example(path).stderr + + +#: `duration_ms` is a measurement of the run, not part of the answer, and it is the one +#: number in a result that legitimately differs between two identical solves. +TIMING = re.compile(r'("(?:duration_ms|elapsed_ms)":\s*)\d+|(\bin )\d+( ms\b)') + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=example_ids()) +def test_an_example_is_deterministic(path, run_example): + """This library promises the same answer for the same input, forever. An example + whose output moves between two runs is either demonstrating something it should not + be, or has found a determinism bug worth knowing about. + + Elapsed times are masked rather than asserted on -- they are the one part of a result + that is a fact about the machine instead of about the packing. + """ + first, second = run_example(path).stdout, run_example(path).stdout + assert TIMING.sub(r"\1\2\3", first) == TIMING.sub(r"\1\2\3", second) + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=example_ids()) +def test_an_example_says_how_to_run_it(path): + docstring = ast.get_docstring(ast.parse(path.read_text())) + assert docstring, f"{path.name} has no module docstring" + assert "Run it:" in docstring, f"{path.name} does not tell the reader how to run it" + assert f"examples/{path.name}" in docstring, f"{path.name}'s run instruction names another file" + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=example_ids()) +def test_an_example_imports_only_the_public_package(path): + """A reader copies an example verbatim. If it reaches into a private module, they + inherit a dependency on something that is free to change without notice.""" + tree = ast.parse(path.read_text()) + imported = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module) + # `__future__` is a language directive, not a private module. + private = [ + name for name in imported + if name.split(".")[-1].startswith("_") and not name.startswith("__") + ] + assert not private, f"{path.name} imports private module(s) {private}" + + +def test_the_readme_lists_every_example(): + """Examples that are not linked are examples nobody finds.""" + readme = (PACKAGE / "README.md").read_text() + missing = [path.name for path in EXAMPLE_FILES if path.name not in readme] + assert not missing, f"README.md does not mention {missing}"