Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .DS_Store
Binary file not shown.
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 40 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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).
2 changes: 1 addition & 1 deletion docs/GUARANTEES.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ silently — if you need them, they belong in your own layer above this library.

## Status of this release

Version `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
Expand Down
42 changes: 39 additions & 3 deletions examples/basic.py
Original file line number Diff line number Diff line change
@@ -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()))
86 changes: 86 additions & 0 deletions examples/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Length,
Packer,
PackingConfig,
Rotation,
explain_unpacked_item,
)

Expand Down Expand Up @@ -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.
Loading
Loading