diff --git a/CHANGELOG.md b/CHANGELOG.md index 1831154..0f4bd21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,401 +1,62 @@ # Changelog -The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project -adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). As of `1.0.0` the -public API, the request and result schemas, the numeric and units policy, the validation -rules and the compatibility policy are frozen: breaking any of them costs a major version. +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.2.0] - -An additive release. A packing result can now be turned into a document an operator can -work from, in all four languages; and, in Python, a proposed catalog change can be -compared, replayed against history and published through an approval step rather than -argued about. - -Nothing here breaks 1.1.0. Every schema change is additive, the frozen Python surface is -unchanged, and the PHP surface grows one new namespace and nothing else. - -### Added - -- **Execution plans, in all four languages.** `packvium.execution`, - `Packvium\Execution\Plan`, `packvium_core::execution` and `@packvium/engine`'s - `execution.js` derive a work order from an already validated result: what to lift, why a - carton was chosen, and what was not packed with its proof level unsoftened. The adapter - calls no solver and no validator — a test in each language asserts that — and given the - same result all four emit a byte-identical canonical form. - - Two properties are part of the format rather than notes about it. Everything the solver - or validator decided sits under `facts`; every human-readable sentence sits under - `presentation` and names the fields it came from in `cites`, so a consumer that reads - only `facts` loses nothing it may rely on. And the step order is **injected**: supply - `loading_orders` and each step is numbered, omit it and the container reports - `order: "unavailable"` with every placement still listed. There is no third behaviour, - because presenting the order the solver happened to walk its candidates in as an order - that is safe to lift boxes in would be a claim nothing supports. - -- **Operator locks, in Python.** `packvium.locks` lets an operator pin a placement and get - a *second* result beside the approved one — never an edit of it, so "what was approved" - and "what was proposed" stay two artifacts. A lock becomes an ordinary placement - constraint, so the re-solve is the same portfolio under the same independent validator: - **a lock cannot produce a placement the engine would otherwise refuse**, and the - strongest thing it can do is reserve its own slot. A lock the solve cannot honour is - reported as unpreserved and names the lock rather than raising; a lock set that - contradicts itself is refused before any solve runs. - - Python-only, deliberately: a lock has no representation in the request schema, so there - is nothing to hand another engine. - -- **Scenario comparison, recommendations and historical replay, in Python.** - `packvium.simulation` compares two pinned scenarios order by order and returns a Pareto - report per order — never a blended delta, because a single number can name a winner that - is worse on the axis a caller actually cares about. `packvium.recommendations` turns that - comparison into a proposal, or returns nothing at all when the paired cohort is too - small, and publishes only through an explicit approval step. `packvium.holdout` replays a - proposal against decisions held out of the evidence that produced it, over the append-only - ledger in `packvium.outcomes`; a packing the injected validator rejects is that arm's - failure at any cost, and realised damage, returns and repacks are reported against the - carton that actually shipped rather than credited to the one that was never tried. - - This surface is supported and **not yet signature-frozen** — a parameter may be renamed - or a returned value gain a field in a minor release, announced here rather than blocked - by a gate. Pin the version if you depend on its exact shape; `docs/PUBLIC-API.md` says - which surfaces carry the stronger promise. - -- **`pack_from_dict` accepts a keyword-only `extensions` registry (Python).** Additive: it - defaults to `None` and every existing call keeps its meaning and its answer. It exists so - a lock can enter the engine as an ordinary request-derived constraint rather than through - a second, lock-aware search path. The registry **adds to** the compiled policy rules and - does not replace them. - -- **Two new worked examples.** `execution` (Python, PHP and Node) turns a result into - numbered steps and shows what "byte-identical" does and does not promise; `intelligence` - (Python) proves a carton change is worth publishing before publishing it. - -### Changed - -- **The solvers do less repeated work, again.** Byte-identical results everywhere. Group - batching in the Rust extreme-point solver drops two quadratic scans over group members - for a single pass, and the PHP and Python grid-admission and load-ordering paths reuse a - prototype profile and a canonical order instead of rebuilding them per candidate. - -- **Every example names its own time budget.** An example that solved on the library default - could print a different answer on a busy machine. Each now sets `time_limit_ms`, far above - what its solve needs; every printed answer is unchanged. - -### Fixed - -- **The JavaScript engine could return fewer placements than it reported packed.** When the - per-container beam search stopped early — on its node limit, the deadline or the effort - budget — it chose the surviving leader without the batches it had not reached yet, so those - items were neither placed nor listed as unpacked while the result still said `feasible`. It - needs `solver_profile: "quality"` or an explicit `container_plan_beam_width` above 1, and it - shipped in `@packvium/engine` `1.0.0` and `1.1.0`. The default profiles never reach that - path, and Python, PHP and Rust were never affected. If you run the JavaScript engine on the - `quality` profile, check `packed_item_count` against the placements you actually received - from those versions. - -- **The intelligence modules would not have imported on Python 3.9**, which is the minimum - the package declares. Six modules used `dataclass(slots=True)` from the standard library; - that argument does not exist before 3.10, so importing any of them raised `TypeError` on a - supported runtime. They now route through the package's own compatibility shim, as the - rest of the package already did. - -- **`@packvium/engine` did not expose its execution plan.** The module was named in the - package manifest but not in `exports`, so `import '@packvium/engine/execution.js'` failed - with `ERR_PACKAGE_PATH_NOT_EXPORTED` and the main entry re-exported neither function. The - subpath is now declared, with TypeScript declarations beside it. - -- **A non-finite metric is refused rather than ranked as optimal.** `NaN` makes both `>` and - `<` false, so an axis carrying one was silently counted equal and a candidate whose metrics - were all `NaN` arrived on the Pareto frontier beside a clean one. It is now refused by a - named error that says which candidate and which axis. Infinities are still accepted: they - are ends of the number line, and a caller encoding "unpriceable" as an infinite cost gets - the answer they mean. - -## [1.1.0] - -An additive release on the 1.0.0 freeze. Route-aware unloading becomes a property of the -container rather than of the whole solve, and the result contract grows the reserved, -typed shape a future optimality gap needs. Every schema change is additive: a request that -omits the new field gets the answer it got before, and both frozen public API surfaces are -unchanged. - -The four engines' candidate hot paths were also profiled and rewritten. That work changes -no result — all 399 corpus fixtures are byte-identical — and it is in this release because -two of its findings were correctness fixes rather than speed. - -### Added - -- **Doors on a container: `container.access_directions`.** Naming which walls an item can - be pulled through, so that nothing due at a later stop stands between an earlier item and - a door. Previously this could only be stated once for a whole solve; a container now - states its own doors and falls back to that setting when it states none. An empty list - leaves the rule inert — it is not read as a sealed container, and it is deliberately not - read as all six walls, which would switch a real constraint on for callers who never set - the field. Implemented in all four engines, and refused by none. -- **A reserved shape for reporting an optimality gap.** The result schema now declares the - names, types and ceilings a gap will use, together with the rule that an attained bound - carries no gap at all rather than a zero. No engine emits any of them yet. The existing - extension point on that object stays open, so a producer accepted by the 1.0 schema is - still accepted by 1.1. - -### Changed - -- **The solvers do less repeated work.** Byte-identical results everywhere, with the - largest gains where a scene has many contacts: on a 120-item contact-heavy request the - Rust engine goes from 10.1 s to 1.4 s and the JavaScript fallback from 1.70 s to 0.76 s; - on a 200-item adversarial free-space scene JavaScript goes from 3.25 s to 0.58 s. Some - scenes are unchanged and one is marginally slower. **This is not a speed-leadership - claim** — measured against other libraries on identical hardware, that claim is false on - latency, and no Packvium surface makes it. -- **Every published package manifest now names its author, licence, repository, issue - tracker and homepage.** Absent fields are read as an anonymous package. -- **`@packvium/engine` no longer declares `@packvium/native` as an optional dependency.** - That package is not published, so the entry named something npm could not fetch. Nothing - a caller can observe changes: the install already succeeded and answered from the - JavaScript engine, and `index.js` still loads `@packvium/native` by literal specifier, so - installing it yourself alongside the engine still selects the compiled backend. - -### Fixed - -- **The Rust engine accepted placements the validator refused.** Support-ratio comparison - used a floating-point epsilon wide enough to admit an area a whole square tick short of - the requirement. It is now the same exact integer rule the other three engines use. -- **The JavaScript fallback accepted door names no other engine would.** An unknown - direction was refused only on requests that took the general solving path; a request - simple enough to be answered by the compact-grid shortcut was answered instead of - refused. -- **The JavaScript fallback broke identifier ties by host locale.** That is neither stable - across machines nor equal to the code-point order the other engines use, so two hosts - could order the same items differently. Every tie-break now uses the shared code-point - order. -- **A container's doors could be answered from another container's cached corridor.** Two - containers of the same size with different doors are two different questions; the cache - key did not separate them, which could have silently accepted a placement that walls an - item in. -- **The PHP package could not be loaded on PHP 7.3 or 7.4.** The package advertises - `php: >=7.3` and carries a second, downgraded source tree for runtimes below 8.2. In - `1.0.0` that tree contained one line of PHP 8.1 syntax, so the whole of it failed to - parse on both older runtimes. `1.1.0` is the first version whose legacy tree loads. If - you are on PHP 8.2 or newer you were never affected — the canonical tree is what your - runtime selects. - -- **The engine package no longer loads its native backend through a computed specifier.** - Every module the package can load can now be resolved by reading the source. Behaviour - is identical: an absent, unbuilt or incompatible native addon still means the pure - JavaScript engine answers instead. +## [1.3.0] -### Not claimed - -- **A reported optimality gap.** The names are reserved and typed; nothing emits them. -- **`container.pallet_overhang_limit`.** Reserved in the request schema and refused by all - four engines. -- **Identical placements across engines.** Unchanged from 1.0.0: different engines may - return different, equally valid arrangements, and that is measured and budgeted. -- **Optimal packings for arbitrary requests.** 3D packing remains NP-hard. -- **Fastest engine.** Still false on latency and still claimed nowhere. - -## [1.0.0] - -The stable core release. It freezes the contract that already exists rather than adding a -feature wave: the public API, the request and result schemas, the numeric and units policy, -the validation rules and the compatibility policy will not break without a major version. - -### Read this before depending on 1.0.0 - -- **Platforms exercised for this release: Linux x86_64, Linux aarch64 and macOS arm64.** - Python wheels are built and installed into a clean Python 3.9 on all three; the PHP FFI - bridge is verified against the real shared library on PHP 8.2, 8.3, 8.4 and 8.5 on both - Linux architectures. **Windows and macOS x86_64 native binaries are not built and not - published.** Without a native binary the pure Python and PHP engines run unchanged — that - is the documented default, and it costs speed rather than correctness. -- **Release artifacts carry no build-provenance attestation.** `gh attestation verify` will - not succeed against 1.0.0. The SBOM and SHA-256 manifests are the integrity evidence for - this release; check them before installing from anywhere other than the official - registry. - -### Added - -- **A sound lower bound on the objective, in every engine.** Computed from the request - alone before a search begins, and identical across the Python, PHP, Rust and JavaScript - implementations on 381 corpus cases. It is not a result field: reporting an optimality - gap would widen the contract this release exists to freeze. -- **A declared numeric ceiling shared by all four engines.** A sum too large to stay exact - returns a structured refusal instead of a number that one language would round and - another would not. The limit is stated by the library rather than inherited from each - language, so the four agree about which requests are answerable. -- **A published coverage frontier for optimality claims.** Where the bound is actually - attained is measured and committed, so `optimal` is bounded by evidence rather than used - as a label. - -### Fixed - -- **A bound could be returned that JavaScript cannot represent exactly.** Values above - `2^53-1` are now refused rather than silently rounded in one engine and exact in the - others. -- **The prebuilt Node addon had no quality floor.** It is now held to a per-fixture budget - like the other independent engines. - -### Not claimed - -- **Identical placements across engines.** Different engines may return different, equally - valid arrangements. This is measured and budgeted, not accidental — and it includes the - JavaScript fallback and the prebuilt native addon, which differ on 18 of 397 corpus - fixtures. -- **Optimal packings for arbitrary requests.** 3D packing remains NP-hard; the bound says - what is provable, not that every answer is optimal. -- **Fastest engine.** Measured against other libraries on identical hardware, that claim is - false on latency, and no Packvium surface makes it. On the report's separate two-axis - time-and-peak-memory frontier, `packvium-rust` is Pareto-optimal in 6 of 9 profiles and - the only engine of ten never dominated. The latency result and the trade-off result are - reported together; neither is a general speed-leadership claim. - -## [0.1.3] - -An additive release. No packing request/result field or solver algorithm changed. +Portable operational artifacts: hand a packing result to a WMS, a TMS or a printer that runs +no engine. Nothing breaks 1.2.0. ### Added -- **First-party carrier connectors for UPS, FedEx, DHL Express and USPS**, and a live - rate-card contract behind them. Connectors prepare ordinary rate-table data before a - deterministic solve; no network call, clock or carrier module enters a packing engine. - A rate card records which tariff version priced a quote and when it was fetched, and a - card that has gone stale is refused rather than served. - -### Fixed - -- **`@packvium/browser` now works under Node**, not only in a browser. The WebAssembly - loader generated for the `web` target fetches its own `.wasm` from a `file:` URL, which - Node's `fetch` refuses — so server-side rendering, a Vitest node environment and - `node --test` failed at initialization on 0.1.1 and 0.1.2. The package now selects a - Node entry point that reads the module off disk. Browser behaviour is unchanged. -- **The PHP package's compatibility tree could not autoload a class on old PHP.** Its - entry point was the one shipped file the downgrade did not process, and it used a PHP 8 - function to match the namespace prefix. Three of its solver files also could not be - parsed by PHP 8.0 or 8.1, which that tree also serves. Both are fixed, and the tree is - installed and loaded on 7.3, 7.4, 8.0 and 8.1 before release. -- **The compatibility tree did not place items where the canonical engine does on PHP 7.** - Several solver comparators were partial and relied on `usort` being stable, which PHP - guarantees only from 8.0, so three shared fixtures came back with a different rotation - chosen. The tie-breaking is explicit now — identical results on every supported version — - and no released package ever carried it, because none was installable below 8.2. +- **`packvium.artifacts`** — `build_operational_artifact(request, result, loading_orders=)` and + `canonical_artifact_json()`. One `packvium-operational-artifact/v1` document carries the + execution plan, exact geometry, the values a work order shows, and the request that produced + it. Every Packvium engine builds the same bytes from the same result. +- **`packvium.artifact_exports`** — `export_json`, `export_csv` (RFC 4180, 18 columns) and + `export_work_order_html`: a printable work order in one HTML file with no scripts and no + external resources. +- `examples/artifacts.py`. ### Changed -- **`packvium/packvium` now requires `php: >=7.3` instead of `>=8.2`.** One package - carries both the canonical PHP 8.2+ source and a generated `src-legacy/` tree, and its - `autoload.php` selects by `PHP_VERSION_ID`; PHP parses only what it loads. - `composer require packvium/packvium` is the right command on 7.3 through 8.5, and every - one of those versions is held to the same committed placement results. Nothing changes - for an 8.2+ consumer: the same files load, under the same names. -- Every package's `homepage` now points at . +- **`packvium.execution`, `packvium.artifacts` and `packvium.artifact_exports` are in the frozen + API snapshot**, with the same guarantee as the rest of the public surface. +- **`canonical_plan_json` writes RFC 8785.** Every plan the engine produces keeps its 1.2.0 + bytes. A float, U+2028 or a key outside the Basic Multilingual Plane is now spelled as the + other engines spell it, and an integer beyond 2^53 − 1 raises instead of being written. +- **Faster commerce lookups, same answers.** A pinned catalog or tariff version resolves + without scanning the history (1000 lookups over 16000 versions: 374 ms → 1.2 ms), a + snapshot's SKU lookup builds its index on first use, and `packvium.pareto` finds a two-axis + frontier with one sort instead of comparing every pair. -## [0.1.2] +## [1.2.0] -An additive documentation and integration release. No packing request/result field or -solver algorithm changed. +Execution plans, operator locks, and the intelligence API. Nothing breaks 1.1.0. ### 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 -the ones `0.1.0` did not break, so that one version number still describes one tested -set. +- **`packvium.execution`** — `build_execution_plan()` and `canonical_plan_json()` turn a + validated result into a work order: what to lift, why a carton was chosen, what was not + packed. Step order is taken from `loading_orders` or reported as `"unavailable"`. +- **`packvium.locks`** — pin a placement and get a constrained re-solve beside the approved + plan (`resolve_with_locks`, `locks_from_plan`). A lock can reserve its slot but never force a + placement the engine would refuse; a lock that cannot be honoured is reported, not raised. +- **`packvium.simulation`, `.recommendations`, `.outcomes`, `.holdout`, `.pareto`** — compare + two scenarios order by order, propose a carton or rule change only on enough paired + evidence, publish it through an explicit approval, and replay it against held-out history. + Supported, but not yet signature-frozen: pin the version if you rely on exact shapes. +- `pack_from_dict(..., extensions=)` — keyword-only, defaults to `None`. +- `examples/execution.py`, `examples/intelligence.py`. ### Fixed -- **`lowest_landed_cost` could choose a container its own rate card cannot price.** When - one container billed lighter than another but its rate table ran out before the - shipment's billed weight, the search preferred it — returning the one packing you - cannot actually buy over a priced alternative. Every engine now compares candidates by - the money the rate table charges rather than by billed weight, which also fixes the - case this objective exists for: a bracket step or a minimum charge can make the - cheaper shipment the heavier one. If no container on offer can price the load, the - request is refused with a message naming the container, its billed weight and the last - bracket, in all four languages — previously two of them returned a result carrying a - sentinel cost, and two aborted requests that had a shippable answer. `RateTable` gains - a non-throwing `charge_minor_or_none` / `chargeMinorOrNull`; the throwing form is - unchanged. No request or result field changed. - -- **`@packvium/engine@0.1.0` could not be imported.** The published tarball was missing - a runtime module that the fallback engine imports, so the first `import` of the package - threw `ERR_MODULE_NOT_FOUND`. npm versions are immutable, which is why the fix has to - arrive as a new version rather than a re-upload. Package assembly now dry-packs the - tarball and resolves every relative import in the real published inventory, so a - missing runtime file fails the release build instead of the consumer's first import. - Only the Node package was affected; the Python, PHP and Rust `0.1.0` releases install - and run correctly. - -### Added - -- **Commercial and control-plane API.** Three deterministic functions over one canonical - JSON document: `quote` returns a landed cost together with the tariff version that - produced it, `evaluate_policy` returns an eligibility decision together with the rule id - and version that decided it, and `catalog_version_info` returns the metadata of one - pinned catalog version. Exported as `packvium.commerce` (Python), `Packvium\Commerce\` - (PHP), `packvium_core::commerce` (Rust, plus three C ABI entry points) and `commerce` - on `@packvium/engine` and `@packvium/browser`. Prices are exact integers in minor - currency units and every inexact division rounds up, so a quote is reproducible rather - than approximately equal. No packing-request or packing-result field changed. Each - package ships a runnable `commerce` example; the contract is in `docs/COMMERCE-API.md`. - -## [0.1.0] - -First release. - -### Added - -- Exact fixed-point units: length in 1/16000 mm, weight in 1/8 µg. Integer, decimal, - fractional (`3/16`) and mixed-fraction (`12 3/8`) input across mm, cm, m, in and ft. - Common fractional inches through 1/128 are exact integers. -- Immutable domain model — items, item instances, containers, obstacles, rotations, - placements, requests and results. A call cannot mutate its inputs. -- Hard placement constraints: container boundaries, collisions, weight limits, permitted - orientations, floor-only, non-stackable, direct top-load limits, minimum support ratio, - tag compatibility and rectangular obstacles. -- A solver portfolio chosen per problem — regular grid, layer, extreme-point, - maximal-space and bounded exact-small — with deadlines and a deterministic seed. -- Lexicographic objective ranking with top-K alternatives. -- Independent post-solve validation: every returned solution is re-checked by logic - separate from the search that produced it. A solution that fails is reported as - `invalid_result` rather than returned as if it were sound. -- Structured reasons for every unplaced item. -- JSON serialization and a command-line interface reading a request on standard input. -- Sequential nested packing — a packed container becomes an item at the next level. -- Extension points for custom placement constraints, item orderings, candidate scorers, - container selectors, solution scorers and complete solvers. +- The new modules imported on Python 3.10+ only; they now import on 3.9, the declared minimum. +- A `NaN` metric is refused with a named error instead of being ranked Pareto-optimal. -### Known limitations +## Earlier releases -Rigid axis-aligned cuboids only. No global optimality guarantee for the heuristic -profiles, no transport physics, and top-load limits apply to what rests directly on an -item rather than to a whole stack. See `docs/GUARANTEES.md` for the full statement of -what is and is not promised. +Up to 1.1.0 one changelog covered every Packvium language. Those entries are kept in +this repository's GitHub Releases for each tag. diff --git a/README.md b/README.md index e41b4d5..3df64ab 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. | @@ -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 diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 44a9cea..1e5eb73 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 `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. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index ecf9af0..d24251f 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -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 diff --git a/examples/artifacts.py b/examples/artifacts.py new file mode 100644 index 0000000..06d2783 --- /dev/null +++ b/examples/artifacts.py @@ -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: {' 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 diff --git a/src/packvium/_compat.py b/src/packvium/_compat.py index 725aecf..2a3f7c2 100644 --- a/src/packvium/_compat.py +++ b/src/packvium/_compat.py @@ -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 diff --git a/src/packvium/artifact_exports.py b/src/packvium/artifact_exports.py new file mode 100644 index 0000000..1581772 --- /dev/null +++ b/src/packvium/artifact_exports.py @@ -0,0 +1,225 @@ +"""JSON, CSV and print-ready work orders from an operational artifact. + +Each export is a pure function of the artifact: no solver, carrier, renderer or clock, so an +export is replayable from the artifact it came from and four engines emit the same bytes. + +- JSON is the artifact's RFC 8785 canonical form. +- CSV has one row per packing step and one per unplaced item. It is RFC 4180: a header row, + CRLF after every row, and a field quoted only when it holds a comma, quote, CR or LF. +- The work order is one self-contained HTML document: inline print styles, no script, no + external resource, ASCII source with entities for the few typographic characters. + +Values are copied, never re-rendered or reinterpreted. That includes a CSV field beginning +with `=`: prefixing it would change an identifier a warehouse system matches on, so opening +the file in a spreadsheet is the reader's decision (`docs/OPERATIONAL-ARTIFACTS.md`). +""" + +from __future__ import annotations + +import json +from typing import Any, Iterable, List, Mapping, Optional, Sequence + +from .artifacts import FORMAT, OperationalArtifactError, canonical_artifact_json + +__all__ = ["CSV_COLUMNS", "export_csv", "export_json", "export_work_order_html"] + +CSV_COLUMNS = ( + "record", "container_index", "container_type", "sequence", "item_type", "orientation", + "x_ticks", "y_ticks", "z_ticks", "x", "y", "z", "length", "width", "height", "length_unit", + "reason", "proof_level", +) + +_CSV_SPECIAL = (",", '"', "\r", "\n") + +_STYLE = ( + "body{font-family:system-ui,sans-serif;margin:24px;color:#111}" + "h1{font-size:20px}h2{font-size:16px;margin-top:24px}" + "table{border-collapse:collapse;width:100%;margin:8px 0 16px}" + "th,td{border:1px solid #999;padding:4px 6px;text-align:left;font-size:12px;vertical-align:top}" + "th{background:#eee}" + ".facts td:first-child{width:28%;font-weight:600}" + "@media print{body{margin:0}section.container{break-after:page}tr{break-inside:avoid}}" +) + +_DASH = "—" + + +def export_json(artifact: Mapping[str, Any]) -> str: + return canonical_artifact_json(_require(artifact)) + + +def export_csv(artifact: Mapping[str, Any]) -> str: + document = _require(artifact) + work_order = document["work_order"] + rows: List[Sequence[Any]] = [CSV_COLUMNS] + for container in work_order["containers"]: + for line in container["lines"]: + reference = line["placement"] + ticks = reference["position_ticks"] + position, dimensions = line["position"], line["dimensions"] + rows.append(( + "step", reference["container_index"], container["container_type"], line.get("sequence"), + reference["item_type"], reference["orientation"], ticks["x"], ticks["y"], ticks["z"], + position["x"], position["y"], position["z"], + dimensions["length"], dimensions["width"], dimensions["height"], work_order["length_unit"], + None, None, + )) + for unplaced in document["plan"]["unplaced"]: + facts = unplaced["facts"] + rows.append(("unplaced", None, None, None, facts["item_type"], None, None, None, None, + None, None, None, None, None, None, None, facts["reason"], facts["proof_level"])) + return "".join(",".join(_csv_field(value) for value in row) + "\r\n" for row in rows) + + +def export_work_order_html(artifact: Mapping[str, Any]) -> str: + document = _require(artifact) + plan, work_order, provenance = document["plan"], document["work_order"], document["provenance"] + lines = [ + "", + '', + "", + '', + "Packing work order", + f"", + "", + "", + "

Packing work order

", + '', + *_fact_rows(document, plan, provenance), + "
", + ] + for plan_container, container in zip(plan["containers"], work_order["containers"]): + lines.extend(_container_section(plan_container, container, work_order)) + lines.extend(_unplaced_section(plan["unplaced"])) + lines.extend(["", ""]) + return "\n".join(lines) + "\n" + + +# ------------------------------------------------------------------------------------ HTML + + +def _fact_rows(document: Mapping[str, Any], plan: Mapping[str, Any], provenance: Mapping[str, Any]) -> List[str]: + facts, replay, solver = plan["facts"], provenance["replay"], provenance["solver"] + feasibility = facts["feasibility"] + replay_text = _escape(replay["level"]) + (f" ({_escape(replay['because'])})" if replay["because"] else "") + solver_text = ( + f"{_escape(solver['profile'])} / {_escape(solver['solver'])} / seed {_escape(solver['seed'])}" + if solver else _DASH + ) + catalogs = ", ".join(f"{_escape(catalog.get('catalog_id'))} v{_escape(catalog.get('version'))}" + for catalog in provenance["catalog_versions_used"]) + rows = ( + ("Status", _escape(facts["status"])), + ("Objective", _or_dash(plan["objective"])), + ("Containers", _escape(facts["container_count"])), + ("Score", "[" + ", ".join(_escape(term) for term in facts["score"]) + "]"), + ("Feasibility", _or_dash(feasibility.get("code") if isinstance(feasibility, Mapping) else None)), + ("Replay", replay_text), + ("Solver", solver_text), + ("Catalogs", catalogs or _DASH), + ("Packvium", _escape(document["suite_version"])), + ) + return [f"{label}{value}" for label, value in rows] + + +def _container_section(plan_container: Mapping[str, Any], container: Mapping[str, Any], + work_order: Mapping[str, Any]) -> List[str]: + facts = plan_container["facts"] + weight_unit, length_unit = _escape(work_order["weight_unit"]), _escape(work_order["length_unit"]) + lines = [ + '
', + f"

Container {_ordinal(container['container_index'])}: {_or_dash(container['container_type'])}

", + f"

Payload {_escape(container['payload_weight'])} {weight_unit} · " + f"Gross {_escape(container['gross_weight'])} {weight_unit} · " + f"Utilization {_or_dash(facts['volume_utilization'])}

", + ] + if plan_container["order"] == "loading": + lines.append("

Order: loading. Follow the steps in sequence.

") + else: + lines.append("

Order: unavailable. No safe loading order was supplied, so the steps are not numbered.

") + lines.extend([ + "", + f"" + f"", + "", + ]) + for line in container["lines"]: + reference, position, size = line["placement"], line["position"], line["dimensions"] + lines.append( + f"" + f"" + f"" + f"" + "" + ) + lines.extend(["", "
StepItemOrientationPosition x, y, z ({length_unit})Size l × w × h ({length_unit})Done
{_escape(line.get('sequence', ''))}{_escape(reference['item_type'])}{_escape(reference['orientation'])}{_escape(position['x'])}, {_escape(position['y'])}, {_escape(position['z'])}{_escape(size['length'])} × {_escape(size['width'])} × {_escape(size['height'])}☐
", "
"]) + return lines + + +def _unplaced_section(unplaced: Iterable[Mapping[str, Any]]) -> List[str]: + entries = list(unplaced) + lines = ["
", "

Not packed

"] + if not entries: + lines.extend(["

Every item was packed.

", "
"]) + return lines + lines.extend(["", "", ""]) + for entry in entries: + facts = entry["facts"] + lines.append(f"" + f"") + lines.extend(["", "
ItemReasonProof
{_or_dash(facts['item_type'])}{_or_dash(facts['reason'])}{_or_dash(facts['proof_level'])}
", ""]) + return lines + + +def _text(value: Any) -> str: + """The one rendering every engine shares: a string as itself, an integer in decimal, null as + nothing. Anything else -- a boolean, a float, an object -- has a different default spelling + in each language, so it is refused rather than printed four ways.""" + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + raise OperationalArtifactError("invalid_value", f"a {type(value).__name__} has no single rendering in a work order") + + +def _ordinal(index: Any) -> str: + """A container's 1-based number on the sheet. Computed, so it is checked like `_text`: + `0.5 + 1` would print `1.5` in one engine and be refused in another.""" + if isinstance(index, bool) or not isinstance(index, int): + raise OperationalArtifactError("invalid_value", f"container index {index!r} is not an integer") + return str(index + 1) + + +def _escape(value: Any) -> str: + text = _text(value) + return (text.replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """).replace("'", "'")) + + +def _or_dash(value: Optional[Any]) -> str: + return _DASH if value is None else _escape(value) + + +# ------------------------------------------------------------------------------------- CSV + + +def _csv_field(value: Any) -> str: + text = _text(value) + if any(special in text for special in _CSV_SPECIAL): + return '"' + text.replace('"', '""') + '"' + return text + + +def _require(artifact: Any) -> Mapping[str, Any]: + """An export reads only a document it knows how to read, and says so when it cannot. + + It reads the artifact *through its canonical form*, so an export is a function of the bytes + four engines agree on and not of how one language happens to hold them in memory: a `1.0` + in a Python dict is the `1` that PHP, Rust and JavaScript read back from those bytes. + """ + found = artifact.get("format") if isinstance(artifact, Mapping) else None + if found != FORMAT: + raise OperationalArtifactError("unknown_format", f"cannot export format {found!r}; this exporter reads {FORMAT}") + return json.loads(canonical_artifact_json(artifact)) diff --git a/src/packvium/artifacts.py b/src/packvium/artifacts.py new file mode 100644 index 0000000..ad42e07 --- /dev/null +++ b/src/packvium/artifacts.py @@ -0,0 +1,293 @@ +"""The portable operational artifact. + +`docs/OPERATIONAL-ARTIFACTS.md` is the contract. An execution plan tells an operator what to +do first; it does not say how big the boxes are, what the load weighs or which request and +solver produced it. The artifact is the one document that can be drawn, printed and traced +offline, and it is built so that it adds nothing a solver decided. + +It reads the request, the result and the optional loading orders -- the plan's own inputs -- +and calls no solver, validator, renderer or clock. The plan inside it is exactly what +`packvium.execution.build_execution_plan` emits for the same inputs. Placements are found by +the plan's `placement_ref`, never by `item_id`, so there is one address format to drift. +Four builders are held to byte-identical canonical output. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from ._canonical_json import MAX_EXACT_MAGNITUDE, CanonicalJsonError, canonical_json +from .execution import ExecutionPlanError, build_execution_plan, placement_reference + +__all__ = [ + "FORMAT", + "SUITE_VERSION", + "OperationalArtifactError", + "build_operational_artifact", + "canonical_artifact_json", +] + +FORMAT = "packvium-operational-artifact/v1" + +#: 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.3.0" + +#: The deterministic part of `result.algorithm`. `duration_ms` is wall-clock time and never +#: enters an artifact. +SOLVER_FIELDS = ("profile", "solver", "seed", "time_limit_reached", "effort_limit_reached") + +DIMENSION_AXES = ("length", "width", "height") +POSITION_AXES = ("x", "y", "z") + +PlacementKey = Tuple[Any, ...] + + +class OperationalArtifactError(ValueError): + """The builder was handed something an artifact cannot carry. + + `code` is one of a closed set shared by all four engines: `invalid_request`, + `invalid_result`, `invalid_plan_input`, `mixed_units`, `number_out_of_range`, + `invalid_string`, `invalid_value`, `unknown_format`, `invalid_json`. + """ + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +def build_operational_artifact( + request: Mapping[str, Any], + result: Mapping[str, Any], + *, + loading_orders: Optional[Mapping[int, Sequence[int]]] = None, +) -> Dict[str, Any]: + """Build the artifact for one validated result. O(R + P) for a request of size R and P + placements, plus key sorting when it is serialized.""" + if not isinstance(request, Mapping): + raise OperationalArtifactError("invalid_request", "a request is a JSON object") + if not isinstance(result, Mapping): + raise OperationalArtifactError("invalid_result", "a result is a JSON object") + _require_objects(result) + try: + plan = build_execution_plan(request, result, loading_orders=loading_orders) + except ExecutionPlanError as error: + raise OperationalArtifactError("invalid_plan_input", str(error)) from error + + containers = _list(result.get("containers"), "result.containers") + length_unit, weight_unit = _units(containers) + artifact = { + "format": FORMAT, + "suite_version": SUITE_VERSION, + "provenance": _provenance(request, result), + "plan": plan, + "geometry": {"containers": [_geometry(index, container) for index, container in enumerate(containers)]}, + "work_order": { + "length_unit": length_unit, + "weight_unit": weight_unit, + "containers": [ + _work_order_container(index, container, plan_container, length_unit, weight_unit) + for index, (container, plan_container) in enumerate(zip(containers, plan["containers"])) + ], + }, + } + # Refused here rather than when someone serializes it: an artifact that exists must have + # one spelling in every engine. + canonical_artifact_json(artifact) + return artifact + + +def canonical_artifact_json(artifact: Mapping[str, Any]) -> str: + """The artifact's RFC 8785 canonical form, the bytes four engines are compared on.""" + try: + return canonical_json(artifact) + except CanonicalJsonError as error: + raise OperationalArtifactError(error.code, str(error)) from error + + +# ------------------------------------------------------------------------------ provenance + + +def _provenance(request: Mapping[str, Any], result: Mapping[str, Any]) -> Dict[str, Any]: + solver = _solver(result.get("algorithm")) + catalogs = _list(result.get("catalog_versions_used"), "result.catalog_versions_used") + for catalog in catalogs: + _catalog(catalog) + return { + # Embedded, not digested: only the request itself lets someone replay the artifact + # without a lookup. + "request": request, + "catalog_versions_used": catalogs, + "solver": solver, + "replay": _replay(solver), + } + + +def _catalog(catalog: Mapping[str, Any]) -> None: + """The result schema's closed catalog reference. The work order prints these fields, so a + mistyped one would print differently in every engine.""" + if not isinstance(_field(catalog, "catalog_id", "catalog_versions_used[]"), str): + raise OperationalArtifactError("invalid_result", "catalog_versions_used[].catalog_id is not a string") + for name in ("version", "effective_at", "resolved_at"): + value = _field(catalog, name, "catalog_versions_used[]") + if isinstance(value, bool) or not isinstance(value, int): + raise OperationalArtifactError("invalid_result", f"catalog_versions_used[].{name} is not an integer") + + +def _solver(algorithm: Any) -> Optional[Dict[str, Any]]: + if algorithm is None: + return None + if not isinstance(algorithm, Mapping): + raise OperationalArtifactError("invalid_result", "result.algorithm is not an object") + missing = [field for field in SOLVER_FIELDS if field not in algorithm] + if missing: + raise OperationalArtifactError("invalid_result", f"result.algorithm has no {', '.join(missing)}") + for flag in ("time_limit_reached", "effort_limit_reached"): + if not isinstance(algorithm[flag], bool): + raise OperationalArtifactError("invalid_result", f"result.algorithm.{flag} is not a boolean") + return {field: algorithm[field] for field in SOLVER_FIELDS} + + +def _replay(solver: Optional[Mapping[str, Any]]) -> Dict[str, Any]: + """`exact` only when a replay must reproduce the result. A search stopped by wall-clock + time cannot be reproduced, and a result that does not say how it was solved cannot be + promised to; claiming otherwise would be softening a proof level by another name.""" + if solver is None: + return {"level": "not_guaranteed", "because": "provenance.solver"} + if solver["time_limit_reached"]: + return {"level": "not_guaranteed", "because": "provenance.solver.time_limit_reached"} + return {"level": "exact", "because": None} + + +# -------------------------------------------------------------------------------- geometry + + +def _geometry(index: int, container: Mapping[str, Any]) -> Dict[str, Any]: + return { + "container_index": index, + "inner_dimensions": _tick_dimensions(_field(container, "inner_dimensions", f"containers[{index}]")), + "placements": [ + { + "placement": placement_reference(index, placement), + "dimensions": _tick_dimensions(_field(placement, "dimensions", f"containers[{index}].placements[]")), + } + for placement in _placements(index, container) + ], + } + + +def _tick_dimensions(dimensions: Any) -> Dict[str, str]: + """Lengths as decimal strings of ticks: the scene contract's spelling, and one no engine + has to hold as a native number.""" + return {axis: _ticks(_field(dimensions, axis, "dimensions")) for axis in DIMENSION_AXES} + + +def _ticks(scalar: Any) -> str: + ticks = _field(scalar, "ticks", "exact scalar") + if isinstance(ticks, bool) or not isinstance(ticks, int): + raise OperationalArtifactError("invalid_result", f"ticks {ticks!r} is not an integer") + # Written as a string, so the canonical writer never sees it as a number; the range is + # checked here instead, because JavaScript has already rounded such a value while parsing. + if abs(ticks) > MAX_EXACT_MAGNITUDE: + raise OperationalArtifactError("number_out_of_range", f"ticks {ticks} is beyond what every engine holds exactly") + return str(ticks) + + +# ------------------------------------------------------------------------------ work order + + +def _units(containers: Sequence[Mapping[str, Any]]) -> Tuple[Optional[str], Optional[str]]: + """The display units, read from the result rather than re-derived from request defaults: + the result already rendered every value in them.""" + if not containers: + return None, None + first = containers[0] + length = _field(_field(_field(first, "inner_dimensions", "containers[0]"), "length", "inner_dimensions"), "unit", "length") + weight = _field(_field(first, "payload_weight", "containers[0]"), "unit", "payload_weight") + return length, weight + + +def _work_order_container(index: int, container: Mapping[str, Any], plan_container: Mapping[str, Any], + length_unit: Optional[str], weight_unit: Optional[str]) -> Dict[str, Any]: + by_reference = _placements_by_reference(index, container) + lines = [] + # One line per plan step, in plan step order: the order is the plan's, looked up, never + # derived a second time. + for step in plan_container["steps"]: + placement = by_reference[_reference_key(step["placement"])] + line: Dict[str, Any] = {} + if "sequence" in step: + line["sequence"] = step["sequence"] + position = _field(placement, "position", "placement") + dimensions = _field(placement, "dimensions", "placement") + line["placement"] = step["placement"] + line["position"] = {axis: _value(_field(position, axis, "position"), length_unit) for axis in POSITION_AXES} + line["dimensions"] = {axis: _value(_field(dimensions, axis, "dimensions"), length_unit) for axis in DIMENSION_AXES} + lines.append(line) + return { + "container_index": index, + "container_type": container.get("container_type"), + "payload_weight": _value(_field(container, "payload_weight", f"containers[{index}]"), weight_unit), + "gross_weight": _value(_field(container, "gross_weight", f"containers[{index}]"), weight_unit), + "lines": lines, + } + + +def _placements_by_reference(index: int, container: Mapping[str, Any]) -> Dict[PlacementKey, Mapping[str, Any]]: + placements = _placements(index, container) + found = {_reference_key(placement_reference(index, placement)): placement for placement in placements} + if len(found) != len(placements): + raise OperationalArtifactError( + "invalid_result", f"two placements in container {index} share an origin, type and orientation") + return found + + +def _reference_key(reference: Mapping[str, Any]) -> PlacementKey: + ticks = reference["position_ticks"] + return (reference["item_type"], reference["orientation"], ticks["x"], ticks["y"], ticks["z"]) + + +def _value(scalar: Any, unit: Optional[str]) -> str: + """The result's rendered value, copied and never re-rendered.""" + if _field(scalar, "unit", "exact scalar") != unit: + raise OperationalArtifactError("mixed_units", f"a value in {scalar.get('unit')!r} where the result uses {unit!r}") + value = _field(scalar, "value", "exact scalar") + if not isinstance(value, str): + raise OperationalArtifactError("invalid_result", f"value {value!r} is not a string") + return value + + +# --------------------------------------------------------------------------------- helpers + + +def _require_objects(result: Mapping[str, Any]) -> None: + """Every list entry the artifact and its plan read is an object, checked before either + reads one, so a malformed result is refused by name in every engine instead of failing + wherever each language first touches it.""" + for name in ("containers", "unpacked_items", "alternatives", "catalog_versions_used"): + for entry in _list(result.get(name), f"result.{name}"): + if not isinstance(entry, Mapping): + raise OperationalArtifactError("invalid_result", f"result.{name} holds a non-object entry") + for index, container in enumerate(_list(result.get("containers"), "result.containers")): + for placement in _placements(index, container): + if not isinstance(placement, Mapping): + raise OperationalArtifactError("invalid_result", f"containers[{index}].placements holds a non-object entry") + + +def _placements(index: int, container: Mapping[str, Any]) -> List[Mapping[str, Any]]: + return _list(container.get("placements"), f"containers[{index}].placements") + + +def _list(value: Any, where: str) -> List[Any]: + if value is None: + return [] + if not isinstance(value, (list, tuple)): + raise OperationalArtifactError("invalid_result", f"{where} is not a list") + return list(value) + + +def _field(mapping: Any, name: str, where: str) -> Any: + if not isinstance(mapping, Mapping) or name not in mapping: + raise OperationalArtifactError("invalid_result", f"{where} has no {name}") + return mapping[name] diff --git a/src/packvium/commerce/catalog.py b/src/packvium/commerce/catalog.py index 3e8c5e2..9cca0f9 100644 --- a/src/packvium/commerce/catalog.py +++ b/src/packvium/commerce/catalog.py @@ -45,7 +45,9 @@ from __future__ import annotations from .._compat import dataclass +from dataclasses import fields from enum import Enum +from types import MappingProxyType from typing import Sequence @@ -220,8 +222,12 @@ def _find(entries: Sequence, id: str, kind: CatalogEntryKind): raise CatalogEntryNotFoundError(f"no {kind.value} with id {id!r} in this catalog snapshot") +class _SnapshotLookupCache: + __slots__ = ("_entry_indexes",) + + @dataclass(frozen=True, slots=True) -class CatalogSnapshot: +class CatalogSnapshot(_SnapshotLookupCache): """The complete, immutable content of one catalog version: every item, carton and pallet master record, exclusion rule and facility override active under that version. @@ -245,13 +251,40 @@ def __post_init__(self) -> None: _require_unique_ids("facility override", self.overrides) def item(self, id: str) -> ItemMaster: - return _find(self.items, id, CatalogEntryKind.ITEM) + return self._lookup(self.items, id, CatalogEntryKind.ITEM, ItemMaster) def carton(self, id: str) -> CartonMaster: - return _find(self.cartons, id, CatalogEntryKind.CARTON) + return self._lookup(self.cartons, id, CatalogEntryKind.CARTON, CartonMaster) def pallet(self, id: str) -> PalletMaster: - return _find(self.pallets, id, CatalogEntryKind.PALLET) + return self._lookup(self.pallets, id, CatalogEntryKind.PALLET, PalletMaster) + + + def _lookup(self, entries, id, kind, entry_type): + if type(entries) is not tuple: + return _find(entries, id, kind) + indexes = getattr(self, "_entry_indexes", {}) + index = indexes.get(kind) + if index is None: + index = (MappingProxyType({entry.id: entry for entry in entries}) + if all(type(entry) is entry_type and type(entry.id) is str for entry in entries) + else False) + object.__setattr__(self, "_entry_indexes", {**indexes, kind: index}) + if index is False or type(id) is not str: + return _find(entries, id, kind) + try: + return index[id] + except KeyError: + raise CatalogEntryNotFoundError(f"no {kind.value} with id {id!r} in this catalog snapshot") from None + + def __getstate__(self): + # Derived indexes are not part of the value, serialization or a copied snapshot. + return [getattr(self, field.name) for field in fields(self)] + + def __setstate__(self, state): + # Also accept the dictionary state produced by older non-slotted runtimes. + for index, field in enumerate(fields(self)): + object.__setattr__(self, field.name, state[field.name] if isinstance(state, dict) else state[index]) # ------------------------------------------------------------------------------- version @@ -451,18 +484,26 @@ def _resolve_version(self, *, version: int | None, as_of: int | None) -> Catalog if not self._versions: raise CatalogVersionNotFoundError(f"catalog {self._catalog_id!r} has no published versions") return self._versions[0] - candidates = [v for v in self._versions if v.effective_at <= as_of] - if not candidates: + target = None + for candidate in self._versions: + if candidate.effective_at <= as_of and (target is None or candidate.effective_at >= target.effective_at): + target = candidate + if target is None: raise NoEffectiveCatalogVersionError( f"catalog {self._catalog_id!r} has no version effective as of {as_of}" ) # Ties in effective_at are broken by the higher (later-published) version number, # so a same-instant correction or rollback deterministically wins rather than # being ambiguous. - return max(candidates, key=lambda v: (v.effective_at, v.number)) + return target def _version(self, number: int) -> CatalogVersion: - for v in self._versions: - if v.number == number: - return v + if type(number) is int: + if 1 <= number <= len(self._versions): + return self._versions[number - 1] + else: + # Preserve equality-based resolution for existing non-int callers. + for v in self._versions: + if v.number == number: + return v raise CatalogVersionNotFoundError(f"catalog {self._catalog_id!r} has no version {number}") diff --git a/src/packvium/commerce/rating.py b/src/packvium/commerce/rating.py index 60dae3e..cd0e770 100644 --- a/src/packvium/commerce/rating.py +++ b/src/packvium/commerce/rating.py @@ -253,10 +253,12 @@ def _resolve_effective(versions: list[Tariff], *, as_of: int) -> Optional[Tariff """Same effective-dating resolution `packvium.commerce.catalog`'s `CatalogRegistry` and `packvium.commerce.policy`'s `PolicyRegistry` use: the highest `effective_at` not after `as_of`, ties broken by the higher (later-published) version.""" - candidates = [v for v in versions if v.effective_at <= as_of] - if not candidates: - return None - return max(candidates, key=lambda v: (v.effective_at, v.version)) + winner = None + for candidate in versions: + if candidate.effective_at <= as_of and (winner is None or + (candidate.effective_at, candidate.version) > (winner.effective_at, winner.version)): + winner = candidate + return winner class CarrierRegistry: @@ -290,10 +292,13 @@ def publish( return tariff def versions(self, carrier_id: str, service_id: str) -> tuple[Tariff, ...]: + return tuple(self._history(carrier_id, service_id)) + + def _history(self, carrier_id: str, service_id: str) -> list[Tariff]: key = (carrier_id, service_id) if key not in self._versions: raise TariffNotFoundError(f"no tariff registered for {carrier_id}/{service_id}") - return tuple(self._versions[key]) + return self._versions[key] def tariff(self, carrier_id: str, service_id: str, version: int) -> Tariff: """Resolve one immutable tariff version without performing a rating. @@ -302,9 +307,14 @@ def tariff(self, carrier_id: str, service_id: str, version: int) -> Tariff: the same pinned object, avoiding an ``O(h)`` history scan per candidate where ``h`` is the number of published tariff versions. """ - for tariff in self.versions(carrier_id, service_id): - if tariff.version == version: - return tariff + history = self._history(carrier_id, service_id) + if type(version) is int: + if 1 <= version <= len(history): + return history[version - 1] + else: + for tariff in history: + if tariff.version == version: + return tariff raise TariffNotFoundError(f"{carrier_id}/{service_id} has no version {version}") def effective_tariff(self, carrier_id: str, service_id: str, *, as_of: int) -> Tariff: @@ -314,7 +324,7 @@ def effective_tariff(self, carrier_id: str, service_id: str, *, as_of: int) -> T to report *which* resolution step failed, or that evaluates many requests against one instant, resolves once here instead of re-scanning the history. """ - tariff = _resolve_effective(list(self.versions(carrier_id, service_id)), as_of=as_of) + tariff = _resolve_effective(self._history(carrier_id, service_id), as_of=as_of) if tariff is None: raise TariffNotFoundError( f"{carrier_id}/{service_id} has no tariff version effective as of {as_of}" diff --git a/src/packvium/execution.py b/src/packvium/execution.py index b815833..cb0fe50 100644 --- a/src/packvium/execution.py +++ b/src/packvium/execution.py @@ -31,9 +31,10 @@ from __future__ import annotations -import json from typing import Any, Mapping, Optional, Sequence +from ._canonical_json import canonical_json + __all__ = [ "FORMAT", "ExecutionPlanError", @@ -237,5 +238,10 @@ def canonical_plan_json(plan: Mapping[str, Any]) -> str: Cross-language equality is asserted on this string rather than on a parsed object, so key order and whitespace cannot make two identical plans look different -- the same discipline `packvium.commerce.canonical_json` applies to a quote. + + The spelling is RFC 8785, shared with the operational artifact. Until 1.3.0 this + was `json.dumps(sort_keys=True)`, which is the same bytes for every plan an engine emits + but not for a string holding U+2028, a key outside the Basic Multilingual Plane, or a + float, where the four adapters disagreed. """ - return json.dumps(plan, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return canonical_json(plan) diff --git a/src/packvium/pareto.py b/src/packvium/pareto.py index 59e89ac..4676519 100644 --- a/src/packvium/pareto.py +++ b/src/packvium/pareto.py @@ -139,7 +139,7 @@ def dominates(a: Mapping[str, float], b: Mapping[str, float], higher_is_better: return at_least_as_good and strictly_better_somewhere -def _pareto_frontier(candidates: Sequence[CandidateResult], higher_is_better: Mapping[str, bool]) -> tuple[tuple[str, ...], tuple[str, ...]]: +def _pairwise_frontier(candidates: Sequence[CandidateResult], higher_is_better: Mapping[str, bool]) -> tuple[tuple[str, ...], tuple[str, ...]]: optimal: list[str] = [] dominated: list[str] = [] for candidate in candidates: @@ -151,6 +151,52 @@ def _pareto_frontier(candidates: Sequence[CandidateResult], higher_is_better: Ma return tuple(sorted(optimal)), tuple(sorted(dominated)) +def _pareto_frontier(candidates: Sequence[CandidateResult], higher_is_better: Mapping[str, bool]) -> tuple[tuple[str, ...], tuple[str, ...]]: + axes = tuple(higher_is_better.items()) + # Keep the public comparison path for invalid or custom numeric inputs, including + # its historical error order. Caller mappings may have changed since construction. + if len(candidates) < 2 or any( + any(axis not in candidate.metrics for axis, _ in axes) + or any(type(value) not in (int, float) or (isinstance(value, float) and math.isnan(value)) + for value in candidate.metrics.values()) + for candidate in candidates + ): + return _pairwise_frontier(candidates, higher_is_better) + vectors = [tuple(-candidate.metrics[axis] if higher else candidate.metrics[axis] + for axis, higher in axes) for candidate in candidates] + if len(axes) == 2: + optimal_indices = _two_axis_frontier(vectors) + else: + optimal_indices = { + index for index, vector in enumerate(vectors) + if not any(other != vector and all(a <= b for a, b in zip(other, vector)) + for other in vectors) + } + return (tuple(sorted(candidate.engine for index, candidate in enumerate(candidates) if index in optimal_indices)), + tuple(sorted(candidate.engine for index, candidate in enumerate(candidates) if index not in optimal_indices))) + + +def _two_axis_frontier(vectors: Sequence[tuple]) -> set[int]: + ordered = sorted(range(len(vectors)), key=vectors.__getitem__) + optimal: set[int] = set() + best_y = None + position = 0 + while position < len(ordered): + first = ordered[position] + x, y = vectors[first] + survives = best_y is None or y < best_y + end = position + while end < len(ordered) and vectors[ordered[end]][0] == x: + index = ordered[end] + if survives and vectors[index][1] == y: + optimal.add(index) + end += 1 + if survives: + best_y = y + position = end + return optimal + + def generate_report(candidates: Sequence[CandidateResult], higher_is_better: Mapping[str, bool]) -> tuple[ProfileReport, ...]: """One `ProfileReport` per distinct `profile` among `candidates`, sorted by profile name for a deterministic report order. `higher_is_better` must be supplied diff --git a/src/packvium/solvers.py b/src/packvium/solvers.py index 7ddd569..ec9f095 100644 --- a/src/packvium/solvers.py +++ b/src/packvium/solvers.py @@ -883,6 +883,7 @@ def beam_pack(container: Container, sequence: int, items: Sequence[ItemInstance] else: greedy_unplaced = (*greedy_unplaced, *batch) incumbent: tuple[ContainerState, tuple[ItemInstance, ...]] = (greedy, greedy_unplaced) + incumbent_key = _node_key(incumbent) for position, batch in enumerate(batches): expansions: list[tuple[ContainerState, tuple[ItemInstance, ...]]] = [] exhausted = False @@ -909,8 +910,10 @@ def beam_pack(container: Container, sequence: int, items: Sequence[ItemInstance] future = tuple(item for later in batches[position + 1:] for item in later) for state, unplaced in expansions: candidate = (state, (*unplaced, *future)) - if _node_key(candidate) < _node_key(incumbent): + candidate_key = _node_key(candidate) + if candidate_key < incumbent_key: incumbent = candidate + incumbent_key = candidate_key if not expansions: break expansions.sort(key=lambda node: _node_key(node, future)) @@ -923,7 +926,7 @@ def beam_pack(container: Container, sequence: int, items: Sequence[ItemInstance] state, unplaced = incumbent return SingleContainerSolution(state, unplaced, False, deadline.expired) completed = min(beam, key=_node_key) if beam else incumbent - state, unplaced = min((completed, incumbent), key=_node_key) + state, unplaced = completed if _node_key(completed) <= incumbent_key else incumbent return SingleContainerSolution(state, unplaced) diff --git a/src/packvium/spatial_index.py b/src/packvium/spatial_index.py index a397e81..581f38a 100644 --- a/src/packvium/spatial_index.py +++ b/src/packvium/spatial_index.py @@ -19,6 +19,7 @@ from __future__ import annotations +from itertools import chain from typing import Iterable, Sequence Bound = tuple[int, int, int, int, int, int] @@ -117,14 +118,7 @@ def _collect(self, ix1: int, ix2: int, iy1: int, iy2: int, iz1: int, iz2: int) - if bucket: hits.append(bucket) if not hits: return () if len(hits) == 1: return hits[0] - seen: set[int] = set() - found: list[int] = [] - for bucket in hits: - for index in bucket: - if index not in seen: - seen.add(index) - found.append(index) - return found + return list(dict.fromkeys(chain.from_iterable(hits))) def build(bounds: Iterable[Bound], length_ticks: int, width_ticks: int, height_ticks: int) -> SpatialIndex: diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py new file mode 100644 index 0000000..78baef9 --- /dev/null +++ b/tests/test_artifacts.py @@ -0,0 +1,380 @@ +"""The operational artifact and its exports. + +`docs/OPERATIONAL-ARTIFACTS.md` is the contract. The assertions follow what the document +promises the artifact will not do: re-derive the plan or its order, address a placement by +`item_id`, let wall-clock time in, claim an exact replay it cannot give, or re-render a value +the result already rendered. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path + +import pytest + +from packvium.artifact_exports import CSV_COLUMNS, export_csv, export_json, export_work_order_html +from packvium.artifacts import ( + FORMAT, + SUITE_VERSION, + OperationalArtifactError, + build_operational_artifact, + canonical_artifact_json, +) +from packvium.execution import build_execution_plan, canonical_plan_json + +TICKS_PER_MM = 16000 + + +def _scalar(ticks: int, unit: str = "mm") -> dict: + per_unit = TICKS_PER_MM if unit == "mm" else 8000 + return {"ticks": ticks, "unit": unit, "value": str(ticks // per_unit)} + + +def _placement(item_type: str, x_mm: int, length_mm: int = 10, orientation: str = "LWH") -> dict: + return { + "item_id": f"{item_type}#{x_mm}", + "item_type": item_type, + "orientation": orientation, + "position": {axis: _scalar((x_mm if axis == "x" else 0) * TICKS_PER_MM) for axis in ("x", "y", "z")}, + "dimensions": {axis: _scalar((length_mm if axis == "length" else 10) * TICKS_PER_MM) + for axis in ("length", "width", "height")}, + "support_ratio": "1.000000", + "top_load": _scalar(0, "g"), + } + + +def _result(*, algorithm: object = "default", placements=None, unpacked=None) -> dict: + result = { + "status": "feasible", + "objective": "default", + "score": [1, 0, 250], + "feasibility": {"code": "all_items_packed"}, + "optimality": None, + "containers": [{ + "id": "crate#1", + "container_type": "crate", + "inner_dimensions": {axis: _scalar(100 * TICKS_PER_MM) for axis in ("length", "width", "height")}, + "payload_weight": _scalar(8000, "g"), + "gross_weight": _scalar(16000, "g"), + "volume_utilization": "0.002000", + "placements": placements if placements is not None else [_placement("box", 0), _placement("tin", 10, 5)], + }], + "unpacked_items": unpacked or [], + "catalog_versions_used": [{"catalog_id": "cartons", "version": 3, "effective_at": 10, "resolved_at": 11}], + } + if algorithm == "default": + result["algorithm"] = {"profile": "balanced", "solver": "extreme_point", "seed": 7, "duration_ms": 41, + "time_limit_reached": False, "effort_limit_reached": False} + elif algorithm is not None: + result["algorithm"] = algorithm + return result + + +REQUEST = {"items": [{"id": "box", "dimensions": {"length": 10, "width": 10, "height": 10}, + "minimum_support_ratio": 0.25}], + "containers": [{"id": "crate", "inner_dimensions": {"length": 100, "width": 100, "height": 100}}]} + + +def _code(callable_, *arguments, **options) -> str: + with pytest.raises(OperationalArtifactError) as refused: + callable_(*arguments, **options) + return refused.value.code + + +# --------------------------------------------------------------------------- the document + + +def test_the_plan_inside_is_exactly_the_plan_builder_output(): + orders = {0: [1, 0]} + artifact = build_operational_artifact(REQUEST, _result(), loading_orders=orders) + assert artifact["format"] == FORMAT + assert artifact["suite_version"] == SUITE_VERSION + assert canonical_plan_json(artifact["plan"]) == canonical_plan_json( + build_execution_plan(REQUEST, _result(), loading_orders=orders)) + + +def test_the_request_is_embedded_as_given_with_its_fractional_numbers(): + artifact = build_operational_artifact(REQUEST, _result()) + assert artifact["provenance"]["request"] == REQUEST + assert '"minimum_support_ratio":0.25' in canonical_artifact_json(artifact) + + +def test_geometry_is_in_result_order_in_tick_strings_addressed_by_placement_reference(): + container = build_operational_artifact(REQUEST, _result())["geometry"]["containers"][0] + assert container["inner_dimensions"] == {"length": "1600000", "width": "1600000", "height": "1600000"} + assert [entry["placement"]["item_type"] for entry in container["placements"]] == ["box", "tin"] + assert container["placements"][1]["dimensions"]["length"] == "80000" + assert "item_id" not in json.dumps(container) + + +def test_work_order_lines_follow_the_plan_steps_and_copy_rendered_values(): + artifact = build_operational_artifact(REQUEST, _result(), loading_orders={0: [1, 0]}) + work_order = artifact["work_order"] + container = work_order["containers"][0] + assert (work_order["length_unit"], work_order["weight_unit"]) == ("mm", "g") + assert (container["payload_weight"], container["gross_weight"]) == ("1", "2") + assert [(line["sequence"], line["placement"]["item_type"]) for line in container["lines"]] == [(1, "tin"), (2, "box")] + assert container["lines"][0]["position"] == {"x": "10", "y": "0", "z": "0"} + assert [step["placement"] for step in artifact["plan"]["containers"][0]["steps"]] == [ + line["placement"] for line in container["lines"]] + + +def test_without_a_loading_order_no_line_is_numbered(): + lines = build_operational_artifact(REQUEST, _result())["work_order"]["containers"][0]["lines"] + assert all("sequence" not in line for line in lines) + + +def test_a_result_without_containers_has_no_units_and_no_lines(): + result = _result() + result["containers"] = [] + work_order = build_operational_artifact(REQUEST, result)["work_order"] + assert work_order == {"length_unit": None, "weight_unit": None, "containers": []} + + +# ------------------------------------------------------------------------------ provenance + + +def test_the_solver_is_the_deterministic_part_of_the_algorithm_and_wall_clock_never_enters(): + artifact = build_operational_artifact(REQUEST, _result()) + assert artifact["provenance"]["solver"] == {"profile": "balanced", "solver": "extreme_point", "seed": 7, + "time_limit_reached": False, "effort_limit_reached": False} + assert artifact["provenance"]["replay"] == {"level": "exact", "because": None} + assert artifact["provenance"]["catalog_versions_used"][0]["catalog_id"] == "cartons" + assert "duration_ms" not in canonical_artifact_json(artifact) + + +def test_a_search_stopped_by_the_clock_is_not_promised_an_exact_replay(): + algorithm = dict(_result()["algorithm"], time_limit_reached=True) + replay = build_operational_artifact(REQUEST, _result(algorithm=algorithm))["provenance"]["replay"] + assert replay == {"level": "not_guaranteed", "because": "provenance.solver.time_limit_reached"} + + +def test_a_result_that_does_not_say_how_it_was_solved_is_not_promised_one_either(): + provenance = build_operational_artifact(REQUEST, _result(algorithm=None))["provenance"] + assert provenance["solver"] is None + assert provenance["replay"] == {"level": "not_guaranteed", "because": "provenance.solver"} + + +# --------------------------------------------------------------------------------- refusals + + +def test_a_request_that_is_not_an_object_is_refused(): + assert _code(build_operational_artifact, [], _result()) == "invalid_request" + + +def test_a_loading_order_that_is_not_a_permutation_is_refused_by_the_plan(): + assert _code(build_operational_artifact, REQUEST, _result(), loading_orders={0: [0, 0]}) == "invalid_plan_input" + + +def test_values_in_two_units_are_refused_rather_than_printed_as_one(): + result = _result() + result["containers"][0]["placements"][0]["position"]["x"]["unit"] = "cm" + assert _code(build_operational_artifact, REQUEST, result) == "mixed_units" + + +def test_two_placements_with_one_reference_are_refused(): + result = _result(placements=[_placement("box", 0), _placement("box", 0)]) + assert _code(build_operational_artifact, REQUEST, result) == "invalid_result" + + +def test_a_container_without_inner_dimensions_is_refused(): + result = _result() + del result["containers"][0]["inner_dimensions"] + assert _code(build_operational_artifact, REQUEST, result) == "invalid_result" + + +@pytest.mark.parametrize("algorithm", [{"profile": "fast"}, dict(_result()["algorithm"], time_limit_reached="no"), "fast"]) +def test_an_algorithm_record_that_is_incomplete_or_mistyped_is_refused(algorithm): + assert _code(build_operational_artifact, REQUEST, _result(algorithm=algorithm)) == "invalid_result" + + +def test_a_request_number_javascript_cannot_hold_is_refused(): + request = dict(REQUEST, metadata={"order": 2**53}) + assert _code(build_operational_artifact, request, _result()) == "number_out_of_range" + + +def test_a_container_that_is_not_an_object_is_refused_by_name_before_anything_reads_it(): + result = _result() + result["containers"] = ["crate"] + assert _code(build_operational_artifact, REQUEST, result) == "invalid_result" + + +def test_geometry_ticks_javascript_cannot_hold_are_refused(): + """Geometry writes ticks as strings, so the canonical writer never sees them as numbers.""" + result = _result() + result["containers"][0]["placements"][0]["dimensions"]["length"]["ticks"] = 2**53 + assert _code(build_operational_artifact, REQUEST, result) == "number_out_of_range" + + +def test_a_catalog_reference_of_the_wrong_type_is_refused(): + result = _result() + result["catalog_versions_used"][0]["version"] = 1.5 + assert _code(build_operational_artifact, REQUEST, result) == "invalid_result" + + +def test_a_result_that_is_not_an_object_is_refused(): + assert _code(build_operational_artifact, REQUEST, []) == "invalid_result" + + +def test_a_catalog_id_that_is_not_a_string_is_refused(): + result = _result() + result["catalog_versions_used"][0]["catalog_id"] = 7 + assert _code(build_operational_artifact, REQUEST, result) == "invalid_result" + + +@pytest.mark.parametrize("ticks", [1.5, True, "160000"]) +def test_geometry_ticks_that_are_not_an_integer_are_refused(ticks): + """A boolean is an `int` to Python; JSON `true` is not a tick count in any engine.""" + result = _result() + result["containers"][0]["placements"][0]["dimensions"]["length"]["ticks"] = ticks + assert _code(build_operational_artifact, REQUEST, result) == "invalid_result" + + +def test_a_rendered_value_that_is_not_a_string_is_refused(): + result = _result() + result["containers"][0]["placements"][0]["position"]["x"]["value"] = 0 + assert _code(build_operational_artifact, REQUEST, result) == "invalid_result" + + +def test_a_placement_that_is_not_an_object_is_refused_before_the_plan_reads_it(): + result = _result(placements=[_placement("box", 0), "tin"]) + assert _code(build_operational_artifact, REQUEST, result) == "invalid_result" + + +@pytest.mark.parametrize("field", ["containers", "unpacked_items", "catalog_versions_used"]) +def test_a_list_field_that_is_not_a_list_is_refused(field): + result = _result() + result[field] = {"not": "a list"} + assert _code(build_operational_artifact, REQUEST, result) == "invalid_result" + + +def test_the_builder_imports_no_solver_validator_renderer_or_clock(): + allowed = {"__future__", "typing", "decimal", "math", "json", "_canonical_json", "execution", "artifacts"} + for name in ("artifacts.py", "artifact_exports.py", "_canonical_json.py"): + tree = ast.parse((Path(__file__).resolve().parents[1] / "src" / "packvium" / name).read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + assert (node.module or "").split(".")[-1] in allowed, f"{name} imports {node.module}" + elif isinstance(node, ast.Import): + assert all(alias.name in allowed for alias in node.names), f"{name} imports {node.names[0].name}" + + +# ----------------------------------------------------------------------------------- exports + + +def _artifact(**options) -> dict: + return build_operational_artifact(REQUEST, _result(**options), loading_orders={0: [1, 0]}) + + +def test_the_json_export_is_the_canonical_artifact(): + artifact = _artifact() + assert export_json(artifact) == canonical_artifact_json(artifact) + assert json.loads(export_json(artifact)) == json.loads(json.dumps(artifact)) + + +@pytest.mark.parametrize("export", [export_json, export_csv, export_work_order_html]) +def test_every_export_refuses_a_document_it_cannot_read(export): + assert _code(export, dict(_artifact(), format="packvium-operational-artifact/v2")) == "unknown_format" + assert _code(export, ["not", "an", "artifact"]) == "unknown_format" + + +@pytest.mark.parametrize("export", [export_csv, export_work_order_html]) +def test_a_value_with_no_single_rendering_is_refused_rather_than_printed_four_ways(export): + """Python would print `True`, PHP `1`, JavaScript `true`: none of them is the answer.""" + result = _result() + result["containers"][0]["container_type"] = True + assert _code(export, build_operational_artifact(REQUEST, result)) == "invalid_value" + + +def test_an_export_depends_on_the_canonical_bytes_not_on_how_python_holds_them(): + """A `1.0` seed in memory is the `1` every other engine reads back from the same bytes.""" + in_memory = _artifact() + in_memory["provenance"]["solver"]["seed"] = 7.0 + read_back = json.loads(canonical_artifact_json(in_memory)) + for export in (export_json, export_csv, export_work_order_html): + assert export(in_memory) == export(read_back) + + +@pytest.mark.parametrize("index", [0.5, True]) +def test_a_hand_edited_container_index_is_refused_rather_than_numbered(index): + artifact = _artifact() + artifact["work_order"]["containers"][0]["container_index"] = index + assert _code(export_work_order_html, artifact) == "invalid_value" + + +def test_the_csv_has_a_header_one_row_per_step_in_order_and_one_per_unplaced_item(): + unpacked = [{"item_id": "jack#1", "item_type": "jack", "reason": "no_compatible_container_dimensions", + "details": [], "proof": {"level": "proven"}}] + rows = export_csv(_artifact(unpacked=unpacked)).split("\r\n") + assert rows[0] == ",".join(CSV_COLUMNS) + assert rows[1] == "step,0,crate,1,tin,LWH,160000,0,0,10,0,0,5,10,10,mm,," + assert rows[2] == "step,0,crate,2,box,LWH,0,0,0,0,0,0,10,10,10,mm,," + assert rows[3] == "unplaced,,,,jack,,,,,,,,,,,,no_compatible_container_dimensions,proven" + assert rows[4] == "" and len(rows) == 5 + + +def test_a_csv_field_is_quoted_only_when_it_must_be_and_never_rewritten(): + result = _result(placements=[_placement('a,"b"', 0), _placement("=SUM(A1)", 10), _placement("two\nlines", 20)]) + text = export_csv(build_operational_artifact(REQUEST, result)) + assert ',"a,""b""",' in text + assert ",=SUM(A1)," in text + assert ',"two\nlines",' in text + + +def test_the_work_order_is_self_contained_escaped_and_lists_every_step(): + result = _result(placements=[_placement("&'\"", 0), _placement("tin", 10, 5)]) + html = export_work_order_html(build_operational_artifact(REQUEST, result, loading_orders={0: [1, 0]})) + assert html.startswith("\n") and html.endswith("\n") + assert "&'" not in html + assert html.count("☐") == 2 + assert "Order: loading." in html and "extreme_point" in html and "cartons v3" in html + assert html.isascii() + + +def test_the_work_order_says_when_there_is_no_order_and_when_everything_was_packed(): + html = export_work_order_html(build_operational_artifact(REQUEST, _result())) + assert "Order: unavailable." in html + assert "

Every item was packed.

" in html + + +def test_exports_are_deterministic_and_leave_the_artifact_untouched(): + artifact = _artifact() + before = canonical_artifact_json(artifact) + for export in (export_json, export_csv, export_work_order_html): + assert export(artifact) == export(artifact) + assert canonical_artifact_json(artifact) == before + + +# ------------------------------------------------------------------------------ the corpus + +ROOT = Path(__file__).resolve().parents[2] +SCHEMAS = ROOT / "conformance" / "schema" + + +@pytest.mark.skipif(not (ROOT / "conformance" / "golden").is_dir(), reason="the corpus lives in the workspace only") +def test_every_golden_result_with_its_request_builds_a_schema_valid_artifact(): + jsonschema = pytest.importorskip("jsonschema") + referencing = pytest.importorskip("referencing") + schemas = {name: json.loads((SCHEMAS / name).read_text()) + for name in ("operational-artifact.schema.json", "execution-plan.schema.json")} + registry = referencing.Registry().with_resources( + (schema["$id"], referencing.Resource.from_contents(schema)) for schema in schemas.values()) + validator = jsonschema.Draft202012Validator(schemas["operational-artifact.schema.json"], registry=registry) + built = 0 + for golden in sorted((ROOT / "conformance" / "golden").glob("*.json")): + result = json.loads(golden.read_text()) + if not isinstance(result, dict) or "status" not in result: + continue + request = json.loads((ROOT / "conformance" / "fixtures" / golden.name).read_text()) + orders = {index: list(reversed(range(len(container.get("placements") or [])))) + for index, container in enumerate(result.get("containers") or [])} + artifact = build_operational_artifact(request, result, loading_orders=orders) + errors = list(validator.iter_errors(json.loads(canonical_artifact_json(artifact)))) + assert errors == [], f"{golden.name}: {errors[0].message if errors else ''}" + export_csv(artifact) + export_work_order_html(artifact) + built += 1 + assert built >= 398 diff --git a/tests/test_canonical_json.py b/tests/test_canonical_json.py new file mode 100644 index 0000000..59d409f --- /dev/null +++ b/tests/test_canonical_json.py @@ -0,0 +1,98 @@ +"""RFC 8785 canonical JSON, the spelling the execution plan and the operational artifact share. + +What is checked is where the language defaults disagree, because that is where a +cross-language byte comparison fails: integral floats, exponents, key order outside the +Basic Multilingual Plane, the characters JSON writers escape differently, and the values no +engine can carry exactly. Where Node is installed its own `JSON.stringify` is the oracle for +numbers, since RFC 8785 defines them as ECMAScript writes them. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from packvium._canonical_json import CanonicalJsonError, canonical_json + +NUMBERS = [ + (0.25, "0.25"), (1.0, "1"), (-0.0, "0"), (4.5, "4.5"), (0.1, "0.1"), (2e-3, "0.002"), + (0.000001, "0.000001"), (1e-7, "1e-7"), (1.5e-7, "1.5e-7"), (1e-27, "1e-27"), (-1.5, "-1.5"), + (123456789.5, "123456789.5"), (333333333.33333329, "333333333.3333333"), + (9007199254740991.0, "9007199254740991"), (5e-324, "5e-324"), (100.0, "100"), +] + + +@pytest.mark.parametrize(("value", "spelled"), NUMBERS) +def test_numbers_are_written_as_ecmascript_writes_them(value, spelled): + assert canonical_json(value) == spelled + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") +def test_every_number_matches_javascript_itself(): + values = [value for value, _ in NUMBERS] + script = "process.stdout.write(JSON.stringify(JSON.parse(require('fs').readFileSync(0,'utf8'))))" + completed = subprocess.run(["node", "-e", script], input=json.dumps(values), capture_output=True, + text=True, check=True) + assert completed.stdout == canonical_json(values) + + +def test_integers_and_literals_keep_their_spelling(): + assert canonical_json([0, -7, 9007199254740991, True, False, None]) == "[0,-7,9007199254740991,true,false,null]" + + +def test_keys_are_sorted_by_utf16_code_units_not_code_points(): + """U+FFFD sorts after U+1F600 by code point and before it by UTF-16 code unit, whose + first unit is the high surrogate U+D83D. JavaScript sorts the second way.""" + assert canonical_json({"�": 1, "\U0001F600": 2, "a": 0}) == '{"a":0,"\U0001F600":2,"�":1}' + + +def test_only_the_characters_rfc_8785_names_are_escaped(): + text = "\"\\\b\t\n\f\r\x00\x1f/\x7f

é" + assert canonical_json(text) == '"\\"\\\\\\b\\t\\n\\f\\r\\u0000\\u001f/\x7f

é"' + + +def test_there_is_no_whitespace_and_nesting_is_preserved(): + assert canonical_json({"b": [1, {"d": [], "c": {}}], "a": "x"}) == '{"a":"x","b":[1,{"c":{},"d":[]}]}' + + +@pytest.mark.parametrize("value", [2**53, -(2**53), 9007199254740992.0, float("inf"), float("nan")]) +def test_a_number_no_engine_holds_exactly_is_refused(value): + with pytest.raises(CanonicalJsonError) as refused: + canonical_json({"n": value}) + assert refused.value.code == "number_out_of_range" + + +def test_a_lone_surrogate_is_refused_in_a_value_and_in_a_key(): + for value in ("\ud800", {"\udc00": 1}): + with pytest.raises(CanonicalJsonError) as refused: + canonical_json(value) + assert refused.value.code == "invalid_string" + + +@pytest.mark.parametrize("value", [{1: "a"}, {"a": {1, 2}}, b"bytes"]) +def test_a_value_json_cannot_spell_is_refused(value): + with pytest.raises(CanonicalJsonError) as refused: + canonical_json(value) + assert refused.value.code == "invalid_value" + + +GOLDEN = Path(__file__).resolve().parents[2] / "conformance" / "golden" + + +@pytest.mark.skipif(not GOLDEN.is_dir(), reason="the golden corpus lives in the workspace only") +def test_no_plan_an_engine_emits_changed_its_bytes_when_the_spelling_became_rfc_8785(): + """1.2.0 published `json.dumps(sort_keys=True)` plans. For every golden result the two + spellings are the same bytes, so the switch fixes only inputs the adapters disagreed on.""" + from packvium.execution import build_execution_plan, canonical_plan_json + + for golden in sorted(GOLDEN.glob("*.json")): + result = json.loads(golden.read_text()) + if not isinstance(result, dict) or "status" not in result: + continue + plan = build_execution_plan({}, result) + assert canonical_plan_json(plan) == json.dumps(plan, sort_keys=True, separators=(",", ":"), + ensure_ascii=False), golden.name diff --git a/tests/test_commerce_lookup.py b/tests/test_commerce_lookup.py new file mode 100644 index 0000000..5df3bb9 --- /dev/null +++ b/tests/test_commerce_lookup.py @@ -0,0 +1,99 @@ +"""Lookup optimizations preserve public model values and compatibility behavior.""" +import pytest + +from packvium.commerce.catalog import ( + CatalogRegistry, CatalogSnapshot, CatalogVersionNotFoundError, + CatalogEntryNotFoundError, ItemMaster, CartonMaster, +) +from packvium.commerce.rating import CarrierRegistry, TariffNotFoundError + + +def make_item(id="widget"): + return ItemMaster(id, (80, 60, 40), 250) + + +def make_snapshot(): + return CatalogSnapshot(items=(make_item(),), + cartons=(CartonMaster("box-s", (180, 140, 100), 5000),)) + + +def test_pinned_history_lookup_preserves_boundaries_rollbacks_and_numeric_compatibility(): + registry = CatalogRegistry("lookup") + snapshot = make_snapshot() + for number in range(1, 65): + registry.publish(snapshot, effective_at=number % 7, published_at=number) + for number in (1, 2, 32, 64): + assert registry.resolve(version=number, resolved_at=100).reference.version == number + for number in (-10, 0, 65, 10 ** 100, 1.5, "1"): + with pytest.raises(CatalogVersionNotFoundError): + registry.resolve(version=number, resolved_at=100) + assert registry.resolve(version=True, resolved_at=100).reference.version == 1 + assert registry.resolve(version=2.0, resolved_at=100).reference.version == 2 + rollback = registry.rollback(1, published_at=100, effective_at=6) + assert rollback.number == 65 + assert registry.resolve(version=65, resolved_at=100).snapshot is snapshot + assert registry.resolve(as_of=6, resolved_at=100).reference.version == 65 + assert registry.resolve(as_of=0, resolved_at=100).reference.version == 63 + + +def test_snapshot_lookup_indexes_do_not_change_value_copy_or_serialization(): + import copy + from dataclasses import asdict, fields + import pickle + + snapshot = make_snapshot() + before = asdict(snapshot) + before_hash = hash(snapshot) + assert snapshot.item('widget') is snapshot.items[0] + assert snapshot.carton('box-s') is snapshot.cartons[0] + assert asdict(snapshot) == before + assert hash(snapshot) == before_hash + assert [field.name for field in fields(snapshot)] == ['items', 'cartons', 'pallets', 'exclusions', 'overrides'] + for copied in (copy.copy(snapshot), copy.deepcopy(snapshot), pickle.loads(pickle.dumps(snapshot))): + assert copied == snapshot + assert copied.item('widget') == snapshot.items[0] + assert asdict(copied) == before + restored = CatalogSnapshot.__new__(CatalogSnapshot) + restored.__setstate__({field.name: getattr(snapshot, field.name) for field in fields(snapshot)}) + assert restored.item('widget') == snapshot.items[0] + + +def test_snapshot_lookup_preserves_mutable_and_custom_input_behavior(): + entries = [make_item('first')] + snapshot = CatalogSnapshot(items=entries) + assert snapshot.item('first') is entries[0] + entries.append(make_item('second')) + assert snapshot.item('second') is entries[1] + class MutableEntry: + id = 'old' + entry = MutableEntry() + custom = CatalogSnapshot(items=(entry,)) + assert custom.item('old') is entry + entry.id = 'new' + assert custom.item('new') is entry + with pytest.raises(CatalogEntryNotFoundError): + custom.item('old') + + +def test_indexed_snapshot_lookup_keeps_rejection_for_non_string_identifiers(): + snapshot = make_snapshot() + snapshot.item('widget') + with pytest.raises(CatalogEntryNotFoundError): + snapshot.item(1) + + +def test_pinned_and_effective_history_keep_numeric_and_date_boundaries(): + registry = CarrierRegistry() + for index in range(1, 65): + registry.publish('acme-freight', 'ground', effective_at=index % 7, + dimensional_weight_divisor=5000, + cost_per_dimensional_kg_minor={'zone-1': 500}) + for version in (1, 32, 64, 2.0, True): + assert registry.tariff('acme-freight', 'ground', version).version == version + for version in (-1, 0, 65, 10 ** 100, 1.5, '1'): + with pytest.raises(TariffNotFoundError): + registry.tariff('acme-freight', 'ground', version) + assert registry.effective_tariff('acme-freight', 'ground', as_of=6).version == 62 + assert registry.effective_tariff('acme-freight', 'ground', as_of=0).version == 63 + with pytest.raises(TariffNotFoundError): + registry.effective_tariff('acme-freight', 'ground', as_of=-1) diff --git a/tests/test_compat.py b/tests/test_compat.py index 90f16a6..89cfb92 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -8,7 +8,7 @@ #: sibling here would bake in a path that does not exist for anyone who installed it. EXTRA_SOURCE_ROOTS = "PACKVIUM_EXTRA_PYTHON_SOURCE_ROOTS" -from packvium._compat import _dataclass_options, dataclass +from packvium._compat import _dataclass_options, _reinstate_state_methods, dataclass def test_python_39_drops_only_the_unsupported_slots_option(): @@ -33,6 +33,42 @@ class Value: assert Value(7).amount == 7 +def test_compat_dataclass_keeps_the_state_methods_a_class_defines(): + # Python 3.10's slotted frozen dataclass replaced both with list-only versions, so a + # dictionary state -- what a non-slotted runtime pickles -- restored garbage there. + @dataclass(frozen=True, slots=True) + class Pair: + left: int + right: int + + def __getstate__(self): + return [self.left, self.right] + + def __setstate__(self, state): + values = [state["left"], state["right"]] if isinstance(state, dict) else state + object.__setattr__(self, "left", values[0]) + object.__setattr__(self, "right", values[1]) + + restored = Pair.__new__(Pair) + restored.__setstate__({"left": 1, "right": 2}) + assert (restored.left, restored.right) == (1, 2) + assert Pair(3, 4).__getstate__() == [3, 4] + + +def test_a_replaced_state_method_is_put_back_and_an_untouched_one_is_left_alone(): + # What Python 3.10's decorator does, reproduced on any interpreter. + class Holder: + def __setstate__(self, state): + return "own" + + own = {"__setstate__": Holder.__dict__["__setstate__"]} + Holder.__setstate__ = lambda self, state: "replaced" + assert _reinstate_state_methods(Holder, own) is Holder + assert Holder().__setstate__({}) == "own" + assert _reinstate_state_methods(Holder, own) is Holder + assert Holder().__setstate__({}) == "own" + + def test_all_consumer_python_sources_parse_with_the_python_39_grammar(): package = Path(__file__).resolve().parents[1] roots = [package / "src"] diff --git a/tests/test_pareto.py b/tests/test_pareto.py index d9789af..368af62 100644 --- a/tests/test_pareto.py +++ b/tests/test_pareto.py @@ -180,3 +180,22 @@ def test_a_winner_cannot_be_named_when_two_survive(self): def test_no_winner_alongside_a_frontier_of_two_is_well_formed(self): report = ProfileReport(profile="fast", pareto_optimal=("php", "python"), dominated=()) assert report.winner is None + + +def test_prepared_frontiers_match_independent_pairwise_comparison(): + import random + + rng = random.Random(914) + values = [-float('inf'), -(10 ** 100), -1, -0.0, 0, 1, 10 ** 100, float('inf')] + for dimension in (1, 2, 3, 5): + for scene in range(30): + directions = {f'axis{i}': bool(rng.randrange(2)) for i in range(dimension)} + candidates = [CandidateResult('p', str(i), {axis: rng.choice(values) for axis in directions}) + for i in range(2 + scene * 2)] + expected = tuple(sorted(candidate.engine for candidate in candidates + if not any(other is not candidate and dominates(other.metrics, candidate.metrics, directions) + for other in candidates))) + report, = generate_report(candidates, directions) + assert report.pareto_optimal == expected + assert report.dominated == tuple(sorted(set(c.engine for c in candidates) - set(expected))) + assert generate_report(list(reversed(candidates)), directions) == (report,) diff --git a/tests/test_spatial_index.py b/tests/test_spatial_index.py index 360fc50..abcc380 100644 --- a/tests/test_spatial_index.py +++ b/tests/test_spatial_index.py @@ -113,3 +113,12 @@ def test_a_box_spanning_many_cells_is_found_from_any_of_them(): index.add(0, big) for x in (0, 200, 400, 600, 790): assert 0 in set(index.query(x, 0, 0, x + 5, 5, 5)), x + + +def test_multi_bucket_collection_preserves_first_appearance(): + index = SpatialIndex(80, 80, 80) + index.cells[(0, 0, 0)] = (3, 1) + index.cells[(1, 0, 0)] = (1, 2, 3) + assert index.query(0, 0, 0, 20, 1, 1) == [3, 1, 2] + assert index.query(0, 0, 0, 1, 1, 1) is index.cells[(0, 0, 0)] + assert index.query(70, 70, 70, 80, 80, 80) == ()