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
423 changes: 42 additions & 381 deletions CHANGELOG.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ dependencies**, exact integer geometry.
Full documentation, the constraint reference and benchmarks live at
[packvium.com](https://packvium.com).

> **Version 1.2.0 — the public API is frozen.** Field names, status codes and the
> **Version 1.3.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.
Expand Down Expand Up @@ -67,6 +67,7 @@ in `constraints.py` have failed you.
| [`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. |
| [`execution.py`](https://github.com/toxakara/packvium-python/blob/main/examples/execution.py) | Turn a result into dock instructions: solver facts kept apart from screen text, a step order that is injected or honestly absent, and an operator lock that yields a second plan rather than editing the approved one. |
| [`artifacts.py`](https://github.com/toxakara/packvium-python/blob/main/examples/artifacts.py) | Hand a result to a system with no engine: one document with the plan, geometry and the request that produced it, exported as CSV and a printable work order, with an honest replay level. |
| [`intelligence.py`](https://github.com/toxakara/packvium-python/blob/main/examples/intelligence.py) | Prove a carton change is worth publishing: two scenarios compared order by order, a proposal that refuses to exist on thin evidence, and a replay against held-out history where a cheaper packing your validator rejects still counts as a regression. |
| [`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. |

Expand Down Expand Up @@ -95,6 +96,10 @@ PYTHONPATH=src python3 examples/objectives.py
order or an honest `unavailable`, and a canonical form four engines emit byte for byte.
`packvium.locks` lets an operator pin a placement and get a *second* plan beside the
approved one — never an edit of it, and never a placement the engine would refuse.
- **Artifacts a warehouse system can use without an engine.** `packvium.artifacts` wraps the
plan with geometry, display values and provenance, including the request itself and an
honest replay level. `packvium.artifact_exports` writes it as RFC 8785 JSON, CSV or a
self-contained HTML work order, byte for byte what the other engines write.
- **Decide before you publish.** `packvium.simulation` and `packvium.recommendations`
compare two catalog scenarios order by order and propose a change only when the paired
cohort supports it; `packvium.holdout` replays that proposal against history it has not
Expand Down
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 `1.2.0` freezes the public API. Field names, status codes, the objective
Version `1.3.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.
Expand Down
41 changes: 41 additions & 0 deletions docs/PUBLIC-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,47 @@ solve runs.
Python only, and deliberately so: a lock has no representation in the request schema, since
that field is held for the next contract freeze, so there is nothing to hand another engine.

Since 1.3.0 the canonical form is RFC 8785, the spelling the operational artifact uses. For
every plan an engine emits the bytes are the ones 1.2.0 wrote.

## Operational artifact

One portable document built from a validated result. It carries:
- the execution plan, unchanged;
- the geometry needed to draw it;
- the rendered values a work order prints;
- the provenance needed to replay it: the request itself, the catalogs, the deterministic part
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.

| Language | Builder | Exports |
| --- | --- | --- |
| Python | `packvium.artifacts.build_operational_artifact(request, result, loading_orders=...)`, `.canonical_artifact_json(artifact)` | `packvium.artifact_exports.export_json(artifact)`, `.export_csv(artifact)`, `.export_work_order_html(artifact)` |
| PHP | `Packvium\Artifacts\OperationalArtifact` | `Packvium\Artifacts\ArtifactExports` |
| Rust | `packvium_core::artifacts::build_artifact_json(request_json, result_json, loading_orders_json)` | `packvium_core::artifact_exports::export_json`, `export_csv`, `export_work_order_html` |
| JavaScript | `buildOperationalArtifact(request, result, {loadingOrders})`, `canonicalArtifactJson(artifact)` from `@packvium/engine`'s `artifacts.js` | `exportJson`, `exportCsv`, `exportWorkOrderHtml` from `artifact-exports.js` |

All four are held to **byte-identical** output on all three exports. The comparison covers
every golden result with its own request and a real loading order, plus edge cases no fixture
contains. The schema is closed.

**Placements are addressed by the plan's `placement_ref`, never by `item_id`**, in geometry, in
work-order lines, in CSV rows and in the visualizer. Work-order lines are the plan's steps,
looked up in the plan's order and never re-ordered.

**Replay is stated, not assumed.** `provenance.replay.level` is `exact` only when solving the
embedded request again must reproduce the result. When the search stopped on wall-clock time,
or the result does not record how it was solved, the level is `not_guaranteed`, and `because`
names the field that decided it.

**Refusals use one closed set of codes** in every language (`invalid_request`, `invalid_result`,
`invalid_plan_input`, `mixed_units`, `number_out_of_range`, `invalid_string`, `invalid_value`,
`unknown_format`, `invalid_json`). An export or reader handed a format it does not know refuses
it rather than guessing.

## Scenario and recommendation API

Scenario what-if comparison and catalog/rule recommendation publication, exported as
Expand Down
107 changes: 107 additions & 0 deletions examples/artifacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Hand a packing result to a warehouse system that has no engine.

Run it:

PYTHONPATH=src python3 examples/artifacts.py

An execution plan says what to lift first. A warehouse or transport system needs more than
that before it can act on its own: how big each box is, what the load weighs, a sheet to
print for the dock, and a record of which request and solver produced all of it. Without
those, it has to call the engine again.

The operational artifact is that one document. It wraps the plan unchanged and adds geometry,
display values and provenance. It exports to JSON, CSV and a printable HTML work order, and
none of that calls a solver, a renderer or a clock. Four engines build the same bytes from it.
"""

from __future__ import annotations

from packvium import AxisAlignedBox, Dimensions, Length, Point, pack_from_dict, safe_loading_order
from packvium.artifact_exports import export_csv, export_json, export_work_order_html
from packvium.artifacts import OperationalArtifactError, build_operational_artifact

REQUEST = {
"units": {"length": "mm"},
"configuration": {
# A fuse far above what this solve needs, so the answer never depends on machine load.
"time_limit_ms": 60_000,
},
"items": [
{"id": "printer", "dimensions": {"length": 420, "width": 300, "height": 250}, "weight": "12 kg",
"metadata": {"sales_order": "SO-1042"}},
{"id": "toner", "quantity": 3, "dimensions": {"length": 300, "width": 100, "height": 100},
"weight": "1.5 kg", "minimum_support_ratio": 0.5},
],
"containers": [{"id": "crate", "inner_dimensions": {"length": 800, "width": 400, "height": 400}}],
}

result = pack_from_dict(REQUEST)


def section(title: str) -> None:
print()
print("=" * 78)
print(title)
print("=" * 78)


# --------------------------------------------------------------------------------------
section("1. One document that carries everything a consumer needs")

def box(placement: dict) -> AxisAlignedBox:
position, size = placement["position"], placement["dimensions"]
return AxisAlignedBox(Point(*(position[axis]["ticks"] for axis in ("x", "y", "z"))),
Dimensions(*(Length(size[axis]["ticks"]) for axis in ("length", "width", "height"))))


crate = result["containers"][0]
inside = Dimensions(*(Length(crate["inner_dimensions"][axis]["ticks"]) for axis in ("length", "width", "height")))
# The order boxes can go in without lifting one over another comes from the engine's own
# sequence API. The artifact is handed that order; it never invents one.
order = safe_loading_order([box(placement) for placement in crate["placements"]], inside)
artifact = build_operational_artifact(REQUEST, result, loading_orders={0: order})
provenance = artifact["provenance"]
print(f" format: {artifact['format']}")
print(f" plan steps: {len(artifact['plan']['containers'][0]['steps'])}")
print(f" crate inside: {artifact['geometry']['containers'][0]['inner_dimensions']['length']} ticks long")
print(f" replay: {provenance['replay']['level']}")
print(f" sales order: {provenance['request']['items'][0]['metadata']['sales_order']}")
print("""
The request is inside the artifact, not a hash of it: a digest names a request,
and only the request itself lets someone replay the artifact without a lookup.
`replay` is `exact` because the search finished inside its fuse; a search the
clock stopped would say `not_guaranteed` and name the field that decided it.""")

# --------------------------------------------------------------------------------------
section("2. A CSV a warehouse system can import")

for row in export_csv(artifact).split("\r\n")[:3]:
print(f" {row}")
print("""
One row per step, in the plan's order, then one row per item that was not
packed. The tick columns are the identifiers a system matches on; the rendered
columns are for people. Nothing is re-rendered: every value is the result's own.""")

# --------------------------------------------------------------------------------------
section("3. A work order to print")

html = export_work_order_html(artifact)
print(f" {len(html.encode())} bytes of HTML, one section per container, one checkbox per step")
print(f" contains a script or an external resource: {'<script' in html or 'http' in html}")
print("""
Self-contained on purpose: a work order that loads a stylesheet from the network
is a work order that prints blank on a dock with no signal.""")

# --------------------------------------------------------------------------------------
section("4. The same bytes in every engine, and a refusal instead of a guess")

canonical = export_json(artifact)
print(f" canonical JSON: {len(canonical.encode())} bytes, starting {canonical[:40]}...")
try:
export_csv(dict(artifact, format="packvium-operational-artifact/v2"))
except OperationalArtifactError as error:
print(f" a v2 document: refused with {error.code}")
print("""
The canonical form is RFC 8785, so PHP, Rust and JavaScript build these exact
bytes from the same request and result. A reader that meets a format it does not
know refuses it by name rather than drawing half of it.""")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "packvium"
version = "1.2.0"
version = "1.3.0"
description = "Deterministic, extensible 3D cartonization and rectangular bin-packing library"
readme = "README.md"
requires-python = ">=3.9"
Expand Down
134 changes: 134 additions & 0 deletions src/packvium/_canonical_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""RFC 8785 canonical JSON: the one spelling four engines can agree on byte for byte.

A document that is compared, signed or replayed across Python, PHP, Rust and JavaScript needs
one serialization, and every language's default JSON writer disagrees with the others
somewhere. Python prints `1.0` where JavaScript prints `1`; JavaScript sorts keys by UTF-16
code unit and Python by code point; PHP escapes U+2028 unless told not to. RFC 8785 settles
each of those, and it settles them the way JavaScript already behaves -- which matters,
because JavaScript is the one engine that cannot tell `1.0` from `1` once the text is parsed.

- Object keys are sorted by their UTF-16 code units.
- Strings escape `"`, `\\`, and U+0000..U+001F (`\\b \\t \\n \\f \\r` by name, the rest as
lowercase `\\u00xx`); everything else is written as UTF-8.
- Numbers are written as ECMAScript's `Number::toString` writes them.
- No whitespace.

Two refusals keep that promise honest. A number whose magnitude exceeds 2^53 - 1 (or is not
finite) is refused, because JavaScript already holds a different number by the time it can see
it. A string carrying a lone UTF-16 surrogate is refused, because it has no UTF-8 spelling.
"""

from __future__ import annotations

import math
from decimal import Decimal
from typing import Any, List, Mapping

#: The largest magnitude every engine holds exactly.
MAX_EXACT_MAGNITUDE = 2**53 - 1

_NAMED_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r"}


class CanonicalJsonError(ValueError):
"""A value has no canonical spelling. `code` names which rule it broke."""

def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code


def canonical_json(value: Any) -> str:
parts: List[str] = []
_write(value, parts)
return "".join(parts)


def _write(value: Any, parts: List[str]) -> None:
if value is None:
parts.append("null")
elif value is True:
parts.append("true")
elif value is False:
parts.append("false")
elif isinstance(value, str):
parts.append(_string(value))
elif isinstance(value, int):
parts.append(_integer(value))
elif isinstance(value, float):
parts.append(_number(value))
elif isinstance(value, Mapping):
_object(value, parts)
elif isinstance(value, (list, tuple)):
parts.append("[")
for index, element in enumerate(value):
if index:
parts.append(",")
_write(element, parts)
parts.append("]")
else:
raise CanonicalJsonError("invalid_value", f"a {type(value).__name__} has no JSON spelling")


def _object(value: Mapping[Any, Any], parts: List[str]) -> None:
spelled = {}
for key in value:
if not isinstance(key, str):
raise CanonicalJsonError("invalid_value", f"object key {key!r} is not a string")
spelled[key] = _string(key)
parts.append("{")
# Big-endian UTF-16 bytes compare exactly as UTF-16 code units do; `_string` has already
# refused the lone surrogates that would not encode.
for index, key in enumerate(sorted(spelled, key=lambda name: name.encode("utf-16-be"))):
if index:
parts.append(",")
parts.append(spelled[key])
parts.append(":")
_write(value[key], parts)
parts.append("}")


def _string(text: str) -> str:
escaped = []
for character in text:
code = ord(character)
if 0xD800 <= code <= 0xDFFF:
raise CanonicalJsonError("invalid_string", "a string carries a lone UTF-16 surrogate")
if character in _NAMED_ESCAPES:
escaped.append(_NAMED_ESCAPES[character])
elif code < 0x20:
escaped.append(f"\\u{code:04x}")
else:
escaped.append(character)
return '"' + "".join(escaped) + '"'


def _integer(value: int) -> str:
if abs(value) > MAX_EXACT_MAGNITUDE:
raise CanonicalJsonError("number_out_of_range", f"{value} is beyond what every engine holds exactly")
return str(value)


def _number(value: float) -> str:
if not math.isfinite(value) or abs(value) > MAX_EXACT_MAGNITUDE:
raise CanonicalJsonError("number_out_of_range", f"{value!r} is beyond what every engine holds exactly")
if value == 0:
return "0"
# `repr` is the shortest string that round-trips, which is the digit string ECMAScript uses.
_, digit_tuple, exponent = Decimal(repr(abs(value))).as_tuple()
digits = list(digit_tuple)
while len(digits) > 1 and digits[-1] == 0:
digits.pop()
exponent += 1
text = "".join(str(digit) for digit in digits)
count, point = len(digits), len(digits) + int(exponent)
if count <= point <= 21:
rendered = text + "0" * (point - count)
elif 0 < point <= 21:
rendered = text[:point] + "." + text[point:]
elif -6 < point <= 0:
rendered = "0." + "0" * -point + text
else:
power = point - 1
rendered = text[0] + ("." + text[1:] if count > 1 else "") + "e" + ("+" if power >= 0 else "-") + str(abs(power))
return ("-" if value < 0 else "") + rendered
20 changes: 18 additions & 2 deletions src/packvium/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,28 @@ def dataclass(cls: type[_T]) -> type[_T]: ...
def dataclass(**options: Any) -> Callable[[type[_T]], type[_T]]: ...


#: State methods a class may define for itself. Python 3.10's ``slots=True, frozen=True``
#: replaces both with list-only versions; 3.11 and later keep the class's own. A snapshot
#: that must restore a dictionary state from a non-slotted runtime depends on its own.
_OWN_STATE_METHODS = ("__getstate__", "__setstate__")


def dataclass(cls: type[_T] | None = None, **options: Any) -> Any:
"""Delegate to stdlib dataclass, omitting only unsupported 3.10 keywords."""
"""Delegate to stdlib dataclass, omitting only unsupported 3.10 keywords, and keeping
any state method the class defines itself, as Python 3.11 and later do."""

compatible = _dataclass_options(options, sys.version_info[:2])

def decorate(target: type[_T]) -> type[_T]:
return _stdlib_dataclass(target, **compatible)
own = {name: target.__dict__[name] for name in _OWN_STATE_METHODS if name in target.__dict__}
return _reinstate_state_methods(_stdlib_dataclass(target, **compatible), own)

return decorate if cls is None else decorate(cls)


def _reinstate_state_methods(decorated: type[_T], own: dict[str, Any]) -> type[_T]:
"""Put back each state method the class defined that the decorator replaced."""
for name, method in own.items():
if decorated.__dict__.get(name) is not method:
setattr(decorated, name, method)
return decorated
Loading
Loading