From 1cf93e32878c7cb78a3156c0af2c51c2b8628118 Mon Sep 17 00:00:00 2001 From: Anton Kara Date: Thu, 20 Aug 2026 12:49:27 -0400 Subject: [PATCH] chore(release): sync 0.1.1 from workspace --- .DS_Store | Bin 6148 -> 6148 bytes CHANGELOG.md | 43 +++ MANIFEST.in | 17 + README.md | 18 +- docs/COMMERCE-API.md | 392 ++++++++++++++++++++++ docs/GUARANTEES.md | 2 +- docs/PUBLIC-API.md | 32 ++ docs/README.md | 1 + examples/commerce.py | 171 ++++++++++ examples/constraints.py | 118 +++++++ examples/nested.py | 91 +++++ pyproject.toml | 2 +- src/packvium/commerce/__init__.py | 48 +++ src/packvium/commerce/api.py | 366 +++++++++++++++++++++ src/packvium/commerce/catalog.py | 468 ++++++++++++++++++++++++++ src/packvium/commerce/document.py | 397 ++++++++++++++++++++++ src/packvium/commerce/errors.py | 37 +++ src/packvium/commerce/policy.py | 349 ++++++++++++++++++++ src/packvium/commerce/rating.py | 336 +++++++++++++++++++ src/packvium/extensions.py | 50 ++- src/packvium/models.py | 34 +- src/packvium/packer.py | 51 ++- src/packvium/rebalance.py | 36 +- src/packvium/solvers.py | 77 ++++- tests/test_commerce_api.py | 431 ++++++++++++++++++++++++ tests/test_commerce_edge_cases.py | 530 ++++++++++++++++++++++++++++++ tests/test_commerce_models.py | 380 +++++++++++++++++++++ tests/test_objective.py | 278 +++++++++++++++- tests/test_rebalance.py | 101 ++++++ 29 files changed, 4822 insertions(+), 34 deletions(-) create mode 100644 MANIFEST.in create mode 100644 docs/COMMERCE-API.md create mode 100644 examples/commerce.py create mode 100644 examples/constraints.py create mode 100644 examples/nested.py create mode 100644 src/packvium/commerce/__init__.py create mode 100644 src/packvium/commerce/api.py create mode 100644 src/packvium/commerce/catalog.py create mode 100644 src/packvium/commerce/document.py create mode 100644 src/packvium/commerce/errors.py create mode 100644 src/packvium/commerce/policy.py create mode 100644 src/packvium/commerce/rating.py create mode 100644 tests/test_commerce_api.py create mode 100644 tests/test_commerce_edge_cases.py create mode 100644 tests/test_commerce_models.py diff --git a/.DS_Store b/.DS_Store index f20f12d8af1f8f6646e3acfe0006484f46dae516..0d95b9481ed71b334116d7f6b9402e73d57b96f7 100644 GIT binary patch delta 335 zcmZoMXffE}!=l5KaK2=-^a)1>1_pKpJ%)6KOokGM42DvMq}==zm!zEhB%lljMAhU9 z7Q46ui-7_x3@Hrx49N_|x%n<|MKek+!i++aM;L-lz97ReI5|JJ0B9x#3sqaeqpjSn+!9%#mRkrR%}vi;lWpqUKO7l#U=w|*09Mk Qu`SqGn8Uc4o#QV*03vZ;pa1{> diff --git a/CHANGELOG.md b/CHANGELOG.md index 548f424..bb1897f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/) and thi adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the caveat that the public API is not frozen until `1.0.0`. Pin an exact version. +## [0.1.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. + +### 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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..826fe83 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,17 @@ +# What the source distribution carries beyond the importable package. +# +# setuptools builds an sdist from the declared packages plus a short list of standard +# metadata files, and silently drops everything else -- which left `pip download +# packvium` giving you the library and nothing to read. Every sibling port already ships +# both: the Rust crate carries its examples to crates.io, and the PHP and npm packages +# carry theirs. There is no reason Python should be the one ecosystem where you have to +# find the repository to see a worked example. +graft examples +graft tests + +# `graft` is recursive and takes everything it finds, including whatever bytecode the +# last local test run left behind. Compiled caches are host- and interpreter-specific +# and have no business in a published artifact. +global-exclude __pycache__ +global-exclude *.py[cod] +global-exclude .DS_Store diff --git a/README.md b/README.md index 98f74ec..1547139 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Deterministic 3D cartonization and rectangular bin packing. Pure Python, **no runtime dependencies**, exact integer geometry. -> **Version 0.1.0 — early release.** The public API is not frozen; pin an exact version. +> **Version 0.1.1 — early release.** The public API is not frozen; pin an exact version. > Read [docs/GUARANTEES.md](docs/GUARANTEES.md) before relying on a result. ```bash @@ -40,6 +40,22 @@ echo '{"items":[{"id":"box","quantity":8,"dimensions":{"length":"50","width":"50 | python -m packvium ``` +## Examples + +Runnable, in [`examples/`](examples). Each one is a single file you can read top to bottom +and execute without a project around it. + +| File | What it shows | +| --- | --- | +| [`basic.py`](examples/basic.py) | The smallest useful call: items in, placements out. | +| [`constraints.py`](examples/constraints.py) | Upright-only, floor-only, non-stackable, top-load limits, and tags that keep two items out of the same box — plus how to read the reason an item was refused. | +| [`nested.py`](examples/nested.py) | Units into cartons, cartons onto a pallet, in one call. | +| [`commerce.py`](examples/commerce.py) | Rate a shipment, apply an eligibility rule, and pin a catalog version. | + +```bash +python3 examples/constraints.py +``` + ## What it does - **Exact arithmetic.** Length is measured in ticks of 1/16000 mm and weight in 1/8 µg. diff --git a/docs/COMMERCE-API.md b/docs/COMMERCE-API.md new file mode 100644 index 0000000..6237164 --- /dev/null +++ b/docs/COMMERCE-API.md @@ -0,0 +1,392 @@ +# Commercial and control-plane API + +The packing engines and the commercial layer around them — carrier rating, +eligibility/policy evaluation and catalog versioning — are public APIs. This document +defines the latter contract: three exported +functions, one canonical input document, one canonical result shape and one closed set +of rejection codes, identical in all four languages. + +**No packing-request or packing-result schema field is added or changed by this API.** +Both existing wire schemas are untouched. `container.rate_table` +and `policy` are already public wire fields; what was missing was the +catalog/versioning layer *around* them and a callable entry point, not a field. + +## Design rules + +1. **One implementation per language, never two.** Python's export is the workspace + modules themselves, relocated into the installable package with the workspace paths + kept alive as re-export shims (see [Traceability](#traceability)). PHP, Rust and + JavaScript are independent implementations of this contract, held to the same + cross-language standard as every other capability in this project (see + [Conformance standard](#conformance-standard)). +2. **Data in, data out.** Every function takes plain JSON-shaped data and returns + plain JSON-shaped data. No registry object crosses the API boundary, so the same + fixture can drive all four languages over a subprocess boundary — the only way the + conformance harness can check that they agree. +3. **Deterministic, no clock.** Nothing reads wall-clock time. Every "which version + applies" question is answered from an explicit `version` pin or an explicit `as_of` + value supplied by the caller, so a stored result replays byte-for-byte. +4. **Exact integers only.** Ticks for length, grams for weight, minor currency units + for money, permille for percentage-shaped rates. Every inexact division rounds up. + No floats appear anywhere in the input, the arithmetic or the output. +5. **One ordering: by Unicode code point.** Every sorted list in a result — the catalog + id lists, the accessorial ids in an `unavailable_accessorial` rejection — and every + deterministic tie-break on an id is ordered by code point, never by a locale + collation and never by UTF-16 code unit. The distinction is not academic: by code + unit, an emoji sorts *before* a fullwidth Latin A, and by code point it sorts after. + `conformance/commerce/fixtures/catalog-unicode-id-ordering.json` and + `policy-astral-rule-id-tie-break.json` hold every implementation to this. +6. **Structured rejections, not silent zeros.** A zone with no rate, an accessorial the + tariff does not offer, a catalog version that does not exist — each is a named + rejection code with structured fields, never an empty or zero-valued success. + +## Exported functions + +| Function | Python | PHP | Rust | JavaScript | +| --- | --- | --- | --- | --- | +| Quote | `packvium.commerce.quote(document, request)` | `Packvium\Commerce\quote(array $document, array $request)` | `packvium_core::commerce::quote_json(&str)` | `import { commerce } from '@packvium/engine'; commerce.quote(document, request)` | +| Policy | `packvium.commerce.evaluate_policy(document, request)` | `Packvium\Commerce\evaluatePolicy(...)` | `packvium_core::commerce::evaluate_policy_json(&str)` | `commerce.evaluatePolicy(document, request)` | +| Catalog | `packvium.commerce.catalog_version_info(document, request)` | `Packvium\Commerce\catalogVersionInfo(...)` | `packvium_core::commerce::catalog_version_info_json(&str)` | `commerce.catalogVersionInfo(document, request)` | + +The original API proposal sketches these as `quote(request, catalog_version, +policy_version)`, `evaluate_policy(request, policy_version)` and +`catalog_version_info(version)`. The version pins are carried *inside* the request +object rather than as positional arguments, for one reason: a pin is only meaningful +against the history it indexes into, so the history (`document`) and the pin +(`request.tariff_version` / `request.rule_versions` / `request.version`) must arrive +together or a caller can pin version 3 of a document that has two. The information +content is identical; the shape makes the invalid combination unrepresentable as two +independent arguments. + +The C ABI adds `packvium_commerce_quote`, `packvium_commerce_evaluate_policy` and +`packvium_commerce_catalog_version_info`, each `const char* -> char*` over the same +JSON, freed with the existing `packvium_free_string`. See +[PUBLIC-API.md](PUBLIC-API.md). + +## The commerce document + +One object holding the three append-only histories. Every history is a list of +versions in publication order; **a version's number is its 1-based position in that +list**, exactly as `CarrierRegistry.publish`, `PolicyRegistry.publish` and +`CatalogRegistry.publish` already number them. A document therefore cannot express a +history with a hole or a duplicated version number. + +```json +{ + "tariffs": [ + { + "carrier_id": "acme", + "service_id": "ground", + "versions": [ + { + "effective_at": 0, + "dimensional_weight_divisor": 5000, + "cost_per_dimensional_kg_minor": {"zone-a": 450, "zone-b": 610}, + "minimum_charge_minor": 900, + "fuel_surcharge_permille": 120, + "accessorials": [ + {"accessorial_id": "liftgate", "flat_charge_minor": 250}, + {"accessorial_id": "residential", "permille_of_base": 75} + ] + } + ] + } + ], + "policy_rules": [ + { + "rule_id": "no-hazmat-air", + "versions": [ + { + "scope": "hazmat", + "action": "reject", + "priority": 10, + "effective_at": 0, + "reason": "class 1.4 is not accepted on air services", + "predicates": [ + {"scope": "hazmat", "field": "un_class", "operator": "equals", "value": "1.4"} + ] + } + ] + } + ], + "catalogs": [ + { + "catalog_id": "dc-12", + "versions": [ + { + "effective_at": 0, + "published_at": 0, + "note": "initial", + "snapshot": { + "items": [ + {"id": "sku-1", "dimensions_mm": [100, 200, 300], "weight_g": 1200, "description": ""} + ], + "cartons": [ + {"id": "box-m", "inner_dimensions_mm": [320, 240, 180], "max_payload_g": 15000, "cost_minor": 85} + ], + "pallets": [ + {"id": "euro", "deck_dimensions_mm": [1200, 800], "max_payload_g": 1000000, + "max_stack_height_mm": 1800} + ], + "exclusions": [ + {"id": "x1", "scope": "item_carton", "subject_id": "sku-1", + "excluded_id": "box-m", "reason": "hazmat"} + ], + "overrides": [ + {"id": "o1", "facility_id": "DC-12", "entry_id": "box-m", + "kind": "carton", + "override": {"id": "box-m", "inner_dimensions_mm": [300, 240, 180], + "max_payload_g": 14000, "cost_minor": 85}} + ] + } + }, + {"rollback_to": 1, "published_at": 900, "effective_at": 900, "note": "revert bad correction"} + ] + } + ] +} +``` + +All three top-level keys are optional; a document that only needs to price a shipment +may carry only `tariffs`. Field-level rules: + +- `accessorials` is an ordered **list**, not an object, so no language has to agree + about key order; each entry sets exactly one of `flat_charge_minor` or + `permille_of_base`. Duplicate `accessorial_id` values are an input error. +- A catalog version is either a full `snapshot` version or a `rollback_to` version + (never both). A rollback publishes a *new*, higher-numbered version whose snapshot + equals the referenced one's; history is never rewritten. +- A facility override names its `kind` (`item` / `carton` / `pallet`) explicitly rather + than leaving it to be inferred from which fields the payload happens to carry. +- An omitted optional field and an explicit JSON `null` mean the same thing: absent. + Writing `"minimum_charge_minor": null` is exactly writing nothing. +- Every integer is exact and, except where a model explicitly allows zero, positive. + The per-field bounds are the ones the models already enforce — see + CATALOG-VERSIONING.md and POLICY-RULES.md. + +### Input errors versus rejections + +A malformed document — a missing required key, a negative weight, an unknown policy +operator, a duplicate id — is an **input error**: `CommerceInputError` in Python, +`Packvium\Commerce\CommerceInputError` in PHP, `Err(CommerceError::Input)` in Rust, a +thrown `CommerceInputError` in JavaScript. It is a caller bug, reported the way each +language reports caller bugs. + +A well-formed request the commercial model cannot answer — no tariff effective as of +that instant, no rate for that zone — is a **rejection**: a successful call returning +`"status": "rejected"` with a code from the closed set below. This mirrors how the +packing API already treats an infeasible request: a `PackingResult` with a status, not +an exception. + +## Result shapes + +Every result is an object with `api_version` (currently `1`) and `status` +(`"ok"` or `"rejected"`). + +### `quote` + +Request: + +```json +{"carrier_id": "acme", "service_id": "ground", "tariff_version": 1, + "zone": "zone-a", "actual_weight_g": 1200, "volume_mm3": 6000000, + "requested_accessorials": ["liftgate"]} +``` + +Exactly one of `tariff_version` (pinned replay) or `as_of` (effective-dated lookup) +must be present. `requested_accessorials` defaults to `[]` and must be unique. + +Success — the fields of `commerce/rating/model.py`'s `RateBreakdown`, one for one: + +```json +{"api_version": 1, "status": "ok", + "quote": {"carrier_id": "acme", "service_id": "ground", "tariff_version": 1, + "zone": "zone-a", "actual_weight_g": 1200, "dimensional_weight_g": 1200, + "billed_weight_g": 1200, "base_charge_minor": 900, + "minimum_charge_applied": true, "fuel_surcharge_minor": 108, + "accessorial_charges_minor": [["liftgate", 250]], "total_minor": 1258}} +``` + +`accessorial_charges_minor` is a list of `[accessorial_id, amount_minor]` pairs in the +order the request asked for them — the same ordering `RateBreakdown` already records, +preserved rather than sorted so a caller can line the charges up against the request. + +`total_minor` is the identical number `commerce/rating/objective.py`'s +`CarrierRateSolutionScorer` and `CarrierRateContainerSelector` already rank containers +and solutions by. That is the point of this function: the price a caller is quoted and +the price the engine optimised against come from one code path. + +### `evaluate_policy` + +Request: + +```json +{"scope": "hazmat", "context": {"un_class": "1.4"}, "as_of": 1000} +``` + +Exactly one of `as_of` or `rule_versions` (a list of `[rule_id, version]` pairs, each +rule id at most once) must be present. `rule_versions` is the pinned-replay form and +resolves through `PolicyRegistry.resolve_versions`, which sorts the pins so the +snapshot is order-independent. + +Success: + +```json +{"api_version": 1, "status": "ok", + "decision": {"scope": "hazmat", "allowed": false, + "citation": {"rule_id": "no-hazmat-air", "version": 1, "action": "reject", + "priority": 10, "reason": "class 1.4 is not accepted on air services"}}} +``` + +`citation` is `null` exactly when nothing matched (the open-by-default ALLOW). A +`false` `allowed` always carries a citation — the model refuses to construct a +citation-free rejection. + +### `catalog_version_info` + +Request: + +```json +{"catalog_id": "dc-12", "version": 2, "resolved_at": 1700} +``` + +`resolved_at` is required. At most one of `version` or `as_of`; supplying neither is +allowed only when the catalog has zero or one published version, and is otherwise the +`ambiguous_catalog_reference` rejection. + +Success — metadata about the version, deliberately *not* the whole snapshot (a caller +who wants the master data resolves it through the catalog itself; this function +answers "which version am I looking at and what does it contain"): + +```json +{"api_version": 1, "status": "ok", + "catalog": {"catalog_id": "dc-12", "version": 2, "effective_at": 900, + "published_at": 900, "resolved_at": 1700, "rolled_back_from": 1, + "note": "revert bad correction", + "entry_counts": {"items": 1, "cartons": 1, "pallets": 1, + "exclusions": 1, "overrides": 1}, + "item_ids": ["sku-1"], "carton_ids": ["box-m"], "pallet_ids": ["euro"]}} +``` + +`rolled_back_from` is `null` for an ordinary publication. The three id lists are sorted +ascending by code-point so no language's map or set ordering can leak into the answer. + +### Rejections + +```json +{"api_version": 1, "status": "rejected", + "error": {"code": "unavailable_zone", + "fields": {"carrier_id": "acme", "service_id": "ground", + "tariff_version": 1, "zone": "zone-z"}}} +``` + +The closed set of codes, and the workspace error each one corresponds to: + +| Code | Raised by | Meaning | +| --- | --- | --- | +| `tariff_not_found` | `TariffNotFoundError` | No history for that `(carrier_id, service_id)`, or no such version number | +| `no_effective_tariff` | `TariffNotFoundError` | A history exists but no version is effective as of `as_of` | +| `unavailable_zone` | `UnavailableServiceError` | The resolved tariff prices no such zone | +| `unavailable_accessorial` | `UnavailableServiceError` | The resolved tariff does not offer a requested accessorial | +| `policy_rule_not_found` | `PolicyRuleNotFoundError` | A pinned `rule_id` has no history | +| `policy_version_not_found` | `PolicyVersionNotFoundError` | A pinned rule version number does not exist | +| `catalog_not_found` | `CatalogError` | No catalog with that `catalog_id` in the document | +| `catalog_version_not_found` | `CatalogVersionNotFoundError` | No such version number, or the catalog has none | +| `no_effective_catalog_version` | `NoEffectiveCatalogVersionError` | `as_of` predates every version | +| `ambiguous_catalog_reference` | `AmbiguousCatalogReferenceError` | Neither `version` nor `as_of`, with more than one version published | + +`error.fields` carries only structured values — ids, version numbers, the offending +zone or accessorial id — never a prose message. Human-readable text is a property of +each language's own exception type and is deliberately excluded from the result +document, because prose is the one thing four independent implementations cannot be +held byte-identical on. + +`unavailable_accessorial` reports every missing accessorial at once, in a sorted +`accessorial_ids` list, matching what `rate_tariff` already does. + +## Traceability + +Every exported behaviour resolves to code that already existed before this epic. No +function below computes anything itself. + +| Exported | Wraps | Now lives at | +| --- | --- | --- | +| `quote` | `rate_tariff`, `CarrierRegistry.rate` / `.rate_with_version`, `Tariff`, `AccessorialCharge`, `RateBreakdown`, `RatingRequest` | `packvium/commerce/rating.py`, re-exported from `commerce/rating/model.py` | +| `evaluate_policy` | `PolicyRegistry.evaluate` / `.resolve_versions`, `decide`, `PolicyRule`, `PolicyPredicate`, `PolicyDecision`, `PolicyCitation` | `packvium/commerce/policy.py`, re-exported from `domain/policy/model.py` | +| `catalog_version_info` | `CatalogRegistry.publish` / `.rollback` / `.resolve`, `CatalogVersion`, `CatalogSnapshot`, `CatalogReference` | `packvium/commerce/catalog.py`, re-exported from `domain/catalog/model.py` | + +The Python relocation is a move, not a copy. `commerce/rating/model.py`, +`domain/policy/model.py` and `domain/catalog/model.py` remain importable at their +original paths and re-export the relocated definitions, so every workspace test, +`integration/product/`, `simulation/` and `recommendations/` import keeps working +against the exact same objects. There is one definition of `rate_tariff` in the Python +tree, and the installed wheel contains it. `commerce/rating/objective.py` — the solver +adapter — is untouched and stays a workspace module: it registers in-process scorer and +selector objects, which EXTENDING.md explains are deliberately not +cross-language features. + +## Conformance standard + +Held to the standard every other capability in this project is held to, per +TESTING-AND-RELEASE.md: + +- **Python and PHP: byte-identical.** Both are ports of one contract; canonical JSON + (sorted keys, `,`/`:` separators, no trailing whitespace) of the result document must + match exactly. +- **Rust and JavaScript: valid, and no worse than the floor.** Both are independent + implementations. Every result must be accepted by the independent validator, and for + `quote` the `total_minor` must equal the fixture's objective floor — a price is a + single exact integer, so "no worse than the floor" and "equal" coincide here; there + is no room for an alternative-but-equally-good answer the way there is for a + placement. +- **Uniform rejection.** A fixture no engine can price is rejected by all four with the + same `error.code` and the same `error.fields`. +- **Uniform refusal.** A malformed fixture must make all four *fail* rather than answer. + This half matters as much as the others: four implementations that agree on every + well-formed input can still disagree about what counts as well-formed, and the language + that quietly accepts a string where a list belongs is the one that later returns a + different answer. Every documented rejection code must be reached by some fixture, and + the runner fails if one is not. + + +## Complexity + +`h` = versions in one history, `n` = histories, `p` = predicates per rule, +`e` = entries in a catalog snapshot, `a` = requested accessorials. + +| Operation | Time | Space | +| --- | --- | --- | +| Load document | `O(total input size)` | `O(total input size)` | +| `quote` (pinned) | `O(h + a)` | `O(a)` | +| `quote` (`as_of`) | `O(h + a)` | `O(a)` | +| `evaluate_policy` (`as_of`) | `O(n * (h + p))` | `O(n)` | +| `evaluate_policy` (pinned) | `O(k log k + k * (h + p))` for `k` pins | `O(k)` | +| `catalog_version_info` | `O(h + e log e)` | `O(e)` | + +The `e log e` term is sorting the three id lists; every other bound is a linear scan of +the relevant history. These match the bounds already published for the underlying +models in ALGORITHMS-AND-COMPLEXITY.md — the wrapper +adds parsing and serialization, both linear in the payload, and nothing else. + +## Limitations + +- The document is supplied by the caller. Nothing here fetches, scrapes or embeds any + real carrier's published rates; live rate-card ingestion is still out of scope and + still tracked in LIMITATIONS-AND-ROADMAP.md. +- `catalog_version_info` returns metadata and id lists, not the resolved master-data + records. Exporting the full snapshot is a larger surface with its own wire-format + question and is not part of this epic. +- Policy evaluation covers the closed `PolicyScope` / `PolicyOperator` vocabulary. + An unrecognised scope or operator fails document admission rather than being + silently ignored — the guarantee `domain/policy/model.py` already makes. +- A JSON number must be written without a fractional part. `1` is an integer; `1.0` and + `1e3` are not, and Python, PHP and Rust refuse them. JavaScript cannot tell the + difference — `JSON.parse` gives the same `Number` for `1` and `1.0` — so this is the + one shape where the four implementations cannot be made to agree, and the contract + resolves it by requiring callers not to emit it. No fixture uses one. +- JavaScript refuses, rather than rounds, a quote whose components exceed + `Number.MAX_SAFE_INTEGER`. The arithmetic itself runs in `BigInt`, so nothing drifts + through a double; what cannot be done is *reporting* a value a JSON number cannot hold + exactly. Python has no such ceiling, Rust takes every product in `i128`, and PHP falls + back to decimal-string arithmetic. The bound is far above any real tariff — nine + quadrillion minor currency units. diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index c163f61..e0a8728 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -48,7 +48,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `0.1.0` is an early release. The public API is not yet frozen: field names, +Version `0.1.1` is an early release. The public API is not yet frozen: field names, status codes and the objective vector may change before `1.0.0`. Pin an exact version. The algorithm complexities documented in `ALGORITHMS-AND-COMPLEXITY.md` are design diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index 2d38c76..2089bb4 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -357,6 +357,38 @@ details. A structural bound takes precedence over a deadline: an oversized item `proven` even if the overall run timed out. Conversely, `time_limit`, `search_exhausted` and other unfinished-search outcomes can never carry `proven`. +## Commercial and control-plane API + +Three deterministic functions over one canonical JSON document -- a carrier quote, an +eligibility decision, and catalog version metadata. They are a separate surface from the +packing API and add no packing-request or packing-result field; the full contract, with +the document format, every result shape, the closed set of rejection codes, complexity +and limitations, is [COMMERCE-API.md](COMMERCE-API.md). + +| Language | Entry point | +| --- | --- | +| Python | `packvium.commerce.quote(document, request)`, `.evaluate_policy(...)`, `.catalog_version_info(...)`, `.canonical_json(result)` | +| PHP | `Packvium\Commerce\quote(array $document, array $request)`, `evaluatePolicy(...)`, `catalogVersionInfo(...)`, `canonicalJson(...)` | +| Rust | `packvium_core::commerce::quote_json(&str)`, `evaluate_policy_json(&str)`, `catalog_version_info_json(&str)` | +| JavaScript | `commerce.quote(document, request)`, `.evaluatePolicy(...)`, `.catalogVersionInfo(...)` on `@packvium/engine`; the same three, async, on `@packvium/browser` | +| C ABI | `packvium_commerce_quote(call)`, `packvium_commerce_evaluate_policy(call)`, `packvium_commerce_catalog_version_info(call)` | + +The Rust, C ABI and WASM entry points take one JSON string, `{"document": ..., "request": +...}`, and return the result document as a string. Each C ABI function follows the same +pointer contract as `packvium_solve_json`: a valid, immutable, NUL-terminated UTF-8 +input, and an owned result string the caller releases exactly once with +`packvium_free_string`. `@packvium/engine` selects the native addon when it is installed +and the deterministic JavaScript implementation otherwise, the same way `pack` does; +`commerce.backend()` reports which answered. + +Two kinds of failure, and they are not interchangeable. A malformed document or request +is a caller bug and is raised the way each language raises one (`CommerceInputError`, +`Packvium\Commerce\CommerceInputException`, `Err(CommerceInputError)`, a thrown +`CommerceInputError`). A well-formed request the commercial model cannot answer -- no +tariff effective at that instant, no rate for that zone -- is a successful call returning +`"status": "rejected"` with a code from a closed set, exactly as an infeasible packing +request returns a result with a status rather than raising. + ## JSON API Python, PHP, Rust and the JavaScript fallback accept the same top-level keys: `units`, diff --git a/docs/README.md b/docs/README.md index 788b3fe..51a2866 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,4 +4,5 @@ | --- | --- | | [GUARANTEES.md](GUARANTEES.md) | What the library promises and what it explicitly does not. | | [PUBLIC-API.md](PUBLIC-API.md) | Inputs, outputs and status semantics. | +| [COMMERCE-API.md](COMMERCE-API.md) | Carrier rating, policy evaluation and catalog-version contracts. | | [UNITS-AND-NUMERICS.md](UNITS-AND-NUMERICS.md) | Exact fixed-point units and rounding. | diff --git a/examples/commerce.py b/examples/commerce.py new file mode 100644 index 0000000..71b3cf7 --- /dev/null +++ b/examples/commerce.py @@ -0,0 +1,171 @@ +"""Quote a shipment, apply a policy rule, and inspect a catalog version. + +Run it: + + python examples/commerce.py + +Everything the three functions need arrives in one *commerce document*: the tariffs you +publish, the eligibility rules you publish, and the catalog versions you publish. Each +history is a list, and a version's number is simply its position in that list starting +at 1 -- so `tariff_version: 2` always means "the second entry under this carrier and +service", with no separate numbering to keep in sync. + +Nothing here reads the clock. Every "which version applies" question is answered from a +pin you supply, so the same call replays to the same answer next year. +""" + +from __future__ import annotations + +import json + +from packvium.commerce import canonical_json, catalog_version_info, evaluate_policy, quote + +# -------------------------------------------------------------------------------------- +# One document, three histories. You would normally load this from your own storage. +# -------------------------------------------------------------------------------------- +DOCUMENT = { + "tariffs": [ + { + "carrier_id": "acme", + "service_id": "ground", + # Two published versions. The second takes effect at instant 1000. + "versions": [ + { + "effective_at": 0, + # Volume in mm^3 divided by this gives dimensional weight in grams. + "dimensional_weight_divisor": 5000, + # Minor currency units (cents) per billed kilogram, per zone. + "cost_per_dimensional_kg_minor": {"zone-a": 450, "zone-b": 610}, + "minimum_charge_minor": 900, + # Permille: 120 means 12.0%. + "fuel_surcharge_permille": 120, + "accessorials": [ + {"accessorial_id": "liftgate", "flat_charge_minor": 250}, + {"accessorial_id": "residential", "permille_of_base": 75}, + ], + }, + { + "effective_at": 1000, + "dimensional_weight_divisor": 4000, + "cost_per_dimensional_kg_minor": {"zone-a": 480}, + "minimum_charge_minor": 950, + "fuel_surcharge_permille": 140, + "accessorials": [{"accessorial_id": "liftgate", "flat_charge_minor": 275}], + }, + ], + }, + ], + "policy_rules": [ + { + "rule_id": "no-hazmat-air", + "versions": [ + { + "scope": "hazmat", + "action": "reject", + "priority": 10, + "effective_at": 0, + "reason": "class 1.4 is not accepted on air services", + "predicates": [ + {"scope": "hazmat", "field": "un_class", + "operator": "equals", "value": "1.4"}, + ], + }, + ], + }, + ], + "catalogs": [ + { + "catalog_id": "dc-12", + "versions": [ + { + "effective_at": 0, "published_at": 0, "note": "initial", + "snapshot": { + "items": [{"id": "sku-1", "dimensions_mm": [100, 200, 300], + "weight_g": 1200}], + "cartons": [{"id": "box-m", "inner_dimensions_mm": [320, 240, 180], + "max_payload_g": 15000, "cost_minor": 85}], + }, + }, + # A rollback is a new, higher-numbered version, never an edit of history. + {"rollback_to": 1, "published_at": 900, "effective_at": 900, + "note": "revert the weight correction"}, + ], + }, + ], +} + + +def show(title: str, result: dict) -> None: + print(f"\n== {title}") + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +# -------------------------------------------------------------------------------------- +# 1. Quote: what does this shipment cost? +# -------------------------------------------------------------------------------------- +pinned = quote(DOCUMENT, { + "carrier_id": "acme", + "service_id": "ground", + "tariff_version": 1, # replay against exactly this version... + "zone": "zone-a", + "actual_weight_g": 1200, + "volume_mm3": 6_000_000, + "requested_accessorials": ["liftgate"], +}) +show("a quote pinned to tariff version 1", pinned) +print(f" -> the caller pays {pinned['quote']['total_minor']} minor units") + +effective = quote(DOCUMENT, { + "carrier_id": "acme", + "service_id": "ground", + "as_of": 1500, # ...or against whatever was in force at this instant + "zone": "zone-a", + "actual_weight_g": 1200, + "volume_mm3": 6_000_000, + "requested_accessorials": ["liftgate"], +}) +print(f"\n as of instant 1500 the tariff is version " + f"{effective['quote']['tariff_version']}, and the price is " + f"{effective['quote']['total_minor']}") + +# A request the model cannot answer is not an exception. It is a result with a status, +# a code from a closed set, and the structured fields that say what was missing. +unpriceable = quote(DOCUMENT, { + "carrier_id": "acme", "service_id": "ground", "tariff_version": 1, + "zone": "zone-nowhere", "actual_weight_g": 1200, "volume_mm3": 6_000_000, +}) +show("a zone this tariff does not price", unpriceable) + +# -------------------------------------------------------------------------------------- +# 2. Policy: may this shipment go at all? +# -------------------------------------------------------------------------------------- +decision = evaluate_policy(DOCUMENT, { + "scope": "hazmat", + "context": {"un_class": "1.4"}, + "as_of": 0, +}) +show("a policy decision, with the rule that made it", decision) + +allowed = evaluate_policy(DOCUMENT, { + "scope": "hazmat", "context": {"un_class": "9"}, "as_of": 0, +}) +print(f"\n nothing matched, so the shipment is allowed with no citation: " + f"{allowed['decision']['citation']}") + +# -------------------------------------------------------------------------------------- +# 3. Catalog: which master data was this decision made against? +# -------------------------------------------------------------------------------------- +catalog = catalog_version_info(DOCUMENT, { + "catalog_id": "dc-12", + "version": 2, + "resolved_at": 1700, +}) +show("catalog version metadata", catalog) +print(f"\n version {catalog['catalog']['version']} is a rollback of version " + f"{catalog['catalog']['rolled_back_from']}") + +# -------------------------------------------------------------------------------------- +# Storing or comparing a result: use the canonical form, never str(). +# -------------------------------------------------------------------------------------- +print("\n== the canonical form is what you store, log and compare") +print(canonical_json(pinned)) diff --git a/examples/constraints.py b/examples/constraints.py new file mode 100644 index 0000000..e6ecfa6 --- /dev/null +++ b/examples/constraints.py @@ -0,0 +1,118 @@ +"""Constraints: how to say "this may not go there" and get told why. + +Run it: + + PYTHONPATH=src python3 examples/constraints.py + +The solver's job is not only to fit boxes. Most real packing rules are refusals -- this +side up, nothing on top of that, keep the chemicals away from the food -- and the useful +part of the answer is often the item that did *not* fit and the reason it did not. + +Every constraint here is a field on `Item` or `Container`. None of them needs a custom +class, and none of them changes how you call `pack`. +""" + +from packvium import ( + Container, + Dimensions, + Item, + Length, + Packer, + PackingConfig, + explain_unpacked_item, +) + +items = [ + # `keep_upright` forbids every rotation that would tip the item over. An open tub of + # paint is the usual reason. + Item.create( + "paint", + Dimensions.mm("200", "200", "250"), + "5 kg", + quantity=2, + keep_upright=True, + ), + # `must_be_on_floor` keeps the item on the container floor, and `max_top_load` caps + # what may rest directly on it. Note "directly": this is not a whole-stack limit. + Item.create( + "glass-panel", + Dimensions.mm("400", "300", "40"), + "8 kg", + quantity=1, + must_be_on_floor=True, + max_top_load="2 kg", + ), + # `stackable=False` means nothing may be placed on this item at all. + Item.create( + "cake", + Dimensions.mm("250", "250", "150"), + "1 kg", + quantity=1, + stackable=False, + ), + # Tags are how two items refuse each other. `incompatible_tags` is checked both ways, + # so tagging one side is enough. + Item.create( + "bleach", + Dimensions.mm("120", "120", "300"), + "2 kg", + quantity=2, + tags=("hazmat",), + incompatible_tags=("food",), + ), + Item.create( + "flour", + Dimensions.mm("200", "150", "100"), + "1500 g", + quantity=3, + tags=("food",), + ), + # Longer than the crate's longest inner edge in every orientation, so no solver can + # place it. It is here to show what a refusal looks like. + Item.create("ladder", Dimensions.mm("1800", "300", "100"), "6 kg", quantity=1), +] + +containers = [ + # `max_payload` is the weight the container may carry, excluding its own tare. + Container.create( + "crate", + Dimensions.mm("600", "500", "500"), + tare_weight="3 kg", + max_payload="25 kg", + cost_minor=400, + ), +] + +result = Packer(PackingConfig.balanced()).pack(items, containers) + + +def millimetres(ticks: int) -> str: + """Positions are exact integers in 1/16000 mm; render them for a human.""" + return f"{ticks / Length.TICKS_PER_MM:g}" + + +print(f"status: {result.status.value}") +print(f"containers opened: {len(result.containers)}") + +for index, packed in enumerate(result.containers, start=1): + print(f"\ncrate #{index} ({packed.container.id}): {len(packed.placements)} placement(s)") + for placement in packed.placements: + position = placement.position + print( + f" {placement.instance.item.id:12s} at " + f"({millimetres(position.x)}, {millimetres(position.y)}, {millimetres(position.z)}) mm" + ) + +# Two crates for a load that would fit in one by volume: `bleach` is tagged `hazmat` and +# refuses `food`, so it cannot share a container with `flour`. Nothing asked the solver +# to open a second crate -- the constraint did. + +# The refusals are the interesting half. `explain_unpacked_item` turns the structured +# reason into a sentence, so you can show a human why their order will not ship as one +# box without teaching them the reason codes. +if result.unpacked: + print("\nnot packed:") + for unpacked in result.unpacked: + print(f" {unpacked.instance.item.id:12s} {explain_unpacked_item(unpacked)}") +else: + print("\neverything fitted -- widen the crate or add items to see a refusal explained") diff --git a/examples/nested.py b/examples/nested.py new file mode 100644 index 0000000..c99bf8f --- /dev/null +++ b/examples/nested.py @@ -0,0 +1,91 @@ +"""Nested packing: cartons into a pallet, in one call. + +Run it: + + PYTHONPATH=src python3 examples/nested.py + +Real fulfilment is rarely one level. Units go into cartons, cartons go onto a pallet, and +sometimes pallets go into a trailer. `NestedPacker` runs those levels in order and feeds +each level's *packed containers* into the next level as items -- a carton that came out +of level one arrives at level two as a box with its own outer dimensions and its total +packed weight. + +The levels stay independent on purpose. Level two does not reach back and repack level +one to get a better pallet, because that would make the carton contents depend on the +pallet, and a carton you already taped shut cannot be repacked. If you want that +trade-off explored, run the packer yourself with different carton sets and compare. +""" + +from packvium import ( + Container, + Dimensions, + Item, + NestedPacker, + PackingConfig, + PackingLevel, +) + +# What the customer ordered. +items = [ + Item.create("mug", Dimensions.mm("120", "120", "100"), "400 g", quantity=24), + Item.create("plate", Dimensions.mm("260", "260", "20"), "600 g", quantity=16), +] + +levels = [ + # Level 1: choose cartons. `outer_dimensions` matters here -- the next level packs + # the *outside* of this carton, including its wall thickness. + PackingLevel( + "carton", + ( + Container.create( + "box-s", + Dimensions.mm("300", "300", "300"), + outer_dimensions=Dimensions.mm("310", "310", "310"), + tare_weight="300 g", + max_payload="15 kg", + cost_minor=120, + ), + Container.create( + "box-l", + Dimensions.mm("400", "400", "400"), + outer_dimensions=Dimensions.mm("412", "412", "412"), + tare_weight="500 g", + max_payload="25 kg", + cost_minor=180, + ), + ), + config=PackingConfig.balanced(), + ), + # Level 2: put those cartons on a pallet. The deck is the inner dimension and the + # usable stack height is the rest. + PackingLevel( + "pallet", + ( + Container.create( + "euro", + Dimensions.mm("1200", "800", "1400"), + tare_weight="25 kg", + max_payload="700 kg", + cost_minor=1500, + ), + ), + ), +] + +result = NestedPacker().pack(items, levels) + +# `result.levels` is one PackingResult per level, in the order you supplied them, so zip +# it back against the level names you chose. +for level, packed_level in zip(levels, result.levels): + print(f"== {level.name} ==") + print(f" status: {packed_level.status.value}") + print(f" containers used: {len(packed_level.containers)}") + for packed in packed_level.containers: + print(f" {packed.container.id:8s} holding {len(packed.placements)} item(s)") + if packed_level.unpacked: + print(f" left over: {len(packed_level.unpacked)}") + print() + +cartons = len(result.levels[0].containers) +pallets = len(result.levels[-1].containers) +print(f"{cartons} carton(s) travelling on {pallets} pallet(s)") diff --git a/pyproject.toml b/pyproject.toml index 1d1f825..5bd7e2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "packvium" -version = "0.1.0" +version = "0.1.1" description = "Deterministic, extensible 3D cartonization and rectangular bin-packing library" readme = "README.md" requires-python = ">=3.9" diff --git a/src/packvium/commerce/__init__.py b/src/packvium/commerce/__init__.py new file mode 100644 index 0000000..c819a36 --- /dev/null +++ b/src/packvium/commerce/__init__.py @@ -0,0 +1,48 @@ +"""Packvium's exported commercial and control-plane API. + +Three deterministic functions over one canonical JSON document: + + >>> from packvium.commerce import quote + >>> document = {"tariffs": [{"carrier_id": "acme", "service_id": "ground", "versions": [ + ... {"effective_at": 0, "dimensional_weight_divisor": 5000, + ... "cost_per_dimensional_kg_minor": {"zone-a": 450}}]}]} + >>> quote(document, {"carrier_id": "acme", "service_id": "ground", "tariff_version": 1, + ... "zone": "zone-a", "actual_weight_g": 2000, + ... "volume_mm3": 1000000})["quote"]["total_minor"] + 900 + +The full contract -- document format, result shapes, rejection codes, complexity and +limitations -- is docs/COMMERCE-API.md. + +The models underneath (`rating`, `policy`, `catalog`) are the same objects the +workspace application modules import through `commerce/rating/model.py`, +`domain/policy/model.py` and `domain/catalog/model.py`; those paths are re-export shims +onto this package, so there is exactly one implementation and an exported quote cannot +drift from the price a packing request was optimised against. +""" + +from __future__ import annotations + +from .api import ( + API_VERSION, + REJECTION_CODES, + canonical_json, + catalog_version_info, + evaluate_policy, + quote, +) +from .document import CommerceDocument, load_document +from .errors import CommerceError, CommerceInputError + +__all__ = [ + "API_VERSION", + "REJECTION_CODES", + "CommerceDocument", + "CommerceError", + "CommerceInputError", + "canonical_json", + "catalog_version_info", + "evaluate_policy", + "load_document", + "quote", +] diff --git a/src/packvium/commerce/api.py b/src/packvium/commerce/api.py new file mode 100644 index 0000000..c8f8cff --- /dev/null +++ b/src/packvium/commerce/api.py @@ -0,0 +1,366 @@ +"""The three exported commercial and control-plane functions. + +Each one loads the caller's commerce document into the registries defined by +`packvium.commerce.rating`, `.policy` and `.catalog`, asks those registries the +question, and serializes the answer. No price, decision or version resolution is +computed here -- every number in a result comes back out of the same objects +`commerce/rating/objective.py` and `integration/product/` already use, which is what +keeps the exported answer and the answer a packing request optimises against from ever +diverging. + +The contract -- request shapes, result shapes and the closed set of rejection codes -- +is docs/COMMERCE-API.md. Complexity is stated there too; the wrapper itself adds one +linear parse of the payload and nothing else. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from .catalog import ( + AmbiguousCatalogReferenceError, + CatalogVersionNotFoundError, + NoEffectiveCatalogVersionError, + ResolvedCatalog, +) +from .document import CommerceDocument, load_document +from .errors import CommerceInputError, _Rejection +from .policy import ( + PolicyDecision, + PolicyRegistry, + PolicyRule, + PolicyRuleNotFoundError, + PolicyScope, + PolicyVersionNotFoundError, + decide, +) +from .rating import RateBreakdown, RatingRequest, TariffNotFoundError, UnavailableServiceError, rate_tariff + +API_VERSION = 1 + +#: The closed set of rejection codes, in the order docs/COMMERCE-API.md tabulates them. +REJECTION_CODES = ( + "tariff_not_found", + "no_effective_tariff", + "unavailable_zone", + "unavailable_accessorial", + "policy_rule_not_found", + "policy_version_not_found", + "catalog_not_found", + "catalog_version_not_found", + "no_effective_catalog_version", + "ambiguous_catalog_reference", +) + + +# ------------------------------------------------------------------ request primitives + +def _fail(path: str, message: str) -> None: + raise CommerceInputError("{0}: {1}".format(path, message)) + + +def _request(value: Any) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + _fail("request", "expected an object") + return value + + +def _integer(value: Any, path: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + _fail(path, "expected an exact integer") + return value + + +def _text(value: Any, path: str) -> str: + if not isinstance(value, str): + _fail(path, "expected a string") + return value + + +def _keys(value: Mapping[str, Any], path: str, required, optional=()) -> None: + required_set = set(required) + missing = sorted(required_set - set(value)) + if missing: + _fail(path, "missing required key(s) {0}".format(missing)) + unknown = sorted(set(value) - (required_set | set(optional))) + if unknown: + _fail(path, "unrecognised key(s) {0}".format(unknown)) + + +def _exactly_one(value: Mapping[str, Any], path: str, names: Sequence[str]) -> str: + present = [name for name in names if value.get(name) is not None] + if len(present) != 1: + _fail(path, "expected exactly one of {0}".format(list(names))) + return present[0] + + +def _ok(key: str, payload: Mapping[str, Any]) -> Dict[str, Any]: + return {"api_version": API_VERSION, "status": "ok", key: dict(payload)} + + +def canonical_json(result: Mapping[str, Any]) -> str: + """The one byte-comparable spelling of a result document. + + Cross-language equality is asserted on this string, not on a parsed object, so key + order and whitespace cannot make two identical answers look different -- or two + different answers look identical. + """ + return json.dumps(result, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _rejected(rejection: _Rejection) -> Dict[str, Any]: + return { + "api_version": API_VERSION, + "status": "rejected", + "error": {"code": rejection.code, "fields": dict(rejection.fields)}, + } + + +# -------------------------------------------------------------------------------- quote + +def quote(document: Any, request: Any) -> Dict[str, Any]: + """Price one shipment against one pinned or effective-dated tariff version. + + `document` is a canonical commerce document, `request` names the carrier service, + the version pin (`tariff_version`) or the instant (`as_of`), and the shipment. + Returns the `RateBreakdown` `commerce/rating/model.py` produces, field for field. + """ + loaded = load_document(document) + fields = _request(request) + _keys( + fields, "request", + ("carrier_id", "service_id", "zone", "actual_weight_g", "volume_mm3"), + ("tariff_version", "as_of", "requested_accessorials"), + ) + pin = _exactly_one(fields, "request", ("tariff_version", "as_of")) + carrier_id = _text(fields["carrier_id"], "request.carrier_id") + service_id = _text(fields["service_id"], "request.service_id") + accessorials = _accessorial_ids(fields.get("requested_accessorials")) + rating_request = _build_rating_request(fields, accessorials) + + try: + tariff = _resolve_tariff(loaded, carrier_id, service_id, fields, pin) + breakdown = _rate(tariff, rating_request, carrier_id, service_id) + except _Rejection as rejection: + return _rejected(rejection) + return _ok("quote", _quote_payload(breakdown)) + + +def _accessorial_ids(value: Any) -> Tuple[str, ...]: + """The requested accessorials, as a list of strings and nothing else. + + A bare string and a mapping are both iterable, so iterating whatever arrives would + quietly turn `"liftgate"` into eight one-character ids and `{"liftgate": 1}` into a + one-element list -- two wrong answers where the other implementations report a + malformed request. + """ + if value is None: + return () + if isinstance(value, (str, bytes, Mapping)) or not isinstance(value, Sequence): + _fail("request.requested_accessorials", "expected a list") + return tuple( + _text(entry, "request.requested_accessorials[{0}]".format(index)) + for index, entry in enumerate(value) + ) + + +def _build_rating_request(fields: Mapping[str, Any], accessorials: Tuple[str, ...]) -> RatingRequest: + try: + return RatingRequest( + zone=_text(fields["zone"], "request.zone"), + actual_weight_g=_integer(fields["actual_weight_g"], "request.actual_weight_g"), + volume_mm3=_integer(fields["volume_mm3"], "request.volume_mm3"), + requested_accessorials=accessorials, + ) + except ValueError as error: + raise CommerceInputError("request: {0}".format(error)) from error + + +def _resolve_tariff(loaded: CommerceDocument, carrier_id: str, service_id: str, + fields: Mapping[str, Any], pin: str): + identity = {"carrier_id": carrier_id, "service_id": service_id} + if pin == "tariff_version": + version = _integer(fields["tariff_version"], "request.tariff_version") + try: + return loaded.carriers.tariff(carrier_id, service_id, version) + except TariffNotFoundError: + raise _Rejection("tariff_not_found", dict(identity, tariff_version=version)) + as_of = _integer(fields["as_of"], "request.as_of") + try: + loaded.carriers.versions(carrier_id, service_id) + except TariffNotFoundError: + raise _Rejection("tariff_not_found", dict(identity)) + try: + return loaded.carriers.effective_tariff(carrier_id, service_id, as_of=as_of) + except TariffNotFoundError: + raise _Rejection("no_effective_tariff", dict(identity, as_of=as_of)) + + +def _rate(tariff, rating_request: RatingRequest, carrier_id: str, service_id: str) -> RateBreakdown: + try: + return rate_tariff(tariff, rating_request) + except UnavailableServiceError as error: + identity = { + "carrier_id": carrier_id, "service_id": service_id, "tariff_version": tariff.version, + } + if error.zone is not None: + raise _Rejection("unavailable_zone", dict(identity, zone=error.zone)) + raise _Rejection( + "unavailable_accessorial", dict(identity, accessorial_ids=list(error.accessorial_ids)), + ) + + +def _quote_payload(breakdown: RateBreakdown) -> Dict[str, Any]: + return { + "carrier_id": breakdown.carrier_id, + "service_id": breakdown.service_id, + "tariff_version": breakdown.tariff_version, + "zone": breakdown.zone, + "actual_weight_g": breakdown.actual_weight_g, + "dimensional_weight_g": breakdown.dimensional_weight_g, + "billed_weight_g": breakdown.billed_weight_g, + "base_charge_minor": breakdown.base_charge_minor, + "minimum_charge_applied": breakdown.minimum_charge_applied, + "fuel_surcharge_minor": breakdown.fuel_surcharge_minor, + "accessorial_charges_minor": [ + [accessorial_id, amount] for accessorial_id, amount in breakdown.accessorial_charges_minor + ], + "total_minor": breakdown.total_minor, + } + + +# ----------------------------------------------------------------------- evaluate_policy + +def evaluate_policy(document: Any, request: Any) -> Dict[str, Any]: + """Decide one eligibility question against a pinned or effective-dated rule set.""" + loaded = load_document(document) + fields = _request(request) + _keys(fields, "request", ("scope", "context"), ("as_of", "rule_versions")) + pin = _exactly_one(fields, "request", ("as_of", "rule_versions")) + scope = _policy_scope(fields["scope"]) + context = fields["context"] + if not isinstance(context, Mapping): + _fail("request.context", "expected an object") + + try: + decision = _decide(loaded.policies, scope, context, fields, pin) + except _Rejection as rejection: + return _rejected(rejection) + return _ok("decision", _decision_payload(decision)) + + +def _policy_scope(value: Any) -> PolicyScope: + try: + return PolicyScope(_text(value, "request.scope")) + except ValueError: + _fail("request.scope", "unsupported policy scope {0!r}".format(value)) + + +def _decide(policies: PolicyRegistry, scope: PolicyScope, context: Mapping[str, Any], + fields: Mapping[str, Any], pin: str) -> PolicyDecision: + if pin == "as_of": + return policies.evaluate(scope, context, as_of=_integer(fields["as_of"], "request.as_of")) + return decide(_pinned_rules(policies, fields["rule_versions"]), scope, context) + + +def _pinned_rules(policies: PolicyRegistry, value: Any) -> Sequence[PolicyRule]: + pins: List[Tuple[str, int]] = [] + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + _fail("request.rule_versions", "expected a list") + for index, entry in enumerate(value): + path = "request.rule_versions[{0}]".format(index) + if isinstance(entry, (str, bytes)) or not isinstance(entry, Sequence) or len(entry) != 2: + _fail(path, "expected a [rule_id, version] pair") + pins.append((_text(entry[0], path + "[0]"), _integer(entry[1], path + "[1]"))) + try: + return policies.resolve_versions(pins) + except ValueError as error: + raise CommerceInputError("request.rule_versions: {0}".format(error)) from error + except PolicyVersionNotFoundError as error: + raise _Rejection( + "policy_version_not_found", {"rule_id": error.rule_id, "version": error.version}, + ) + except PolicyRuleNotFoundError as error: + raise _Rejection("policy_rule_not_found", {"rule_id": error.rule_id}) + + +def _decision_payload(decision: PolicyDecision) -> Dict[str, Any]: + citation = decision.citation + return { + "scope": decision.scope.value, + "allowed": decision.allowed, + "citation": None if citation is None else { + "rule_id": citation.rule_id, + "version": citation.version, + "action": citation.action.value, + "priority": citation.priority, + "reason": citation.reason, + }, + } + + +# ------------------------------------------------------------------ catalog_version_info + +def catalog_version_info(document: Any, request: Any) -> Dict[str, Any]: + """Report which catalog version a reference resolves to, and what it contains.""" + loaded = load_document(document) + fields = _request(request) + _keys(fields, "request", ("catalog_id", "resolved_at"), ("version", "as_of")) + catalog_id = _text(fields["catalog_id"], "request.catalog_id") + resolved_at = _integer(fields["resolved_at"], "request.resolved_at") + version = None if fields.get("version") is None else _integer(fields["version"], "request.version") + as_of = None if fields.get("as_of") is None else _integer(fields["as_of"], "request.as_of") + if version is not None and as_of is not None: + _fail("request", "expected at most one of ['version', 'as_of']") + + try: + resolved = _resolve_catalog(loaded, catalog_id, resolved_at, version, as_of) + except _Rejection as rejection: + return _rejected(rejection) + return _ok("catalog", _catalog_payload(loaded, resolved)) + + +def _resolve_catalog(loaded: CommerceDocument, catalog_id: str, resolved_at: int, + version: Optional[int], as_of: Optional[int]) -> ResolvedCatalog: + registry = loaded.catalogs.get(catalog_id) + if registry is None: + raise _Rejection("catalog_not_found", {"catalog_id": catalog_id}) + selector: Dict[str, Any] = {"catalog_id": catalog_id} + if version is not None: + selector["version"] = version + if as_of is not None: + selector["as_of"] = as_of + try: + return registry.resolve(resolved_at=resolved_at, version=version, as_of=as_of) + except CatalogVersionNotFoundError: + raise _Rejection("catalog_version_not_found", selector) + except NoEffectiveCatalogVersionError: + raise _Rejection("no_effective_catalog_version", selector) + except AmbiguousCatalogReferenceError: + raise _Rejection("ambiguous_catalog_reference", selector) + + +def _catalog_payload(loaded: CommerceDocument, resolved: ResolvedCatalog) -> Dict[str, Any]: + reference = resolved.reference + snapshot = resolved.snapshot + published = loaded.catalogs[reference.catalog_id].versions[reference.version - 1] + return { + "catalog_id": reference.catalog_id, + "version": reference.version, + "effective_at": reference.effective_at, + "published_at": published.published_at, + "resolved_at": reference.resolved_at, + "rolled_back_from": published.rolled_back_from, + "note": published.note, + "entry_counts": { + "items": len(snapshot.items), + "cartons": len(snapshot.cartons), + "pallets": len(snapshot.pallets), + "exclusions": len(snapshot.exclusions), + "overrides": len(snapshot.overrides), + }, + "item_ids": sorted(entry.id for entry in snapshot.items), + "carton_ids": sorted(entry.id for entry in snapshot.cartons), + "pallet_ids": sorted(entry.id for entry in snapshot.pallets), + } diff --git a/src/packvium/commerce/catalog.py b/src/packvium/commerce/catalog.py new file mode 100644 index 0000000..3e8c5e2 --- /dev/null +++ b/src/packvium/commerce/catalog.py @@ -0,0 +1,468 @@ +"""Domain model for versioned item, carton and pallet master data. + +A packing decision is only as trustworthy as the master data it was made against. This +module gives SKUs, cartons, pallets, exclusion rules and facility-specific overrides a +first-party, immutable, effective-dated catalog so that: + + * every request resolves to concrete, numbered catalog versions rather than a + moving "current" — a result can record exactly what master data it used + (`CatalogReference`, `ResolvedCatalog`); + * publishing a new version can never reach back and change a reference or snapshot + a caller already resolved, because every object this module hands out is a frozen + dataclass and `CatalogRegistry` only ever *appends* to its history + (`CatalogSnapshot`, `CatalogVersion`, `CatalogRegistry.publish`); + * a version can be rolled back, and a rollback is itself a new, higher-numbered + version — history is never rewritten in place (`CatalogRegistry.rollback`); + * a version can be published now but only take effect later, so future-dated + corrections can be queued ahead of time (`CatalogVersion.effective_at`, + `CatalogRegistry.resolve(as_of=...)`); + * a reference that is missing, ambiguous, or predates any effective version is a + distinct, structured rejection rather than a silent wrong answer + (`CatalogVersionNotFoundError`, `AmbiguousCatalogReferenceError`, + `NoEffectiveCatalogVersionError`, `CatalogEntryNotFoundError`); + * an old decision's recorded `CatalogReference` still resolves to the exact data it + was made against after the catalog has since been corrected, rolled back, or + republished (`CatalogRegistry.resolve_reference`) — historical replay stays + reproducible, and bad master data can be told apart from a solver defect. + +Scope note: this module intentionally does not import `packvium.units.Length` / +`Weight`. Versioning, effective-dating and reference resolution are the concern here, +not geometry or unit parsing, so master-data fields use plain non-negative integer +millimetres/grams rather than pulling in a per-language runtime package. See +docs/CATALOG-VERSIONING.md for the wire-format proposal this model backs. + +Follows the same conventions as packvium.models and optimizer/spec/model.py: frozen, +slotted dataclasses, `__post_init__` validation, no floats, no duplicated logic across +kinds (see the module docstring above and docs/OBJECTIVE.md's exact-arithmetic rationale +for why this codebase avoids floats generally). + +Exported surface: this module is the one definition of the catalog model in +the Python tree and ships inside the installed `packvium` distribution. +`domain/catalog/model.py` re-exports it so every workspace import keeps resolving to +these exact objects. See docs/COMMERCE-API.md for the wrapper contract built on top. +""" + +from __future__ import annotations + +from .._compat import dataclass +from enum import Enum +from typing import Sequence + + +# --------------------------------------------------------------------------------- errors + +class CatalogError(Exception): + """Base class for every catalog-domain error raised by this module.""" + + +class CatalogEntryNotFoundError(CatalogError): + """A referenced item, carton or pallet id does not exist within a resolved snapshot.""" + + +class CatalogVersionNotFoundError(CatalogError): + """An explicitly referenced catalog version number does not exist in the history.""" + + +class NoEffectiveCatalogVersionError(CatalogError): + """An as-of time was resolved before any catalog version had become effective.""" + + +class AmbiguousCatalogReferenceError(CatalogError): + """A reference gave neither an explicit version nor an as-of time while more than one + version exists in the catalog's history, so which version is "the" answer is + undefined. Precisely: `resolve(version=None, as_of=None)` is ambiguous if and only if + the catalog has more than one published version; with zero or exactly one version the + answer is unambiguous and is returned instead of raising. + """ + + +# ------------------------------------------------------------------------------- entries + +class CatalogEntryKind(str, Enum): + ITEM = "item" + CARTON = "carton" + PALLET = "pallet" + + +def _require_id(label: str, id: str) -> None: + if not id: + raise ValueError(f"{label} id is required") + + +def _require_positive_dimensions(label: str, dimensions_mm: Sequence[int]) -> None: + if any(d <= 0 for d in dimensions_mm): + raise ValueError(f"{label} dimensions must be positive") + + +@dataclass(frozen=True, slots=True) +class ItemMaster: + """A first-party SKU master record: the dimensions and weight a catalog version pins + for one stock keeping unit, independent of any one packing request.""" + + id: str + dimensions_mm: tuple[int, int, int] + weight_g: int + description: str = "" + + def __post_init__(self) -> None: + _require_id("item", self.id) + if len(self.dimensions_mm) != 3: + raise ValueError("item dimensions must have exactly three axes") + _require_positive_dimensions("item", self.dimensions_mm) + if self.weight_g <= 0: + raise ValueError("item weight must be positive") + + +@dataclass(frozen=True, slots=True) +class CartonMaster: + """A first-party carton (box) master record a catalog version pins.""" + + id: str + inner_dimensions_mm: tuple[int, int, int] + max_payload_g: int + cost_minor: int = 0 + + def __post_init__(self) -> None: + _require_id("carton", self.id) + if len(self.inner_dimensions_mm) != 3: + raise ValueError("carton dimensions must have exactly three axes") + _require_positive_dimensions("carton", self.inner_dimensions_mm) + if self.max_payload_g <= 0: + raise ValueError("carton max_payload_g must be positive") + if self.cost_minor < 0: + raise ValueError("cost_minor cannot be negative") + + +@dataclass(frozen=True, slots=True) +class PalletMaster: + """A first-party pallet master record a catalog version pins.""" + + id: str + deck_dimensions_mm: tuple[int, int] + max_payload_g: int + max_stack_height_mm: int | None = None + + def __post_init__(self) -> None: + _require_id("pallet", self.id) + if len(self.deck_dimensions_mm) != 2: + raise ValueError("pallet deck dimensions must have exactly two axes") + _require_positive_dimensions("pallet", self.deck_dimensions_mm) + if self.max_payload_g <= 0: + raise ValueError("pallet max_payload_g must be positive") + if self.max_stack_height_mm is not None and self.max_stack_height_mm <= 0: + raise ValueError("max_stack_height_mm must be positive") + + +class ExclusionScope(str, Enum): + ITEM_CARTON = "item_carton" + ITEM_PALLET = "item_pallet" + + +@dataclass(frozen=True, slots=True) +class ExclusionRule: + """A first-party rule forbidding one master-data entry from being packed with + another — e.g. a hazmat SKU forbidden from a given carton type.""" + + id: str + scope: ExclusionScope + subject_id: str + excluded_id: str + reason: str = "" + + def __post_init__(self) -> None: + _require_id("exclusion", self.id) + if not self.subject_id or not self.excluded_id: + raise ValueError("an exclusion rule must reference both a subject and an excluded id") + + +@dataclass(frozen=True, slots=True) +class FacilityOverride: + """A facility-specific override of a base master-data entry — e.g. facility 'DC-12' + stocks carton 'box-m' at different inner dimensions than the network default. + + `entry_kind` is derived from `override`'s type rather than stored separately, so the + two can never disagree. + """ + + id: str + facility_id: str + entry_id: str + override: ItemMaster | CartonMaster | PalletMaster + + def __post_init__(self) -> None: + _require_id("facility override", self.id) + if not self.facility_id: + raise ValueError("facility_id is required") + if self.override.id != self.entry_id: + raise ValueError("a facility override's entry_id must match override.id") + + @property + def entry_kind(self) -> CatalogEntryKind: + if isinstance(self.override, ItemMaster): + return CatalogEntryKind.ITEM + if isinstance(self.override, CartonMaster): + return CatalogEntryKind.CARTON + return CatalogEntryKind.PALLET + + +# ------------------------------------------------------------------------------ snapshot + +def _require_unique_ids(label: str, entries: Sequence) -> None: + ids = [entry.id for entry in entries] + if len(set(ids)) != len(ids): + raise ValueError(f"duplicate {label} ids in catalog snapshot") + + +def _find(entries: Sequence, id: str, kind: CatalogEntryKind): + for entry in entries: + if entry.id == id: + return entry + raise CatalogEntryNotFoundError(f"no {kind.value} with id {id!r} in this catalog snapshot") + + +@dataclass(frozen=True, slots=True) +class CatalogSnapshot: + """The complete, immutable content of one catalog version: every item, carton and + pallet master record, exclusion rule and facility override active under that version. + + Built entirely from frozen dataclasses and stored as tuples, so a `CatalogSnapshot` + handed out by `CatalogRegistry.resolve()` can never be mutated by a later `publish()` + — the invariant that makes concurrent publication safe (the publication contract: + "concurrent catalog publication cannot change an in-flight request"). + """ + + items: tuple[ItemMaster, ...] = () + cartons: tuple[CartonMaster, ...] = () + pallets: tuple[PalletMaster, ...] = () + exclusions: tuple[ExclusionRule, ...] = () + overrides: tuple[FacilityOverride, ...] = () + + def __post_init__(self) -> None: + _require_unique_ids("item", self.items) + _require_unique_ids("carton", self.cartons) + _require_unique_ids("pallet", self.pallets) + _require_unique_ids("exclusion", self.exclusions) + _require_unique_ids("facility override", self.overrides) + + def item(self, id: str) -> ItemMaster: + return _find(self.items, id, CatalogEntryKind.ITEM) + + def carton(self, id: str) -> CartonMaster: + return _find(self.cartons, id, CatalogEntryKind.CARTON) + + def pallet(self, id: str) -> PalletMaster: + return _find(self.pallets, id, CatalogEntryKind.PALLET) + + +# ------------------------------------------------------------------------------- version + +@dataclass(frozen=True, slots=True) +class CatalogVersion: + """One immutable, numbered entry in a catalog's append-only publication history. + + `effective_at` is when this version starts governing lookups (future-effective + versions are supported — a version may be published now but not take effect until + later). `published_at` is when the publication itself was recorded, purely for audit; + it never affects resolution. A rollback is not a mutation of history — it is a new, + higher-numbered version whose snapshot equals a prior one's, recorded via + `rolled_back_from`. + """ + + number: int + snapshot: CatalogSnapshot + effective_at: int + published_at: int + rolled_back_from: int | None = None + note: str = "" + + def __post_init__(self) -> None: + if self.number <= 0: + raise ValueError("version number must be positive") + if self.effective_at < 0: + raise ValueError("effective_at cannot be negative") + if self.published_at < 0: + raise ValueError("published_at cannot be negative") + if self.rolled_back_from is not None and self.rolled_back_from <= 0: + raise ValueError("rolled_back_from must reference a positive version number") + + +# ----------------------------------------------------------------------------- reference + +@dataclass(frozen=True, slots=True) +class CatalogReference: + """A pinned pointer to one concrete, immutable catalog version. + + Embeddable directly in a packing result so every catalog version used is recorded + literally in the result, not merely re-derivable + after the fact. See `domain/catalog/schema/catalog-versions-used.schema.json` for the proposed wire + shape and `as_dict()` for the exact serialization it validates against. + """ + + catalog_id: str + version: int + effective_at: int + resolved_at: int + + def __post_init__(self) -> None: + if not self.catalog_id: + raise ValueError("catalog_id is required") + if self.version <= 0: + raise ValueError("version must be positive") + if self.effective_at < 0: + raise ValueError("effective_at cannot be negative") + if self.resolved_at < 0: + raise ValueError("resolved_at cannot be negative") + + def as_dict(self) -> dict[str, int | str]: + """The wire representation matching `domain/catalog/schema/catalog-versions-used.schema.json`.""" + return { + "catalog_id": self.catalog_id, + "version": self.version, + "effective_at": self.effective_at, + "resolved_at": self.resolved_at, + } + + +@dataclass(frozen=True, slots=True) +class ResolvedCatalog: + """The concrete, frozen result of resolving a `CatalogReference`: one version's + snapshot plus the reference that pins it. + + Handed to a solver as "the" master data for one request. Because both `reference` and + `snapshot` are frozen and nothing in `CatalogRegistry.publish()` reaches back into + already-issued objects, a `ResolvedCatalog` is safe to hold across an "in-flight" + request while other publications happen concurrently. + """ + + reference: CatalogReference + snapshot: CatalogSnapshot + + def item(self, id: str) -> ItemMaster: + return self.snapshot.item(id) + + def carton(self, id: str) -> CartonMaster: + return self.snapshot.carton(id) + + def pallet(self, id: str) -> PalletMaster: + return self.snapshot.pallet(id) + + +# ------------------------------------------------------------------------------ registry + +class CatalogRegistry: + """Per-catalog (e.g. per-warehouse or per-tenant) append-only publication history. + + This is the one place catalog state can change; every object it hands out + (`CatalogVersion`, `ResolvedCatalog`, `CatalogSnapshot`, `CatalogReference`) is frozen, + so publishing a new version can only ever append to the history — it can never reach + back and mutate something a caller already resolved. Concurrent publication cannot + change an in-flight request. + """ + + def __init__(self, catalog_id: str) -> None: + if not catalog_id: + raise ValueError("catalog_id is required") + self._catalog_id = catalog_id + self._versions: list[CatalogVersion] = [] + + @property + def catalog_id(self) -> str: + return self._catalog_id + + @property + def versions(self) -> tuple[CatalogVersion, ...]: + """The full append-only history, oldest first. Never mutated in place.""" + return tuple(self._versions) + + def publish( + self, snapshot: CatalogSnapshot, *, effective_at: int, published_at: int, note: str = "" + ) -> CatalogVersion: + """Append a new version. `effective_at` may be in the future relative to + `published_at` (future-effective publication) or in the past (backdated + correction) — both are valid; only ordering of `effective_at` values across + versions affects what `resolve()` later returns.""" + version = CatalogVersion( + number=len(self._versions) + 1, + snapshot=snapshot, + effective_at=effective_at, + published_at=published_at, + note=note, + ) + self._versions.append(version) + return version + + def rollback( + self, to_version: int, *, published_at: int, effective_at: int | None = None, note: str = "" + ) -> CatalogVersion: + """Publish a new version whose snapshot equals a prior version's, recorded as a + rollback via `rolled_back_from`. History is append-only: `to_version` and every + version after it remain in the history untouched.""" + target = self._version(to_version) + version = CatalogVersion( + number=len(self._versions) + 1, + snapshot=target.snapshot, + effective_at=published_at if effective_at is None else effective_at, + published_at=published_at, + rolled_back_from=to_version, + note=note or f"rollback to version {to_version}", + ) + self._versions.append(version) + return version + + def resolve( + self, *, resolved_at: int, version: int | None = None, as_of: int | None = None + ) -> ResolvedCatalog: + """Resolve a concrete `ResolvedCatalog`, pinned by an explicit `version` number or + by the version effective `as_of` a given time. Raises `CatalogVersionNotFoundError` + for an unknown explicit version, `NoEffectiveCatalogVersionError` if `as_of` + predates every version, and `AmbiguousCatalogReferenceError` if neither is given + while more than one version exists. + """ + target = self._resolve_version(version=version, as_of=as_of) + reference = CatalogReference( + catalog_id=self._catalog_id, + version=target.number, + effective_at=target.effective_at, + resolved_at=resolved_at, + ) + return ResolvedCatalog(reference=reference, snapshot=target.snapshot) + + def resolve_reference(self, reference: CatalogReference, *, resolved_at: int) -> ResolvedCatalog: + """Re-resolve a previously recorded `CatalogReference` (historical replay + stays reproducible). Always resolves by the reference's pinned version number, + never by re-deriving "current" or "as of", so a corrected, rolled-back or + republished catalog can never change what an old decision replays to. + """ + if reference.catalog_id != self._catalog_id: + raise CatalogError( + f"reference is for catalog {reference.catalog_id!r}, not {self._catalog_id!r}" + ) + return self.resolve(resolved_at=resolved_at, version=reference.version) + + def _resolve_version(self, *, version: int | None, as_of: int | None) -> CatalogVersion: + if version is not None: + return self._version(version) + if as_of is None: + if len(self._versions) > 1: + raise AmbiguousCatalogReferenceError( + f"catalog {self._catalog_id!r} has {len(self._versions)} versions; " + "resolve() requires an explicit version or as_of to avoid an ambiguous 'current'" + ) + 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: + 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)) + + def _version(self, number: int) -> CatalogVersion: + 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/document.py b/src/packvium/commerce/document.py new file mode 100644 index 0000000..aa84695 --- /dev/null +++ b/src/packvium/commerce/document.py @@ -0,0 +1,397 @@ +"""Parse the canonical commerce document into the three registries. + +The document format is specified in docs/COMMERCE-API.md. This module does no +commercial arithmetic whatsoever: it validates shape, then hands every field to +`packvium.commerce.rating`, `.policy` and `.catalog` -- the same `CarrierRegistry`, +`PolicyRegistry` and `CatalogRegistry` the workspace application modules publish into. +A version's number is its 1-based position in its `versions` list, which is exactly how +those registries already number a `publish()`. + +Parsing is strict in both directions: a missing required key and an unrecognised extra +key are both `CommerceInputError`. A field this contract does not define cannot be +silently ignored, for the same reason the packing engines refuse an unknown request +field rather than dropping it. + +Complexity: one pass over the payload, `O(size of the document)` time and space. +""" + +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from .._compat import dataclass +from .catalog import ( + CartonMaster, + CatalogError, + CatalogRegistry, + CatalogSnapshot, + ExclusionRule, + ExclusionScope, + FacilityOverride, + ItemMaster, + PalletMaster, +) +from .errors import CommerceInputError +from .policy import ( + PolicyAction, + PolicyOperator, + PolicyPredicate, + PolicyRegistry, + PolicyScope, + UnsupportedPredicateError, +) +from .rating import AccessorialCharge, CarrierRegistry + + +# ------------------------------------------------------------------- shape primitives + +def _fail(path: str, message: str) -> None: + raise CommerceInputError("{0}: {1}".format(path, message)) + + +def _mapping(value: Any, path: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + _fail(path, "expected an object") + return value + + +def _sequence(value: Any, path: str) -> Sequence[Any]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + _fail(path, "expected a list") + return value + + +def _integer(value: Any, path: str) -> int: + # `bool` is a subclass of `int`; a boolean where an exact integer belongs is a + # caller mistake, not a zero or a one. + if isinstance(value, bool) or not isinstance(value, int): + _fail(path, "expected an exact integer") + return value + + +def _text(value: Any, path: str) -> str: + if not isinstance(value, str): + _fail(path, "expected a string") + return value + + +def _default(fields: Mapping[str, Any], key: str, fallback: Any) -> Any: + """An omitted optional field and an explicit JSON `null` mean the same thing. + + Every other implementation of this contract collapses the two -- PHP through `??`, + Rust and JavaScript through their own optional lookups -- so reading `.get(key, + default)` here, which only collapses the first, would make Python the one language + that rejects `{"minimum_charge_minor": null}`. + """ + value = fields.get(key) + return fallback if value is None else value + + +def _keys(value: Mapping[str, Any], path: str, required: Iterable[str], optional: Iterable[str] = ()) -> None: + required_set = set(required) + allowed = required_set | set(optional) + missing = sorted(required_set - set(value)) + if missing: + _fail(path, "missing required key(s) {0}".format(missing)) + unknown = sorted(set(value) - allowed) + if unknown: + _fail(path, "unrecognised key(s) {0}".format(unknown)) + + +def _model(path: str, build): + """Run a model constructor, reporting its own validation as an input error. + + The models validate their own invariants (`__post_init__`); this keeps that one + definition of "valid" and only re-labels the failure for the API boundary. + """ + try: + return build() + except (ValueError, TypeError, UnsupportedPredicateError, CatalogError) as error: + raise CommerceInputError("{0}: {1}".format(path, error)) from error + + +def _dimensions(value: Any, path: str, axes: int) -> Tuple[int, ...]: + entries = _sequence(value, path) + if len(entries) != axes: + _fail(path, "expected exactly {0} axes".format(axes)) + return tuple(_integer(entry, "{0}[{1}]".format(path, index)) for index, entry in enumerate(entries)) + + +# ------------------------------------------------------------------------- the document + +@dataclass(frozen=True, slots=True) +class CommerceDocument: + """The three append-only histories a request is answered against.""" + + carriers: CarrierRegistry + policies: PolicyRegistry + catalogs: Mapping[str, CatalogRegistry] + + +def load_document(document: Any) -> CommerceDocument: + """Build the registries described by one canonical commerce document.""" + payload = _mapping(document, "document") + _keys(payload, "document", (), ("tariffs", "policy_rules", "catalogs")) + return CommerceDocument( + carriers=_load_tariffs(payload.get("tariffs", ())), + policies=_load_policy_rules(payload.get("policy_rules", ())), + catalogs=_load_catalogs(payload.get("catalogs", ())), + ) + + +# --------------------------------------------------------------------------- tariffs + +def _load_accessorials(value: Any, path: str) -> Dict[str, AccessorialCharge]: + charges: Dict[str, AccessorialCharge] = {} + for index, entry in enumerate(_sequence(value, path)): + entry_path = "{0}[{1}]".format(path, index) + fields = _mapping(entry, entry_path) + _keys(fields, entry_path, ("accessorial_id",), ("flat_charge_minor", "permille_of_base")) + accessorial_id = _text(fields["accessorial_id"], entry_path + ".accessorial_id") + if accessorial_id in charges: + _fail(entry_path, "duplicate accessorial_id {0!r}".format(accessorial_id)) + flat = fields.get("flat_charge_minor") + permille = fields.get("permille_of_base") + charges[accessorial_id] = _model(entry_path, lambda: AccessorialCharge( + accessorial_id=accessorial_id, + flat_charge_minor=None if flat is None else _integer(flat, entry_path + ".flat_charge_minor"), + permille_of_base=None if permille is None else _integer(permille, entry_path + ".permille_of_base"), + )) + return charges + + +def _load_tariffs(value: Any) -> CarrierRegistry: + registry = CarrierRegistry() + seen: set = set() + for index, entry in enumerate(_sequence(value, "document.tariffs")): + path = "document.tariffs[{0}]".format(index) + fields = _mapping(entry, path) + _keys(fields, path, ("carrier_id", "service_id", "versions")) + carrier_id = _text(fields["carrier_id"], path + ".carrier_id") + service_id = _text(fields["service_id"], path + ".service_id") + if (carrier_id, service_id) in seen: + _fail(path, "duplicate tariff history for {0}/{1}".format(carrier_id, service_id)) + seen.add((carrier_id, service_id)) + _publish_tariff_versions(registry, carrier_id, service_id, fields["versions"], path) + return registry + + +def _publish_tariff_versions( + registry: CarrierRegistry, carrier_id: str, service_id: str, value: Any, parent: str, +) -> None: + versions = _sequence(value, parent + ".versions") + if not versions: + _fail(parent + ".versions", "a tariff history needs at least one version") + for index, entry in enumerate(versions): + path = "{0}.versions[{1}]".format(parent, index) + fields = _mapping(entry, path) + _keys( + fields, path, + ("effective_at", "dimensional_weight_divisor", "cost_per_dimensional_kg_minor"), + ("minimum_charge_minor", "fuel_surcharge_permille", "accessorials"), + ) + zones = _mapping(fields["cost_per_dimensional_kg_minor"], path + ".cost_per_dimensional_kg_minor") + costs = { + _text(zone, path + ".cost_per_dimensional_kg_minor key"): + _integer(cost, "{0}.cost_per_dimensional_kg_minor[{1!r}]".format(path, zone)) + for zone, cost in zones.items() + } + accessorials = _load_accessorials(_default(fields, "accessorials", ()), path + ".accessorials") + _model(path, lambda: registry.publish( + carrier_id, service_id, + effective_at=_integer(fields["effective_at"], path + ".effective_at"), + dimensional_weight_divisor=_integer( + fields["dimensional_weight_divisor"], path + ".dimensional_weight_divisor"), + cost_per_dimensional_kg_minor=costs, + minimum_charge_minor=_integer( + _default(fields, "minimum_charge_minor", 0), path + ".minimum_charge_minor"), + fuel_surcharge_permille=_integer( + _default(fields, "fuel_surcharge_permille", 0), path + ".fuel_surcharge_permille"), + accessorials=accessorials, + )) + + +# ----------------------------------------------------------------------- policy rules + +def _enum(kind, value: Any, path: str): + try: + return kind(_text(value, path)) + except ValueError: + _fail(path, "unsupported {0} {1!r}".format(kind.__name__, value)) + + +def _load_predicates(value: Any, path: str) -> List[PolicyPredicate]: + predicates = [] + for index, entry in enumerate(_sequence(value, path)): + entry_path = "{0}[{1}]".format(path, index) + fields = _mapping(entry, entry_path) + _keys(fields, entry_path, ("scope", "field", "operator"), ("value",)) + predicates.append(_model(entry_path, lambda: PolicyPredicate( + scope=_enum(PolicyScope, fields["scope"], entry_path + ".scope"), + field=_text(fields["field"], entry_path + ".field"), + operator=_enum(PolicyOperator, fields["operator"], entry_path + ".operator"), + value=fields.get("value"), + ))) + return predicates + + +def _load_policy_rules(value: Any) -> PolicyRegistry: + registry = PolicyRegistry() + seen: set = set() + for index, entry in enumerate(_sequence(value, "document.policy_rules")): + path = "document.policy_rules[{0}]".format(index) + fields = _mapping(entry, path) + _keys(fields, path, ("rule_id", "versions")) + rule_id = _text(fields["rule_id"], path + ".rule_id") + if rule_id in seen: + _fail(path, "duplicate rule history for {0!r}".format(rule_id)) + seen.add(rule_id) + _publish_rule_versions(registry, rule_id, fields["versions"], path) + return registry + + +def _publish_rule_versions(registry: PolicyRegistry, rule_id: str, value: Any, parent: str) -> None: + versions = _sequence(value, parent + ".versions") + if not versions: + _fail(parent + ".versions", "a rule history needs at least one version") + for index, entry in enumerate(versions): + path = "{0}.versions[{1}]".format(parent, index) + fields = _mapping(entry, path) + _keys(fields, path, ("scope", "action", "predicates", "priority", "effective_at"), ("reason",)) + _model(path, lambda: registry.publish( + rule_id, + scope=_enum(PolicyScope, fields["scope"], path + ".scope"), + action=_enum(PolicyAction, fields["action"], path + ".action"), + predicates=_load_predicates(fields["predicates"], path + ".predicates"), + priority=_integer(fields["priority"], path + ".priority"), + effective_at=_integer(fields["effective_at"], path + ".effective_at"), + reason=_text(_default(fields, "reason", ""), path + ".reason"), + )) + + +# --------------------------------------------------------------------------- catalogs + +def _load_item(fields: Mapping[str, Any], path: str) -> ItemMaster: + _keys(fields, path, ("id", "dimensions_mm", "weight_g"), ("description",)) + return _model(path, lambda: ItemMaster( + id=_text(fields["id"], path + ".id"), + dimensions_mm=_dimensions(fields["dimensions_mm"], path + ".dimensions_mm", 3), + weight_g=_integer(fields["weight_g"], path + ".weight_g"), + description=_text(_default(fields, "description", ""), path + ".description"), + )) + + +def _load_carton(fields: Mapping[str, Any], path: str) -> CartonMaster: + _keys(fields, path, ("id", "inner_dimensions_mm", "max_payload_g"), ("cost_minor",)) + return _model(path, lambda: CartonMaster( + id=_text(fields["id"], path + ".id"), + inner_dimensions_mm=_dimensions(fields["inner_dimensions_mm"], path + ".inner_dimensions_mm", 3), + max_payload_g=_integer(fields["max_payload_g"], path + ".max_payload_g"), + cost_minor=_integer(_default(fields, "cost_minor", 0), path + ".cost_minor"), + )) + + +def _load_pallet(fields: Mapping[str, Any], path: str) -> PalletMaster: + _keys(fields, path, ("id", "deck_dimensions_mm", "max_payload_g"), ("max_stack_height_mm",)) + height = fields.get("max_stack_height_mm") + return _model(path, lambda: PalletMaster( + id=_text(fields["id"], path + ".id"), + deck_dimensions_mm=_dimensions(fields["deck_dimensions_mm"], path + ".deck_dimensions_mm", 2), + max_payload_g=_integer(fields["max_payload_g"], path + ".max_payload_g"), + max_stack_height_mm=None if height is None else _integer(height, path + ".max_stack_height_mm"), + )) + + +_ENTRY_LOADERS = {"item": _load_item, "carton": _load_carton, "pallet": _load_pallet} + + +def _load_exclusion(fields: Mapping[str, Any], path: str) -> ExclusionRule: + _keys(fields, path, ("id", "scope", "subject_id", "excluded_id"), ("reason",)) + return _model(path, lambda: ExclusionRule( + id=_text(fields["id"], path + ".id"), + scope=_enum(ExclusionScope, fields["scope"], path + ".scope"), + subject_id=_text(fields["subject_id"], path + ".subject_id"), + excluded_id=_text(fields["excluded_id"], path + ".excluded_id"), + reason=_text(_default(fields, "reason", ""), path + ".reason"), + )) + + +def _load_override(fields: Mapping[str, Any], path: str) -> FacilityOverride: + _keys(fields, path, ("id", "facility_id", "entry_id", "kind", "override")) + kind = _text(fields["kind"], path + ".kind") + if kind not in _ENTRY_LOADERS: + _fail(path + ".kind", "expected one of {0}".format(sorted(_ENTRY_LOADERS))) + override = _ENTRY_LOADERS[kind](_mapping(fields["override"], path + ".override"), path + ".override") + return _model(path, lambda: FacilityOverride( + id=_text(fields["id"], path + ".id"), + facility_id=_text(fields["facility_id"], path + ".facility_id"), + entry_id=_text(fields["entry_id"], path + ".entry_id"), + override=override, + )) + + +def _load_snapshot(value: Any, path: str) -> CatalogSnapshot: + fields = _mapping(value, path) + _keys(fields, path, (), ("items", "cartons", "pallets", "exclusions", "overrides")) + + def entries(key: str, load): + collected = [] + for index, entry in enumerate(_sequence(_default(fields, key, ()), "{0}.{1}".format(path, key))): + entry_path = "{0}.{1}[{2}]".format(path, key, index) + collected.append(load(_mapping(entry, entry_path), entry_path)) + return tuple(collected) + + return _model(path, lambda: CatalogSnapshot( + items=entries("items", _load_item), + cartons=entries("cartons", _load_carton), + pallets=entries("pallets", _load_pallet), + exclusions=entries("exclusions", _load_exclusion), + overrides=entries("overrides", _load_override), + )) + + +def _load_catalogs(value: Any) -> Dict[str, CatalogRegistry]: + catalogs: Dict[str, CatalogRegistry] = {} + for index, entry in enumerate(_sequence(value, "document.catalogs")): + path = "document.catalogs[{0}]".format(index) + fields = _mapping(entry, path) + _keys(fields, path, ("catalog_id", "versions")) + catalog_id = _text(fields["catalog_id"], path + ".catalog_id") + if catalog_id in catalogs: + _fail(path, "duplicate catalog history for {0!r}".format(catalog_id)) + registry = _model(path, lambda: CatalogRegistry(catalog_id)) + _publish_catalog_versions(registry, fields["versions"], path) + catalogs[catalog_id] = registry + return catalogs + + +def _publish_catalog_versions(registry: CatalogRegistry, value: Any, parent: str) -> None: + versions = _sequence(value, parent + ".versions") + if not versions: + _fail(parent + ".versions", "a catalog history needs at least one version") + for index, entry in enumerate(versions): + path = "{0}.versions[{1}]".format(parent, index) + fields = _mapping(entry, path) + if "rollback_to" in fields: + _publish_rollback(registry, fields, path) + continue + _keys(fields, path, ("effective_at", "published_at", "snapshot"), ("note",)) + _model(path, lambda: registry.publish( + _load_snapshot(fields["snapshot"], path + ".snapshot"), + effective_at=_integer(fields["effective_at"], path + ".effective_at"), + published_at=_integer(fields["published_at"], path + ".published_at"), + note=_text(_default(fields, "note", ""), path + ".note"), + )) + + +def _publish_rollback(registry: CatalogRegistry, fields: Mapping[str, Any], path: str) -> None: + _keys(fields, path, ("rollback_to", "published_at"), ("effective_at", "note")) + effective_at: Optional[int] = None + if fields.get("effective_at") is not None: + effective_at = _integer(fields["effective_at"], path + ".effective_at") + _model(path, lambda: registry.rollback( + _integer(fields["rollback_to"], path + ".rollback_to"), + published_at=_integer(fields["published_at"], path + ".published_at"), + effective_at=effective_at, + note=_text(_default(fields, "note", ""), path + ".note"), + )) diff --git a/src/packvium/commerce/errors.py b/src/packvium/commerce/errors.py new file mode 100644 index 0000000..41e65e4 --- /dev/null +++ b/src/packvium/commerce/errors.py @@ -0,0 +1,37 @@ +"""Error types for the exported commercial and control-plane API. + +Two kinds of failure, deliberately kept apart (docs/COMMERCE-API.md, "Input errors +versus rejections"): + + * a **caller bug** -- a missing key, a negative weight, an unknown policy operator -- + is a `CommerceInputError`, raised the way Python reports caller bugs; + * a **rejection** the commercial model is entitled to make -- no tariff effective at + that instant, no rate for that zone -- is not an exception at all. It is a + successful call returning a result document whose `status` is `"rejected"`, the + same way an infeasible packing request returns a `PackingResult` with a status. + +`_Rejection` is the internal carrier for the second kind between the point of detection +and the API boundary; it never escapes `packvium.commerce`. +""" + +from __future__ import annotations + +from typing import Any, Mapping + + +class CommerceError(Exception): + """Base class for every error raised by `packvium.commerce`.""" + + +class CommerceInputError(CommerceError): + """The supplied document or request is not well formed.""" + + +class _Rejection(Exception): + """Internal: a structured rejection travelling to the API boundary, where it is + turned into the `{"status": "rejected", ...}` result document.""" + + def __init__(self, code: str, fields: Mapping[str, Any]) -> None: + super().__init__(code) + self.code = code + self.fields = dict(fields) diff --git a/src/packvium/commerce/policy.py b/src/packvium/commerce/policy.py new file mode 100644 index 0000000..6445860 --- /dev/null +++ b/src/packvium/commerce/policy.py @@ -0,0 +1,349 @@ +"""Domain model for a versioned eligibility/policy rule engine. + +Facility, customer, carrier, material, hazmat, temperature and service eligibility are +modelled as first-party, versioned, effective-dated predicates rather than an +ad-hoc if/else wall bolted onto the solver: + + * every rule is identified and versioned the same way `packvium.commerce.catalog`'s + catalog is -- append-only per rule id, effective-dated, never mutated in place + (`PolicyRule`, `PolicyRegistry.publish`) -- so "every rejection cites rule id and + version" has a concrete version number to cite, not just a rule name; + * a rule's predicate is a closed, validated vocabulary (`PolicyScope`, `PolicyOperator`) + rather than an arbitrary callable, so a predicate this engine does not understand is a + registration-time `UnsupportedPredicateError`, never a rule that gets silently + admitted and then silently ignored during evaluation ("unsupported predicates fail + admission instead of being ignored"); + * conflicting rules resolve deterministically: an explicit REJECT always outranks an + ALLOW for the same context (deny-takes-precedence), and among several matching + REJECTs the highest `priority` wins, ties broken by the lexicographically smallest + rule id -- never by dict/set iteration order or by which rule happened to register + first (`PolicyRegistry.evaluate`); + * `evaluate()` is a pure function of its inputs with no side effect to "commit" or + undo, so it doubles as its own dry-run interface ("dry-run evaluation is + available") -- there is no separate code path that behaves differently once a + decision is acted on. + +Scope: this remains a solver-independent domain model. The dependency points inward +from `integration/product/policy_constraint.py`, which resolves an immutable versioned +rule snapshot and implements Packvium's native `PlacementConstraint` protocol. Policies +therefore reject candidates inside the solve rather than wrapping or rewriting output, +without making this lower layer import a solver. + +Exported surface: this module is the one definition of the policy model in +the Python tree and ships inside the installed `packvium` distribution. +`domain/policy/model.py` re-exports it so every workspace import keeps resolving to +these exact objects. See docs/COMMERCE-API.md for the wrapper contract built on top. +""" + +from __future__ import annotations + +from .._compat import dataclass +from enum import Enum +from typing import Any, Mapping, Optional, Sequence + + +# --------------------------------------------------------------------------------- errors + +class PolicyError(Exception): + """Base class for every policy-domain error raised by this module.""" + + +class UnsupportedPredicateError(PolicyError): + """A rule's predicate names a scope or operator this engine version does not + recognize. Raised at registration time -- an unsupported predicate is refused + admission outright, never silently admitted and then ignored during evaluation.""" + + +class PolicyRuleNotFoundError(PolicyError): + """No rule is registered under the given rule id. Carries the id itself so an + adapter reporting the rejection machine-readably (docs/COMMERCE-API.md's + `policy_rule_not_found`) reads it off the exception rather than out of prose.""" + + def __init__(self, message: str, *, rule_id: str) -> None: + super().__init__(message) + self.rule_id = rule_id + + +class PolicyVersionNotFoundError(PolicyError): + """An explicitly referenced rule version number does not exist in that rule's + history. Carries the id and the requested number, for the same reason.""" + + def __init__(self, message: str, *, rule_id: str, version: int) -> None: + super().__init__(message) + self.rule_id = rule_id + self.version = version + + +# --------------------------------------------------------------------------------- scope + +class PolicyScope(str, Enum): + """The named eligibility domains this task's description enumerates.""" + + FACILITY = "facility" + CUSTOMER = "customer" + CARRIER = "carrier" + MATERIAL = "material" + HAZMAT = "hazmat" + TEMPERATURE = "temperature" + SERVICE = "service" + + +class PolicyOperator(str, Enum): + """The closed set of predicate comparisons this engine understands. Anything outside + this enum cannot even be constructed as a `PolicyPredicate` (see `__post_init__`), + and `PolicyRegistry.publish` re-checks it defensively (see that method's docstring) + so a rule built by hand rather than through this enum still fails admission.""" + + EQUALS = "equals" + NOT_EQUALS = "not_equals" + IN = "in" + NOT_IN = "not_in" + EXISTS = "exists" + ABSENT = "absent" + + +class PolicyAction(str, Enum): + ALLOW = "allow" + REJECT = "reject" + + +_SUPPORTED_OPERATORS = frozenset(operator.value for operator in PolicyOperator) +_SUPPORTED_SCOPES = frozenset(scope.value for scope in PolicyScope) + +#: Operators that take no `value` (a bare presence/absence check on `field`). +_UNARY_OPERATORS = frozenset({PolicyOperator.EXISTS, PolicyOperator.ABSENT}) + + +@dataclass(frozen=True, slots=True) +class PolicyPredicate: + """One condition over the caller-supplied context: `context[field] value` + (or, for `EXISTS`/`ABSENT`, just whether `field` is present).""" + + scope: PolicyScope + field: str + operator: PolicyOperator + value: Any = None + + def __post_init__(self) -> None: + # A caller may pass either the enum member or its raw string value (e.g. a + # predicate deserialized from an untrusted wire payload); coerce to the enum + # member so the rest of this module only ever compares members, and turn an + # unrecognized value into this module's own `UnsupportedPredicateError` rather + # than a generic `ValueError` from the stdlib enum machinery. + try: + scope = self.scope if isinstance(self.scope, PolicyScope) else PolicyScope(self.scope) + except ValueError: + raise UnsupportedPredicateError(f"unsupported policy scope {self.scope!r}") from None + try: + operator = self.operator if isinstance(self.operator, PolicyOperator) else PolicyOperator(self.operator) + except ValueError: + raise UnsupportedPredicateError(f"unsupported policy operator {self.operator!r}") from None + object.__setattr__(self, "scope", scope) + object.__setattr__(self, "operator", operator) + if not self.field: + raise ValueError("field is required") + if operator not in _UNARY_OPERATORS and self.value is None: + raise ValueError(f"operator {operator.value!r} requires a value") + + def matches(self, context: Mapping[str, Any]) -> bool: + if self.operator is PolicyOperator.EXISTS: + return self.field in context + if self.operator is PolicyOperator.ABSENT: + return self.field not in context + if self.field not in context: + return False + actual = context[self.field] + if self.operator is PolicyOperator.EQUALS: + return actual == self.value + if self.operator is PolicyOperator.NOT_EQUALS: + return actual != self.value + if self.operator is PolicyOperator.IN: + return actual in self.value + if self.operator is PolicyOperator.NOT_IN: + return actual not in self.value + raise UnsupportedPredicateError(f"unsupported policy operator {self.operator!r}") # pragma: no cover + + +# ----------------------------------------------------------------------------------- rule + +@dataclass(frozen=True, slots=True) +class PolicyRule: + """One immutable, numbered version of one rule id's history (mirrors + `packvium.commerce.catalog`'s `CatalogVersion`: append-only, never mutated in place). + All of a rule id's predicates must match (logical AND) for the rule to apply.""" + + rule_id: str + version: int + scope: PolicyScope + action: PolicyAction + predicates: tuple[PolicyPredicate, ...] + priority: int + effective_at: int + reason: str = "" + + def __post_init__(self) -> None: + if not self.rule_id: + raise ValueError("rule_id is required") + if self.version <= 0: + raise ValueError("version must be positive") + if not self.predicates: + raise ValueError("a rule must have at least one predicate") + if self.effective_at < 0: + raise ValueError("effective_at cannot be negative") + mismatched = [p for p in self.predicates if p.scope is not self.scope] + if mismatched: + raise ValueError(f"every predicate of rule {self.rule_id!r} must share the rule's own scope") + + def matches(self, context: Mapping[str, Any]) -> bool: + return all(predicate.matches(context) for predicate in self.predicates) + + +@dataclass(frozen=True, slots=True) +class PolicyCitation: + """The evidence a decision cites: exactly which rule id/version produced it, and + why. `None` when no rule matched at all (the open-by-default ALLOW case, see + `PolicyRegistry.evaluate`'s docstring).""" + + rule_id: str + version: int + action: PolicyAction + priority: int + reason: str + + +@dataclass(frozen=True, slots=True) +class PolicyDecision: + """The outcome of one `evaluate()` call: whether the context is admitted for the + given scope, and the single rule (if any) whose citation explains why.""" + + scope: PolicyScope + allowed: bool + citation: Optional[PolicyCitation] = None + + def __post_init__(self) -> None: + if not self.allowed and self.citation is None: + raise ValueError("a REJECT decision must carry a citation") + + +# ------------------------------------------------------------------------------ registry + +def _resolve_effective(versions: Sequence[PolicyRule], *, as_of: int) -> PolicyRule | None: + """The same effective-dating resolution `CatalogRegistry._resolve_version` uses for + `as_of` lookups: the highest `effective_at` not after `as_of`, ties broken by the + higher (later-published) version number. `None` if no version of this rule id has + yet taken effect by `as_of`.""" + 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)) + + +def decide(rules: Sequence[PolicyRule], scope: PolicyScope, context: Mapping[str, Any]) -> PolicyDecision: + """Evaluate an already-resolved immutable rule set. + + Keeping resolution separate lets a solver adapter pin exact rule versions once per + request and reuse them for every placement candidate. Evaluation is ``O(r * p)`` in + matching rules and predicates, with no registry lookup and no mutable state. + """ + matching = [rule for rule in rules if rule.scope is scope and rule.matches(context)] + rejects = [rule for rule in matching if rule.action is PolicyAction.REJECT] + allows = [rule for rule in matching if rule.action is PolicyAction.ALLOW] + pool = rejects or allows + if not pool: + return PolicyDecision(scope=scope, allowed=True, citation=None) + + winner = min(pool, key=lambda rule: (-rule.priority, rule.rule_id)) + citation = PolicyCitation( + rule_id=winner.rule_id, version=winner.version, action=winner.action, + priority=winner.priority, reason=winner.reason, + ) + return PolicyDecision(scope=scope, allowed=winner.action is PolicyAction.ALLOW, citation=citation) + + +class PolicyRegistry: + """Per-tenant (or global) append-only history of policy rules, one history per + `rule_id`. See the module docstring for what this contract does and does not cover.""" + + def __init__(self) -> None: + self._versions: dict[str, list[PolicyRule]] = {} + + def publish( + self, rule_id: str, *, scope: PolicyScope, action: PolicyAction, + predicates: Sequence[PolicyPredicate], priority: int, effective_at: int, reason: str = "", + ) -> PolicyRule: + """Append a new, numbered version for `rule_id`. Re-validates scope/operator + support defensively (on top of `PolicyPredicate.__post_init__`) so a caller + cannot bypass the "unsupported predicates fail admission" guarantee by + constructing a predicate through any path other than the public enums.""" + for predicate in predicates: + scope_value = predicate.scope.value if isinstance(predicate.scope, PolicyScope) else predicate.scope + operator_value = ( + predicate.operator.value if isinstance(predicate.operator, PolicyOperator) else predicate.operator + ) + if scope_value not in _SUPPORTED_SCOPES or operator_value not in _SUPPORTED_OPERATORS: + raise UnsupportedPredicateError( + f"rule {rule_id!r} uses an unsupported scope/operator combination" + ) + history = self._versions.setdefault(rule_id, []) + rule = PolicyRule( + rule_id=rule_id, + version=len(history) + 1, + scope=scope, + action=action, + predicates=tuple(predicates), + priority=priority, + effective_at=effective_at, + reason=reason, + ) + history.append(rule) + return rule + + def versions(self, rule_id: str) -> tuple[PolicyRule, ...]: + if rule_id not in self._versions: + raise PolicyRuleNotFoundError(f"no rule registered under id {rule_id!r}", rule_id=rule_id) + return tuple(self._versions[rule_id]) + + def version(self, rule_id: str, number: int) -> PolicyRule: + for rule in self.versions(rule_id): + if rule.version == number: + return rule + raise PolicyVersionNotFoundError( + f"rule {rule_id!r} has no version {number}", rule_id=rule_id, version=number, + ) + + def resolve_versions(self, versions: Sequence[tuple[str, int]]) -> tuple[PolicyRule, ...]: + """Resolve and deterministically order an explicit policy snapshot.""" + pins = tuple(versions) + if len({rule_id for rule_id, _ in pins}) != len(pins): + raise ValueError("a policy snapshot cannot pin the same rule id twice") + return tuple(self.version(rule_id, number) for rule_id, number in sorted(pins)) + + def evaluate(self, scope: PolicyScope, context: Mapping[str, Any], *, as_of: int) -> PolicyDecision: + """Resolve one deterministic decision for `scope`/`context` as of `as_of`. + + For every rule id, only the version effective as of `as_of` is considered (an + as-yet-ineffective or not-yet-published version never participates). Among the + rules of matching `scope` whose predicates all match `context`: + + * if any matching rule's action is REJECT, the REJECT with the highest + `priority` wins (ties broken by the lexicographically smallest `rule_id`) -- + an explicit REJECT always outranks an ALLOW for the same context, so one + permissive rule can never quietly override a more specific denial; + * otherwise, if any matching rule's action is ALLOW, the highest-priority one + (same tie-break) is cited; + * if nothing matches at all, the context is allowed with no citation + (open-by-default) -- a scope with zero registered rules is not the same as + a scope where everything is rejected. + + This method has no side effect and nothing to undo, so it is itself the + dry-run interface: dry-run evaluation is available without a second code path. + """ + matching: list[PolicyRule] = [] + for rule_id, history in self._versions.items(): + effective = _resolve_effective(history, as_of=as_of) + if effective is None or effective.scope is not scope: + continue + if effective.matches(context): + matching.append(effective) + + return decide(matching, scope, context) diff --git a/src/packvium/commerce/rating.py b/src/packvium/commerce/rating.py new file mode 100644 index 0000000..60dae3e --- /dev/null +++ b/src/packvium/commerce/rating.py @@ -0,0 +1,336 @@ +"""Domain model for a versioned carrier rating / landed-cost engine. + +Carrier services, dimensional-weight divisors, zone rates, minimum charges, fuel and +accessorial surcharges are modelled as versioned first-party rules, the same append-only +discipline `packvium.commerce.catalog`'s catalog and `packvium.commerce.policy`'s policy rules +already use: + + * every `Tariff` is a numbered, effective-dated version of one `(carrier_id, + service_id)` pair, never mutated in place -- a `RateBreakdown`'s `tariff_version` + is a citation someone can independently look up later, not a label that could have + silently drifted ("each alternative carries an auditable rate breakdown and tariff + version"); + * dimensional weight, the minimum-charge floor, the fuel surcharge and every + accessorial surcharge are computed with exact integer arithmetic only -- ticks for + length, minor currency units (cents) for cost, permille (parts-per-1000) for + percentage-shaped rates -- and any division that is not exact rounds up + (`_ceil_div`), never down and never through a float ("dimensional weight and + surcharges are exact"); + * a zone this tariff has no rate for, or a requested accessorial this tariff does not + define, is a structured `UnavailableServiceError` naming exactly which zone or + accessorial id was missing -- never a silently-zero or silently-skipped charge + ("unavailable services are structured rejections"); + * `rate_with_version` pins an explicit tariff version rather than resolving "current", + so a stored `RateBreakdown` can be reproduced byte-for-byte later purely from its own + `tariff_id`/`tariff_version`, independent of whatever the registry's history has + grown to since ("offline deterministic replay is possible"). + +Scope: this module computes a rate breakdown from first-party tariff data a caller +publishes into `CarrierRegistry` -- it does not fetch, scrape or embed any real carrier's +published rates (the illustrative tariffs in this module's own tests are synthetic). +The dependency remains one-way: `commerce/rating/objective.py` (workspace) adapts this independent +domain model to Packvium's solution scorer and container selector, so the exact +`RateBreakdown.total_minor` participates in selection without importing a solver here. + +Exported surface: this module is the one definition of the rating model in +the Python tree and ships inside the installed `packvium` distribution. +`commerce/rating/model.py` re-exports it so every workspace import keeps resolving to +these exact objects. See docs/COMMERCE-API.md for the wrapper contract built on top. +""" + +from __future__ import annotations + +from .._compat import dataclass +from typing import Mapping, Optional + + +# --------------------------------------------------------------------------------- errors + +class RatingError(Exception): + """Base class for every rating-domain error raised by this module.""" + + +class UnavailableServiceError(RatingError): + """The requested zone or accessorial is not defined by the resolved tariff -- a + structured rejection naming exactly what was missing, never a silently-zero charge. + + Exactly one of `zone` / `accessorial_ids` is set, so a caller that has to report + the rejection in a machine-readable form (docs/COMMERCE-API.md's `unavailable_zone` + and `unavailable_accessorial` codes) reads it off the exception instead of parsing + the message back out of prose.""" + + def __init__( + self, message: str, *, zone: Optional[str] = None, accessorial_ids: tuple[str, ...] = (), + ) -> None: + super().__init__(message) + self.zone = zone + self.accessorial_ids = tuple(accessorial_ids) + + +class TariffNotFoundError(RatingError): + """No tariff is registered under the given carrier/service id, or no version of it + is effective at the requested time / exists at the requested version number.""" + + +def _ceil_div(numerator: int, denominator: int) -> int: + """Integer ceiling division -- every inexact division in this module (dimensional + weight, permille-based surcharges) rounds up, never down, and never through a float.""" + if denominator <= 0: + raise ValueError("denominator must be positive") + return -(-numerator // denominator) + + +# --------------------------------------------------------------------------------- tariff + +@dataclass(frozen=True, slots=True) +class AccessorialCharge: + """One named accessorial (e.g. residential delivery, liftgate, signature-required), + either a flat charge or a permille-of-base charge -- never both.""" + + accessorial_id: str + flat_charge_minor: Optional[int] = None + permille_of_base: Optional[int] = None + + def __post_init__(self) -> None: + if not self.accessorial_id: + raise ValueError("accessorial_id is required") + has_flat = self.flat_charge_minor is not None + has_permille = self.permille_of_base is not None + if has_flat == has_permille: + raise ValueError("an accessorial must set exactly one of flat_charge_minor or permille_of_base") + if has_flat and self.flat_charge_minor < 0: + raise ValueError("flat_charge_minor cannot be negative") + if has_permille and self.permille_of_base < 0: + raise ValueError("permille_of_base cannot be negative") + + def charge_minor(self, base_charge_minor: int) -> int: + if self.flat_charge_minor is not None: + return self.flat_charge_minor + return _ceil_div(base_charge_minor * self.permille_of_base, 1000) + + +@dataclass(frozen=True, slots=True) +class Tariff: + """One immutable, numbered version of one `(carrier_id, service_id)` pair's rate + card. Mirrors `packvium.commerce.catalog`'s `CatalogVersion`: append-only, effective- + dated, never mutated in place.""" + + carrier_id: str + service_id: str + version: int + effective_at: int + dimensional_weight_divisor: int + cost_per_dimensional_kg_minor: Mapping[str, int] # zone -> minor cost per kg (1000 g) + minimum_charge_minor: int + fuel_surcharge_permille: int + accessorials: Mapping[str, AccessorialCharge] + + def __post_init__(self) -> None: + if not self.carrier_id: + raise ValueError("carrier_id is required") + if not self.service_id: + raise ValueError("service_id is required") + if self.version <= 0: + raise ValueError("version must be positive") + if self.effective_at < 0: + raise ValueError("effective_at cannot be negative") + if self.dimensional_weight_divisor <= 0: + raise ValueError("dimensional_weight_divisor must be positive") + if self.minimum_charge_minor < 0: + raise ValueError("minimum_charge_minor cannot be negative") + if self.fuel_surcharge_permille < 0: + raise ValueError("fuel_surcharge_permille cannot be negative") + if any(cost < 0 for cost in self.cost_per_dimensional_kg_minor.values()): + raise ValueError("cost_per_dimensional_kg_minor entries cannot be negative") + mismatched = [key for key, value in self.accessorials.items() if key != value.accessorial_id] + if mismatched: + raise ValueError(f"accessorials dict key must match its own accessorial_id: {mismatched}") + + +# ---------------------------------------------------------------------------- breakdown + +@dataclass(frozen=True, slots=True) +class RateBreakdown: + """The fully itemized, auditable result of one `rate()`/`rate_with_version()` call. + Every component that contributed to `total_minor` is named individually, and the + exact tariff version that produced it is recorded alongside.""" + + carrier_id: str + service_id: str + tariff_version: int + zone: str + actual_weight_g: int + dimensional_weight_g: int + billed_weight_g: int + base_charge_minor: int + minimum_charge_applied: bool + fuel_surcharge_minor: int + accessorial_charges_minor: tuple[tuple[str, int], ...] + total_minor: int + + +# ------------------------------------------------------------------------------ request + +@dataclass(frozen=True, slots=True) +class RatingRequest: + """What is being rated: a shipment's real weight and volume, the zone it is moving + to, and whichever accessorial services this specific shipment needs.""" + + zone: str + actual_weight_g: int + volume_mm3: int + requested_accessorials: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not self.zone: + raise ValueError("zone is required") + if self.actual_weight_g < 0: + raise ValueError("actual_weight_g cannot be negative") + if self.volume_mm3 < 0: + raise ValueError("volume_mm3 cannot be negative") + if any(not accessorial for accessorial in self.requested_accessorials): + raise ValueError("requested accessorial ids must be non-empty") + if len(set(self.requested_accessorials)) != len(self.requested_accessorials): + raise ValueError("requested accessorial ids must be unique") + + +def rate_tariff(tariff: Tariff, request: RatingRequest) -> RateBreakdown: + """Rate a request against one already-resolved immutable tariff version.""" + if request.zone not in tariff.cost_per_dimensional_kg_minor: + raise UnavailableServiceError( + f"tariff {tariff.carrier_id}/{tariff.service_id} v{tariff.version} has no rate for " + f"zone {request.zone!r}", + zone=request.zone, + ) + unknown_accessorials = set(request.requested_accessorials) - set(tariff.accessorials) + if unknown_accessorials: + raise UnavailableServiceError( + f"tariff {tariff.carrier_id}/{tariff.service_id} v{tariff.version} does not offer " + f"accessorial(s) {sorted(unknown_accessorials)}", + accessorial_ids=tuple(sorted(unknown_accessorials)), + ) + + # 1 ticks-cubed volume unit maps to 1 mm^3 in this repo's own units convention + # (packvium.units); dimensional weight in grams is volume (mm^3) / divisor, + # rounded up -- never down, never through a float. + dimensional_weight_g = _ceil_div(request.volume_mm3, tariff.dimensional_weight_divisor) + billed_weight_g = max(request.actual_weight_g, dimensional_weight_g) + + rate_per_kg = tariff.cost_per_dimensional_kg_minor[request.zone] + raw_base_charge_minor = _ceil_div(billed_weight_g * rate_per_kg, 1000) + minimum_applied = raw_base_charge_minor < tariff.minimum_charge_minor + base_charge_minor = tariff.minimum_charge_minor if minimum_applied else raw_base_charge_minor + + fuel_surcharge_minor = _ceil_div(base_charge_minor * tariff.fuel_surcharge_permille, 1000) + + accessorial_charges = tuple( + (accessorial_id, tariff.accessorials[accessorial_id].charge_minor(base_charge_minor)) + for accessorial_id in request.requested_accessorials + ) + total_minor = base_charge_minor + fuel_surcharge_minor + sum( + amount for _, amount in accessorial_charges + ) + + return RateBreakdown( + carrier_id=tariff.carrier_id, + service_id=tariff.service_id, + tariff_version=tariff.version, + zone=request.zone, + actual_weight_g=request.actual_weight_g, + dimensional_weight_g=dimensional_weight_g, + billed_weight_g=billed_weight_g, + base_charge_minor=base_charge_minor, + minimum_charge_applied=minimum_applied, + fuel_surcharge_minor=fuel_surcharge_minor, + accessorial_charges_minor=accessorial_charges, + total_minor=total_minor, + ) + + +# ------------------------------------------------------------------------------ registry + +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)) + + +class CarrierRegistry: + """Per-`(carrier_id, service_id)` append-only tariff history. See the module + docstring for what this contract does and does not cover.""" + + def __init__(self) -> None: + self._versions: dict[tuple[str, str], list[Tariff]] = {} + + def publish( + self, carrier_id: str, service_id: str, *, effective_at: int, + dimensional_weight_divisor: int, cost_per_dimensional_kg_minor: Mapping[str, int], + minimum_charge_minor: int = 0, fuel_surcharge_permille: int = 0, + accessorials: Mapping[str, AccessorialCharge] = (), + ) -> Tariff: + """Append a new, numbered tariff version for `(carrier_id, service_id)`.""" + key = (carrier_id, service_id) + history = self._versions.setdefault(key, []) + tariff = Tariff( + carrier_id=carrier_id, + service_id=service_id, + version=len(history) + 1, + effective_at=effective_at, + dimensional_weight_divisor=dimensional_weight_divisor, + cost_per_dimensional_kg_minor=dict(cost_per_dimensional_kg_minor), + minimum_charge_minor=minimum_charge_minor, + fuel_surcharge_permille=fuel_surcharge_permille, + accessorials=dict(accessorials), + ) + history.append(tariff) + return tariff + + def versions(self, carrier_id: str, service_id: str) -> tuple[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]) + + def tariff(self, carrier_id: str, service_id: str, version: int) -> Tariff: + """Resolve one immutable tariff version without performing a rating. + + Application adapters use this once and then evaluate every candidate against + 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 + 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: + """Resolve the tariff version effective at `as_of` without performing a rating. + + The counterpart of `tariff()` for the effective-dated path: an adapter that has + 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) + if tariff is None: + raise TariffNotFoundError( + f"{carrier_id}/{service_id} has no tariff version effective as of {as_of}" + ) + return tariff + + def rate(self, carrier_id: str, service_id: str, request: RatingRequest, *, as_of: int) -> RateBreakdown: + """Resolve the tariff version effective at `as_of` for `(carrier_id, + service_id)` and compute its rate breakdown for `request`.""" + return rate_tariff(self.effective_tariff(carrier_id, service_id, as_of=as_of), request) + + def rate_with_version( + self, carrier_id: str, service_id: str, version: int, request: RatingRequest, + ) -> RateBreakdown: + """Resolve one explicit, pinned tariff version rather than an `as_of` lookup -- + the deterministic-offline-replay path: given the same `(carrier_id, service_id, + version)` and the same `request`, this always reproduces the identical + `RateBreakdown`, regardless of what the registry's history has grown to since.""" + return rate_tariff(self.tariff(carrier_id, service_id, version), request) diff --git a/src/packvium/extensions.py b/src/packvium/extensions.py index 240e13b..2aead64 100644 --- a/src/packvium/extensions.py +++ b/src/packvium/extensions.py @@ -141,6 +141,12 @@ class LandedCostSolutionScorer(ShippingCostSolutionScorer): Every container must carry a `rate_table`. Rating some containers and not others would silently rank a priced packing against an unpriced one as though the unpriced were free, so a missing table is a rejection. + + A billed weight past the last bracket is different: it is a property of how the search + happened to fill the box, not of the request, so it loses a candidate rather than + aborting a run that has a perfectly shippable alternative. Scoring it `UNPRICEABLE_MINOR` + is what makes the priceable alternative win; `pack` refuses if that sentinel is still + standing when an answer is about to be returned. """ def score_containers( @@ -161,10 +167,52 @@ def score_containers( dimensions = c.container.outer_dimensions or c.container.inner_dimensions dim_weight = dimensional_weight(dimensions, self.divisor, self.length_unit, self.weight_unit) billed_ticks = max(c.gross_weight.ticks, dim_weight.ticks) - landed += table.charge_minor(_grams(billed_ticks)) + charge = table.charge_minor_or_none(_grams(billed_ticks)) + if charge is None: + landed = UNPRICEABLE_MINOR + break + landed += charge return (unpacked_count, landed, container_count, unused, height) +#: Ranks a packing the tariff cannot price behind every priceable one during search. +#: It is a search device, never an answer -- `pack` refuses before a solution carrying it +#: can be returned -- so its exact magnitude only has to dominate any real total. The value +#: is Rust's `i128::MAX as i64` so the three engines that need a sentinel share one. +UNPRICEABLE_MINOR = 2**63 - 1 + + +def unpriceable_container( + containers: Sequence["PackedContainer"], config: "PackingConfig" +) -> "tuple[str, int, int] | None": + """The first container in a finished answer its own rate table cannot price, as + `(container id, billed grams, last bracket)`. + + Ranking an unpriceable candidate worst is what lets a priceable alternative win the + round. This is the guard that stops the sentinel from surfacing: returning a packing + the tariff cannot price would quote a number the carrier never published. + """ + from .geometry import dimensional_weight + + if config.objective != "lowest_landed_cost" or config.dimensional_weight_divisor is None: + return None + for c in containers: + table = c.container.rate_table + dimensions = c.container.outer_dimensions or c.container.inner_dimensions + dim_weight = dimensional_weight( + dimensions, + config.dimensional_weight_divisor, + config.dimensional_weight_length_unit, + config.dimensional_weight_weight_unit, + ) + grams = _grams(max(c.gross_weight.ticks, dim_weight.ticks)) + if table is None: + return (c.container.id, grams, 0) + if table.charge_minor_or_none(grams) is None: + return (c.container.id, grams, table.weight_brackets_g[-1]) + return None + + def _grams(weight_ticks: int) -> int: """Billed weight in whole grams, rounded up. diff --git a/src/packvium/models.py b/src/packvium/models.py index 01bf1f1..ba9868f 100644 --- a/src/packvium/models.py +++ b/src/packvium/models.py @@ -170,12 +170,14 @@ def __post_init__(self) -> None: if self.fuel_surcharge_permille < 0: raise ValueError("rate_table fuel_surcharge_permille cannot be negative") - def charge_minor(self, billed_weight_g: int) -> int: - """The exact landed cost of one shipment at this billed weight. - - Raises `UnratedWeightError` above the last bracket rather than clamping to the top - price. Clamping would quietly under-price every oversize shipment and, worse, make - the objective prefer a packing the caller cannot actually ship at that price. + def charge_minor_or_none(self, billed_weight_g: int) -> int | None: + """The exact landed cost of one shipment at this billed weight, or `None` when the + tariff does not price it. + + The search needs to *compare* an unpriceable candidate rather than abort on one: + a container whose tariff runs out at this weight must lose to one that can price + the load, which it cannot do if asking the question raises. `charge_minor` is the + same walk for callers who want the refusal. """ for bound, price in zip(self.weight_brackets_g, self.prices_minor): if billed_weight_g <= bound: @@ -184,10 +186,22 @@ def charge_minor(self, billed_weight_g: int) -> int: # and rounding down would let a fractional unit of revenue vanish. surcharge = -(-base * self.fuel_surcharge_permille // 1000) return base + surcharge - raise UnratedWeightError( - f"billed weight {billed_weight_g} g is above the rate table's last bracket " - f"({self.weight_brackets_g[-1]} g); the shipment has no published price" - ) + return None + + def charge_minor(self, billed_weight_g: int) -> int: + """The exact landed cost of one shipment at this billed weight. + + Raises `UnratedWeightError` above the last bracket rather than clamping to the top + price. Clamping would quietly under-price every oversize shipment and, worse, make + the objective prefer a packing the caller cannot actually ship at that price. + """ + charge = self.charge_minor_or_none(billed_weight_g) + if charge is None: + raise UnratedWeightError( + f"billed weight {billed_weight_g} g is above the rate table's last bracket " + f"({self.weight_brackets_g[-1]} g); the shipment has no published price" + ) + return charge class UnratedWeightError(ValueError): diff --git a/src/packvium/packer.py b/src/packvium/packer.py index 3c24bc3..1b08b82 100644 --- a/src/packvium/packer.py +++ b/src/packvium/packer.py @@ -4,8 +4,9 @@ from dataclasses import replace from .config import PackingConfig -from .extensions import ExtensionRegistry, SolutionScorer, resolve_objective_scorer -from .models import Container, Item, PackingRequest +from .extensions import (ExtensionRegistry, SolutionScorer, UnknownObjectiveError, + resolve_objective_scorer, unpriceable_container) +from .models import Container, Item, PackingRequest, UnratedWeightError from .result import AlgorithmReport, PackingResult, PackingStatus from .result import aggregate_termination from .solvers import Deadline, SolverOrchestrator @@ -33,6 +34,30 @@ def __init__( def pack(self, items, containers) -> PackingResult: request = PackingRequest(tuple(items), tuple(containers)) + # Both weight objectives price the same billed weight, so both need the divisor + # up front -- a wrong guess would silently misprice every shipment. And rating + # some containers while others carry no tariff would rank a priced packing + # against an unpriced one as though the unpriced were free: a missing rate table + # is a static property of the request, unlike a billed weight past the last + # bracket, which depends on how the search filled the box and loses a candidate + # instead. Rust and the JavaScript fallback refuse both at admission with these + # same sentences ( review); the scorer's late checks stay as the backstop + # for callers who bypass `Packer.pack`. + if ( + self.config.objective in ("shipping_cost", "lowest_landed_cost") + and self.config.dimensional_weight_divisor is None + ): + raise UnknownObjectiveError( + f"the {self.config.objective} objective requires " + f"configuration.dimensional_weight_divisor" + ) + if self.config.objective == "lowest_landed_cost": + unrated = next((c for c in request.containers if c.rate_table is None), None) + if unrated is not None: + raise UnknownObjectiveError( + f"the lowest_landed_cost objective requires a rate_table on every " + f"container; {unrated.id!r} has none" + ) deadline = ( Deadline(self.config.time_limit_ms, clock=self.clock) if self.clock is not None @@ -131,6 +156,19 @@ def pack(self, items, containers) -> PackingResult: valid_ranked = [result for result in ranked if result.status is not PackingStatus.INVALID_RESULT] selected = valid_ranked or ranked best = selected[0] + # The search ranks an unpriceable packing worst so that any priceable alternative + # beats it; reaching here with one still winning means no alternative existed. + # Returning it would quote a number the carrier never published, so the run is + # refused -- the same refusal the other three engines give, in the same words. The + # refusal is deliberately here and not in the scorer: raising while *comparing* + # candidates would abort runs that have a perfectly shippable answer. + unpriceable = unpriceable_container(best.containers, self.config) + if unpriceable is not None: + container_id, grams, bound = unpriceable + raise UnratedWeightError( + f"container {container_id!r} bills at {grams} g, above its rate table's " + f"last bracket ({bound} g); the shipment has no published price" + ) return PackingResult( best.status, best.containers, @@ -138,7 +176,14 @@ def pack(self, items, containers) -> PackingResult: best.algorithm, best.score, best.warnings, - tuple(selected[1:self.config.top_k]), + # The sentinel is a search device, never an answer -- alternatives included. + # A runner-up the tariff cannot price is dropped before the slice, so up to + # top_k-1 usable packings survive when priceable runners exist beyond an + # unpriceable one ( review). + tuple( + runner for runner in selected[1:] + if unpriceable_container(runner.containers, self.config) is None + )[: max(0, self.config.top_k - 1)], best.feasibility, best.termination, best.optimality, diff --git a/src/packvium/rebalance.py b/src/packvium/rebalance.py index c352a41..a07453d 100644 --- a/src/packvium/rebalance.py +++ b/src/packvium/rebalance.py @@ -7,7 +7,9 @@ from .config import PackingConfig from .constraints import direct_support_view, load_units, top_loads from .geometry import AxisAlignedBox, Dimensions, Point -from .models import ItemInstance, PackedContainer, PackingRequest, Placement, UnpackedItem +from .extensions import UnknownObjectiveError, unpriceable_container +from .models import (ItemInstance, PackedContainer, PackingRequest, Placement, UnpackedItem, + UnratedWeightError) from .solvers import (ContainerState, Deadline, SearchStats, TimeLimitReached, default_constraints, find_candidates) from .units import Weight @@ -158,6 +160,33 @@ def rebalance_weight( reaches the smallest spread achievable by some other arrangement. """ config = config or PackingConfig() + if ( + config.objective in ("shipping_cost", "lowest_landed_cost") + and config.dimensional_weight_divisor is None + ): + raise UnknownObjectiveError( + f"the {config.objective} objective requires " + "configuration.dimensional_weight_divisor" + ) + if config.objective == "lowest_landed_cost": + unrated = next((c for c in request.containers if c.rate_table is None), None) + if unrated is not None: + raise UnknownObjectiveError( + "the lowest_landed_cost objective requires a rate_table on every " + f"container; {unrated.id!r} has none" + ) + # A packing the tariff cannot price is refused here for the same reason + # `Packer.pack` refuses one on the way out: rebalancing it would hand back a + # shipment with no published price under the caller's own objective ( + # review). Objective-gated inside the helper, so every other objective -- and a + # call without a config -- is untouched. + unpriceable = unpriceable_container(tuple(containers), config) + if unpriceable is not None: + container_id, grams, bound = unpriceable + raise UnratedWeightError( + f"container {container_id!r} bills at {grams} g, above its rate table's " + f"last bracket ({bound} g); the shipment has no published price" + ) validator = IndependentSolutionValidator() deadline = Deadline(time_limit_ms) working = list(containers) @@ -201,6 +230,11 @@ def rebalance_weight( trial = None if trial is None: continue + # A move that prices the destination past its tariff is not an + # improvement: the sentinel must never ride out through a rebalanced + # packing any more than through a packed one ( review). + if unpriceable_container(tuple(trial), config) is not None: + continue committed = (trial, source.placements[placement_index].instance.id, source.id, working[dest_index].id) break if committed is not None: diff --git a/src/packvium/solvers.py b/src/packvium/solvers.py index 97a6fdd..1e569d9 100644 --- a/src/packvium/solvers.py +++ b/src/packvium/solvers.py @@ -11,6 +11,9 @@ from .axle_load import axle_balanced_origins from .config import PackingConfig from .effort import EffortBudget +# `_grams` rather than a local ceil-div: the round-up from ticks to whole grams is the +# published billing rule, and a second copy of it could drift a bracket. +from .extensions import UNPRICEABLE_MINOR, _grams from .constraints import (AxleLoadConstraint, CompatibilityConstraint, ConstraintContext, ContainerEligibilityConstraint, FloorConstraint, LoadUnit, PlacementConstraint, RIDES_THE_WHOLE_ROUTE, RouteOrderConstraint, SupportConstraint, @@ -1396,22 +1399,49 @@ def pack_one(self, container, sequence, items, config, stats, deadline): best = ContainerState(container, sequence) best_volume = 0 + def state_rank(state: ContainerState, state_volume: int) -> tuple[int, ...]: + """The exact solver's incumbent order for this one container. + + Landed cost precedes unused volume in the public objective. A promotional + bracket may make a heavier equal-count subset cheaper, so the historical + count/volume rank was wrong for this objective ( second review). + """ + count = len(state.placements) + if config.objective != "lowest_landed_cost": + return (-count, -state_volume) + dimensions = container.outer_dimensions or container.inner_dimensions + dim_weight = dimensional_weight( + dimensions, + config.dimensional_weight_divisor, + config.dimensional_weight_length_unit, + config.dimensional_weight_weight_unit, + ) + billed_ticks = max(container.tare_weight.ticks + state.payload_ticks, dim_weight.ticks) + table = container.rate_table + charge = None if table is None else table.charge_minor_or_none(_grams(billed_ticks)) + return (-count, UNPRICEABLE_MINOR if charge is None else charge, -state_volume) + + best_rank = state_rank(best, best_volume) + def dfs(index: int, state: ContainerState, reachable: int, state_volume: int) -> None: - nonlocal best, best_volume + nonlocal best, best_volume, best_rank if deadline.expired: return stats.search_nodes_expanded += 1 state_count = len(state.placements) best_count = len(best.placements) - if _is_better_state_values(state_count, state_volume, best_count, best_volume): + candidate_rank = state_rank(state, state_volume) + if candidate_rank < best_rank: best = state best_volume = state_volume + best_rank = candidate_rank if index >= len(batches): return potential_count = state_count + reachable best_count = len(best.placements) if potential_count < best_count: return - if (potential_count == best_count + if (config.objective != "lowest_landed_cost" + and potential_count == best_count and state_volume + suffix_volumes[index] <= best_volume): return batch = batches[index] @@ -2249,7 +2279,7 @@ def _across_containers_greedy(self, solver, items, containers, config, stats, de break continue if ( - config.objective == "shipping_cost" + config.objective in ("shipping_cost", "lowest_landed_cost") and config.dimensional_weight_divisor is not None ): dimensions = container.outer_dimensions or container.inner_dimensions @@ -2259,15 +2289,36 @@ def _across_containers_greedy(self, solver, items, containers, config, stats, de config.dimensional_weight_length_unit, config.dimensional_weight_weight_unit, ).ticks - gross = container.tare_weight.ticks + sum( - placement.instance.weight.ticks - for placement in one.state.placements - ) - score = ( - -len(one.state.placements), - max(gross, dim_weight), - container.id, - ) + # `payload_ticks` and `placement_count` are lattice-aware: the + # compact path carries no per-item placements, so summing + # `state.placements` here priced a quantity-compressed trial as + # tare alone and let an unpriceable container win the round. + gross = container.tare_weight.ticks + one.state.payload_ticks + billed = max(gross, dim_weight) + placed = one.state.placement_count + if config.objective == "shipping_cost": + score = (-placed, billed, container.id) + else: + # Landed cost ranks the round by the money the finished score will + # charge, not by the grams it is derived from. This loop commits + # the trial verbatim, so its billed weight is already final and the + # tariff can be read now; a bracket step or a minimum charge makes + # the cheaper shipment the heavier one, and a trial the tariff + # cannot price is unshippable rather than merely dear, so it sorts + # behind every priceable alternative. Keys after the charge + # mirror Rust's, so the two engines pick the same container. + charge = ( + container.rate_table.charge_minor_or_none(_grams(billed)) + if container.rate_table is not None + else None + ) + containers_needed = -(-len(round_items) // max(placed, 1)) + score = ( + UNPRICEABLE_MINOR if charge is None else charge, + containers_needed, + -placed, + container.id, + ) else: score = self.container_selector.score(container, one) if best_score is None or score < best_score: diff --git a/tests/test_commerce_api.py b/tests/test_commerce_api.py new file mode 100644 index 0000000..b715b85 --- /dev/null +++ b/tests/test_commerce_api.py @@ -0,0 +1,431 @@ +"""The exported commercial and control-plane API. + +Two things are checked here, and they are different things: + + * the wrapper agrees with the model. Every success case computes the same answer a + second time by driving `CarrierRegistry` / `PolicyRegistry` / `CatalogRegistry` + directly, and asserts the exported document reports exactly that. This is the guard + against the wrapper growing a second implementation. + * the workspace paths still resolve to the same objects. `commerce/rating/model.py`, + `domain/policy/model.py` and `domain/catalog/model.py` are re-export shims after + the export consolidation; the identity assertions below fail the moment one starts carrying + its own copy. + +docs/COMMERCE-API.md is the contract under test. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +from packvium.commerce import ( + REJECTION_CODES, + CommerceInputError, + canonical_json, + catalog_version_info, + evaluate_policy, + quote, +) +from packvium.commerce.catalog import CatalogRegistry, CatalogSnapshot, CartonMaster, ItemMaster +from packvium.commerce.policy import ( + PolicyAction, + PolicyOperator, + PolicyPredicate, + PolicyRegistry, + PolicyScope, +) +from packvium.commerce.rating import AccessorialCharge, CarrierRegistry, RatingRequest + +WORKSPACE_ROOT = Path(__file__).resolve().parents[2] + + +# ------------------------------------------------------------------------- fixture data + +TARIFF_VERSIONS = [ + { + "effective_at": 0, + "dimensional_weight_divisor": 5000, + "cost_per_dimensional_kg_minor": {"zone-a": 450, "zone-b": 610}, + "minimum_charge_minor": 900, + "fuel_surcharge_permille": 120, + "accessorials": [ + {"accessorial_id": "liftgate", "flat_charge_minor": 250}, + {"accessorial_id": "residential", "permille_of_base": 75}, + ], + }, + { + "effective_at": 1000, + "dimensional_weight_divisor": 4000, + "cost_per_dimensional_kg_minor": {"zone-a": 480}, + "minimum_charge_minor": 950, + "fuel_surcharge_permille": 140, + "accessorials": [{"accessorial_id": "liftgate", "flat_charge_minor": 275}], + }, +] + +DOCUMENT = { + "tariffs": [{"carrier_id": "acme", "service_id": "ground", "versions": TARIFF_VERSIONS}], + "policy_rules": [ + { + "rule_id": "no-hazmat-air", + "versions": [ + { + "scope": "hazmat", "action": "reject", "priority": 10, "effective_at": 0, + "reason": "class 1.4 is not accepted on air services", + "predicates": [ + {"scope": "hazmat", "field": "un_class", "operator": "equals", "value": "1.4"}, + ], + }, + ], + }, + { + "rule_id": "allow-known-shippers", + "versions": [ + { + "scope": "hazmat", "action": "allow", "priority": 5, "effective_at": 0, + "reason": "vetted shipper", + "predicates": [ + {"scope": "hazmat", "field": "shipper", "operator": "in", "value": ["vetted"]}, + ], + }, + ], + }, + ], + "catalogs": [ + { + "catalog_id": "dc-12", + "versions": [ + { + "effective_at": 0, "published_at": 0, "note": "initial", + "snapshot": { + "items": [{"id": "sku-1", "dimensions_mm": [100, 200, 300], "weight_g": 1200}], + "cartons": [{ + "id": "box-m", "inner_dimensions_mm": [320, 240, 180], + "max_payload_g": 15000, "cost_minor": 85, + }], + }, + }, + {"rollback_to": 1, "published_at": 900, "effective_at": 900, "note": "revert"}, + ], + }, + ], +} + + +def build_carrier_registry() -> CarrierRegistry: + """The same two tariff versions the document declares, published by hand.""" + registry = CarrierRegistry() + for version in TARIFF_VERSIONS: + registry.publish( + "acme", "ground", + effective_at=version["effective_at"], + dimensional_weight_divisor=version["dimensional_weight_divisor"], + cost_per_dimensional_kg_minor=version["cost_per_dimensional_kg_minor"], + minimum_charge_minor=version["minimum_charge_minor"], + fuel_surcharge_permille=version["fuel_surcharge_permille"], + accessorials={ + entry["accessorial_id"]: AccessorialCharge( + entry["accessorial_id"], + flat_charge_minor=entry.get("flat_charge_minor"), + permille_of_base=entry.get("permille_of_base"), + ) + for entry in version["accessorials"] + }, + ) + return registry + + +def build_policy_registry() -> PolicyRegistry: + registry = PolicyRegistry() + registry.publish( + "no-hazmat-air", scope=PolicyScope.HAZMAT, action=PolicyAction.REJECT, priority=10, + effective_at=0, reason="class 1.4 is not accepted on air services", + predicates=[PolicyPredicate( + scope=PolicyScope.HAZMAT, field="un_class", operator=PolicyOperator.EQUALS, value="1.4", + )], + ) + registry.publish( + "allow-known-shippers", scope=PolicyScope.HAZMAT, action=PolicyAction.ALLOW, priority=5, + effective_at=0, reason="vetted shipper", + predicates=[PolicyPredicate( + scope=PolicyScope.HAZMAT, field="shipper", operator=PolicyOperator.IN, value=["vetted"], + )], + ) + return registry + + +def build_catalog_registry() -> CatalogRegistry: + registry = CatalogRegistry("dc-12") + registry.publish( + CatalogSnapshot( + items=(ItemMaster("sku-1", (100, 200, 300), 1200),), + cartons=(CartonMaster("box-m", (320, 240, 180), 15000, 85),), + ), + effective_at=0, published_at=0, note="initial", + ) + registry.rollback(1, published_at=900, effective_at=900, note="revert") + return registry + + +def shipment(**overrides): + request = { + "carrier_id": "acme", "service_id": "ground", "tariff_version": 1, + "zone": "zone-a", "actual_weight_g": 1200, "volume_mm3": 6_000_000, + "requested_accessorials": ["liftgate"], + } + request.update(overrides) + return request + + +# ------------------------------------------------------- the wrapper agrees with the model + +class TestQuoteMatchesTheModel: + @pytest.mark.parametrize("pin", [ + {"tariff_version": 1, "as_of": None}, + {"tariff_version": None, "as_of": 0}, + ]) + def test_every_breakdown_field_comes_from_the_rating_model(self, pin): + result = quote(DOCUMENT, shipment(**pin)) + + expected = build_carrier_registry().rate_with_version( + "acme", "ground", 1, + RatingRequest(zone="zone-a", actual_weight_g=1200, volume_mm3=6_000_000, + requested_accessorials=("liftgate",)), + ) + assert result["status"] == "ok" + assert result["quote"] == { + "carrier_id": expected.carrier_id, + "service_id": expected.service_id, + "tariff_version": expected.tariff_version, + "zone": expected.zone, + "actual_weight_g": expected.actual_weight_g, + "dimensional_weight_g": expected.dimensional_weight_g, + "billed_weight_g": expected.billed_weight_g, + "base_charge_minor": expected.base_charge_minor, + "minimum_charge_applied": expected.minimum_charge_applied, + "fuel_surcharge_minor": expected.fuel_surcharge_minor, + "accessorial_charges_minor": [list(pair) for pair in expected.accessorial_charges_minor], + "total_minor": expected.total_minor, + } + + def test_as_of_resolves_the_later_version_the_registry_resolves(self): + result = quote(DOCUMENT, shipment(as_of=1500, tariff_version=None, + requested_accessorials=["liftgate"])) + + expected = build_carrier_registry().rate( + "acme", "ground", + RatingRequest(zone="zone-a", actual_weight_g=1200, volume_mm3=6_000_000, + requested_accessorials=("liftgate",)), + as_of=1500, + ) + assert result["quote"]["tariff_version"] == expected.tariff_version == 2 + assert result["quote"]["total_minor"] == expected.total_minor + + def test_accessorial_charges_keep_the_requested_order(self): + result = quote(DOCUMENT, shipment(requested_accessorials=["residential", "liftgate"])) + + assert [pair[0] for pair in result["quote"]["accessorial_charges_minor"]] == [ + "residential", "liftgate", + ] + + def test_a_shipment_with_no_accessorials_needs_no_accessorial_key(self): + request = shipment() + request.pop("requested_accessorials") + + assert quote(DOCUMENT, request)["quote"]["accessorial_charges_minor"] == [] + + +class TestPolicyMatchesTheModel: + def test_an_as_of_decision_matches_the_registry(self): + result = evaluate_policy(DOCUMENT, {"scope": "hazmat", "context": {"un_class": "1.4"}, "as_of": 0}) + + expected = build_policy_registry().evaluate(PolicyScope.HAZMAT, {"un_class": "1.4"}, as_of=0) + assert result["decision"]["allowed"] is expected.allowed is False + assert result["decision"]["citation"]["rule_id"] == expected.citation.rule_id + assert result["decision"]["citation"]["version"] == expected.citation.version + + def test_deny_still_outranks_allow_through_the_wrapper(self): + context = {"un_class": "1.4", "shipper": "vetted"} + + result = evaluate_policy(DOCUMENT, {"scope": "hazmat", "context": context, "as_of": 0}) + + assert result["decision"]["allowed"] is False + assert result["decision"]["citation"]["action"] == "reject" + + def test_nothing_matching_is_allowed_without_a_citation(self): + result = evaluate_policy(DOCUMENT, {"scope": "hazmat", "context": {"un_class": "9"}, "as_of": 0}) + + assert result["decision"] == {"scope": "hazmat", "allowed": True, "citation": None} + + def test_pinned_rule_versions_are_order_independent(self): + pins = [["no-hazmat-air", 1], ["allow-known-shippers", 1]] + request = {"scope": "hazmat", "context": {"un_class": "1.4"}, "rule_versions": pins} + + forward = evaluate_policy(DOCUMENT, request) + reversed_pins = dict(request, rule_versions=list(reversed(pins))) + + assert canonical_json(forward) == canonical_json(evaluate_policy(DOCUMENT, reversed_pins)) + + +class TestCatalogMatchesTheModel: + def test_metadata_matches_the_registry_resolution(self): + result = catalog_version_info(DOCUMENT, {"catalog_id": "dc-12", "version": 2, "resolved_at": 1700}) + + registry = build_catalog_registry() + expected = registry.resolve(resolved_at=1700, version=2) + assert result["catalog"]["effective_at"] == expected.reference.effective_at + assert result["catalog"]["resolved_at"] == 1700 + assert result["catalog"]["rolled_back_from"] == 1 + assert result["catalog"]["entry_counts"] == { + "items": 1, "cartons": 1, "pallets": 0, "exclusions": 0, "overrides": 0, + } + assert result["catalog"]["item_ids"] == ["sku-1"] + + def test_an_as_of_lookup_selects_the_same_version_the_registry_does(self): + result = catalog_version_info(DOCUMENT, {"catalog_id": "dc-12", "as_of": 500, "resolved_at": 600}) + + expected = build_catalog_registry().resolve(resolved_at=600, as_of=500) + assert result["catalog"]["version"] == expected.reference.version == 1 + + +# ---------------------------------------------------------------------------- rejections + +class TestRejections: + def test_every_documented_code_is_reachable_and_none_other_is(self): + produced = { + quote(DOCUMENT, shipment(carrier_id="nobody"))["error"]["code"], + quote(DOCUMENT, shipment(tariff_version=99))["error"]["code"], + quote(DOCUMENT, shipment(tariff_version=None, as_of=-1))["error"]["code"], + quote(DOCUMENT, shipment(zone="zone-z"))["error"]["code"], + quote(DOCUMENT, shipment(requested_accessorials=["helicopter"]))["error"]["code"], + evaluate_policy(DOCUMENT, { + "scope": "hazmat", "context": {}, "rule_versions": [["ghost", 1]], + })["error"]["code"], + evaluate_policy(DOCUMENT, { + "scope": "hazmat", "context": {}, "rule_versions": [["no-hazmat-air", 7]], + })["error"]["code"], + catalog_version_info(DOCUMENT, {"catalog_id": "nope", "resolved_at": 1})["error"]["code"], + catalog_version_info(DOCUMENT, {"catalog_id": "dc-12", "version": 9, "resolved_at": 1})["error"]["code"], + catalog_version_info(DOCUMENT, {"catalog_id": "dc-12", "as_of": -1, "resolved_at": 1})["error"]["code"], + catalog_version_info(DOCUMENT, {"catalog_id": "dc-12", "resolved_at": 1})["error"]["code"], + } + + assert produced == set(REJECTION_CODES) + + def test_an_unpriceable_zone_names_the_zone_and_the_resolved_version(self): + result = quote(DOCUMENT, shipment(zone="zone-z")) + + assert result == { + "api_version": 1, "status": "rejected", + "error": {"code": "unavailable_zone", "fields": { + "carrier_id": "acme", "service_id": "ground", "tariff_version": 1, "zone": "zone-z", + }}, + } + + def test_missing_accessorials_are_reported_together_and_sorted(self): + result = quote(DOCUMENT, shipment(requested_accessorials=["zeppelin", "helicopter"])) + + assert result["error"]["code"] == "unavailable_accessorial" + assert result["error"]["fields"]["accessorial_ids"] == ["helicopter", "zeppelin"] + + def test_a_rejection_carries_no_prose(self): + rendered = canonical_json(quote(DOCUMENT, shipment(zone="zone-z"))) + + assert "has no rate for" not in rendered + + +# -------------------------------------------------------------------------- input errors + +class TestInputErrors: + @pytest.mark.parametrize("request_overrides", [ + {"tariff_version": None}, # neither pin + {"as_of": 0}, # both pins + {"actual_weight_g": -1}, + {"actual_weight_g": True}, + {"requested_accessorials": ["liftgate", "liftgate"]}, + {"zone": 7}, + ]) + def test_a_malformed_request_raises_rather_than_rejecting(self, request_overrides): + with pytest.raises(CommerceInputError): + quote(DOCUMENT, shipment(**request_overrides)) + + def test_an_unrecognised_request_key_is_refused_not_ignored(self): + with pytest.raises(CommerceInputError, match="unrecognised key"): + quote(DOCUMENT, shipment(discount_code="FREE")) + + def test_an_unrecognised_document_key_is_refused_not_ignored(self): + with pytest.raises(CommerceInputError, match="unrecognised key"): + quote(dict(DOCUMENT, surcharges=[]), shipment()) + + def test_an_unsupported_policy_operator_fails_admission(self): + document = {"policy_rules": [{"rule_id": "r", "versions": [{ + "scope": "hazmat", "action": "reject", "priority": 1, "effective_at": 0, + "predicates": [{"scope": "hazmat", "field": "x", "operator": "contains", "value": 1}], + }]}]} + + with pytest.raises(CommerceInputError, match="unsupported"): + evaluate_policy(document, {"scope": "hazmat", "context": {}, "as_of": 0}) + + def test_a_duplicate_accessorial_id_is_refused(self): + versions = [dict(TARIFF_VERSIONS[0], accessorials=[ + {"accessorial_id": "liftgate", "flat_charge_minor": 1}, + {"accessorial_id": "liftgate", "flat_charge_minor": 2}, + ])] + document = {"tariffs": [{"carrier_id": "acme", "service_id": "ground", "versions": versions}]} + + with pytest.raises(CommerceInputError, match="duplicate accessorial_id"): + quote(document, shipment()) + + +# ------------------------------------------------------------------------ canonical form + +class TestCanonicalForm: + def test_key_order_in_the_input_cannot_change_the_output_bytes(self): + shuffled = dict(reversed(list(shipment().items()))) + + assert canonical_json(quote(DOCUMENT, shipment())) == canonical_json(quote(DOCUMENT, shuffled)) + + def test_the_canonical_form_is_compact_sorted_json(self): + rendered = canonical_json(quote(DOCUMENT, shipment())) + + assert rendered == json.dumps(json.loads(rendered), sort_keys=True, separators=(",", ":")) + assert rendered.startswith('{"api_version":1,') + + +# --------------------------------------------------------- the workspace paths still hold + +@pytest.mark.skipif( + not (WORKSPACE_ROOT / "commerce" / "rating" / "model.py").exists(), + reason="workspace tree is not present next to an installed package", +) +class TestWorkspaceShimsReExportTheSameObjects: + @staticmethod + def _workspace_module(dotted: str): + if str(WORKSPACE_ROOT) not in sys.path: + sys.path.insert(0, str(WORKSPACE_ROOT)) + return __import__(dotted, fromlist=["*"]) + + def test_rating_shim_is_not_a_second_implementation(self): + import packvium.commerce.rating as canonical + + shim = self._workspace_module("commerce.rating.model") + assert shim.rate_tariff is canonical.rate_tariff + assert shim.CarrierRegistry is canonical.CarrierRegistry + + def test_policy_shim_is_not_a_second_implementation(self): + import packvium.commerce.policy as canonical + + shim = self._workspace_module("domain.policy.model") + assert shim.decide is canonical.decide + assert shim.PolicyRegistry is canonical.PolicyRegistry + + def test_catalog_shim_is_not_a_second_implementation(self): + import packvium.commerce.catalog as canonical + + shim = self._workspace_module("domain.catalog.model") + assert shim.CatalogRegistry is canonical.CatalogRegistry + assert shim.CatalogSnapshot is canonical.CatalogSnapshot diff --git a/tests/test_commerce_edge_cases.py b/tests/test_commerce_edge_cases.py new file mode 100644 index 0000000..c02e5f4 --- /dev/null +++ b/tests/test_commerce_edge_cases.py @@ -0,0 +1,530 @@ +"""Every way to hand the commerce API something it should refuse, or something legal +that looks like it should not be. + +`test_commerce_api.py` proves the wrapper agrees with the model on the shapes a caller +is expected to send. This file is the other half: the shapes a caller is *not* expected +to send, and the handful that look wrong but are not. + +The point is not coverage for its own sake. Every case here is a place where a plausible +implementation quietly does the wrong thing instead of failing — treats a JSON `true` as +the integer 1, accepts a float where the contract says exact integer, silently drops a +field it does not recognise, lets a rollback point at a version that does not exist yet, +sorts ids by something other than code point. A test that never sends malformed input +cannot tell a strict parser from a permissive one. +""" + +from __future__ import annotations + +import json + +import pytest + +from packvium.commerce import ( + CommerceInputError, + canonical_json, + catalog_version_info, + evaluate_policy, + load_document, + quote, +) + +TARIFF_VERSION = { + "effective_at": 0, + "dimensional_weight_divisor": 5000, + "cost_per_dimensional_kg_minor": {"zone-a": 450}, + "minimum_charge_minor": 900, + "fuel_surcharge_permille": 120, + "accessorials": [{"accessorial_id": "liftgate", "flat_charge_minor": 250}], +} +DOCUMENT = {"tariffs": [{"carrier_id": "acme", "service_id": "ground", + "versions": [TARIFF_VERSION]}]} +SHIPMENT = { + "carrier_id": "acme", "service_id": "ground", "tariff_version": 1, + "zone": "zone-a", "actual_weight_g": 1200, "volume_mm3": 6_000_000, +} + + +def tariff_document(**overrides): + return {"tariffs": [{"carrier_id": "acme", "service_id": "ground", + "versions": [dict(TARIFF_VERSION, **overrides)]}]} + + +def policy_document(**overrides): + version = { + "scope": "hazmat", "action": "reject", "priority": 1, "effective_at": 0, + "predicates": [{"scope": "hazmat", "field": "un_class", "operator": "equals", + "value": "1.4"}], + } + version.update(overrides) + return {"policy_rules": [{"rule_id": "r", "versions": [version]}]} + + +def catalog_document(*versions): + return {"catalogs": [{"catalog_id": "c", "versions": list(versions)}]} + + +def catalog_request(**overrides): + return dict({"catalog_id": "c", "resolved_at": 1}, **overrides) + + +# ------------------------------------------------------------------- the outer envelope + +class TestTheDocumentItself: + @pytest.mark.parametrize("document", [None, [], "{}", 7, True, 1.5, ()]) + def test_a_document_that_is_not_an_object_is_refused(self, document): + with pytest.raises(CommerceInputError, match="document: expected an object"): + quote(document, SHIPMENT) + + def test_an_empty_document_is_legal_and_prices_nothing(self): + assert quote({}, SHIPMENT)["error"]["code"] == "tariff_not_found" + + @pytest.mark.parametrize("key", ["tariffs", "policy_rules", "catalogs"]) + def test_each_history_may_be_omitted_independently(self, key): + document = dict(DOCUMENT) + document.pop(key, None) + + assert load_document(document) is not None + + @pytest.mark.parametrize("collection", ["tariffs", "policy_rules", "catalogs"]) + def test_a_history_collection_must_be_a_list(self, collection): + with pytest.raises(CommerceInputError, match="expected a list"): + load_document({collection: {"carrier_id": "acme"}}) + + @pytest.mark.parametrize("collection", ["tariffs", "policy_rules", "catalogs"]) + def test_an_empty_history_collection_is_legal(self, collection): + assert load_document({collection: []}) is not None + + @pytest.mark.parametrize("collection,entry", [ + ("tariffs", {"carrier_id": "a", "service_id": "b", "versions": []}), + ("policy_rules", {"rule_id": "r", "versions": []}), + ("catalogs", {"catalog_id": "c", "versions": []}), + ]) + def test_a_history_with_no_versions_is_refused(self, collection, entry): + with pytest.raises(CommerceInputError, match="at least one version"): + load_document({collection: [entry]}) + + @pytest.mark.parametrize("collection,entry", [ + ("tariffs", {"carrier_id": "a", "service_id": "b", "versions": [TARIFF_VERSION]}), + ("policy_rules", {"rule_id": "r", "versions": [ + {"scope": "hazmat", "action": "allow", "priority": 0, "effective_at": 0, + "predicates": [{"scope": "hazmat", "field": "f", "operator": "exists"}]}]}), + ("catalogs", {"catalog_id": "c", "versions": [ + {"effective_at": 0, "published_at": 0, "snapshot": {}}]}), + ]) + def test_two_histories_with_the_same_identity_are_refused(self, collection, entry): + with pytest.raises(CommerceInputError, match="duplicate"): + load_document({collection: [entry, entry]}) + + def test_a_history_entry_must_be_an_object(self): + with pytest.raises(CommerceInputError, match="expected an object"): + load_document({"tariffs": ["acme"]}) + + def test_an_unrecognised_top_level_key_is_refused_not_ignored(self): + with pytest.raises(CommerceInputError, match=r"unrecognised key\(s\) \['discounts'\]"): + load_document({"discounts": []}) + + def test_a_missing_identity_key_names_what_is_missing(self): + with pytest.raises(CommerceInputError, match=r"missing required key\(s\) \['service_id'\]"): + load_document({"tariffs": [{"carrier_id": "acme", "versions": [TARIFF_VERSION]}]}) + + +# --------------------------------------------------------------------------- scalar types + +class TestScalarTypes: + @pytest.mark.parametrize("value", [True, False, 1.0, 0.5, "1", None, [], {}]) + def test_only_an_exact_integer_is_an_integer(self, value): + with pytest.raises(CommerceInputError, match="expected an exact integer"): + load_document(tariff_document(effective_at=value)) + + def test_a_float_that_happens_to_be_whole_is_still_not_an_integer(self): + with pytest.raises(CommerceInputError, match="expected an exact integer"): + load_document(tariff_document(minimum_charge_minor=900.0)) + + @pytest.mark.parametrize("value", [7, True, None, [], {}]) + def test_only_a_string_is_a_string(self, value): + with pytest.raises(CommerceInputError, match="expected a string"): + load_document({"tariffs": [{"carrier_id": value, "service_id": "g", + "versions": [TARIFF_VERSION]}]}) + + def test_an_explicit_null_optional_reads_as_absent(self): + document = tariff_document(minimum_charge_minor=None, accessorials=None) + + assert quote(document, SHIPMENT)["quote"]["minimum_charge_applied"] is False + + +# --------------------------------------------------------------------------------- tariffs + +class TestTariffAdmission: + @pytest.mark.parametrize("overrides,message", [ + ({"dimensional_weight_divisor": 0}, "must be positive"), + ({"dimensional_weight_divisor": -1}, "must be positive"), + ({"effective_at": -1}, "cannot be negative"), + ({"minimum_charge_minor": -1}, "cannot be negative"), + ({"fuel_surcharge_permille": -1}, "cannot be negative"), + ({"cost_per_dimensional_kg_minor": {"zone-a": -1}}, "cannot be negative"), + ]) + def test_an_out_of_range_tariff_field_fails_admission(self, overrides, message): + with pytest.raises(CommerceInputError, match=message): + load_document(tariff_document(**overrides)) + + def test_a_zone_map_must_be_an_object(self): + with pytest.raises(CommerceInputError, match="expected an object"): + load_document(tariff_document(cost_per_dimensional_kg_minor=[["zone-a", 450]])) + + def test_a_tariff_with_no_zones_at_all_is_admitted_and_prices_nothing(self): + document = tariff_document(cost_per_dimensional_kg_minor={}) + + assert quote(document, SHIPMENT)["error"]["code"] == "unavailable_zone" + + @pytest.mark.parametrize("accessorial,message", [ + ({"accessorial_id": "x"}, "exactly one"), + ({"accessorial_id": "x", "flat_charge_minor": 1, "permille_of_base": 1}, "exactly one"), + ({"accessorial_id": "x", "flat_charge_minor": -1}, "cannot be negative"), + ({"accessorial_id": "x", "permille_of_base": -1}, "cannot be negative"), + ({"accessorial_id": "", "flat_charge_minor": 1}, "required"), + ]) + def test_a_malformed_accessorial_fails_admission(self, accessorial, message): + with pytest.raises(CommerceInputError, match=message): + load_document(tariff_document(accessorials=[accessorial])) + + def test_an_accessorial_charging_zero_is_legal(self): + document = tariff_document( + accessorials=[{"accessorial_id": "free", "flat_charge_minor": 0}]) + + result = quote(document, dict(SHIPMENT, requested_accessorials=["free"])) + + assert result["quote"]["accessorial_charges_minor"] == [["free", 0]] + + def test_a_permille_accessorial_of_a_zero_base_charges_zero(self): + document = tariff_document( + cost_per_dimensional_kg_minor={"zone-a": 0}, minimum_charge_minor=0, + accessorials=[{"accessorial_id": "pct", "permille_of_base": 999}]) + + result = quote(document, dict(SHIPMENT, requested_accessorials=["pct"])) + + assert result["quote"]["total_minor"] == 0 + + +# ------------------------------------------------------------------------------- requests + +class TestQuoteRequestAdmission: + @pytest.mark.parametrize("payload", [None, [], "x", 7]) + def test_a_request_that_is_not_an_object_is_refused(self, payload): + with pytest.raises(CommerceInputError, match="request: expected an object"): + quote(DOCUMENT, payload) + + def test_neither_pin_is_refused(self): + request = dict(SHIPMENT) + request.pop("tariff_version") + + with pytest.raises(CommerceInputError, match="exactly one"): + quote(DOCUMENT, request) + + def test_both_pins_are_refused(self): + with pytest.raises(CommerceInputError, match="exactly one"): + quote(DOCUMENT, dict(SHIPMENT, as_of=0)) + + @pytest.mark.parametrize("accessorials,message", [ + (["a", "a"], "unique"), + ([""], "non-empty"), + ([7], "expected a string"), + ("liftgate", "expected a list"), + ({"liftgate": 1}, "expected a list"), + ]) + def test_a_malformed_accessorial_request_is_refused(self, accessorials, message): + with pytest.raises(CommerceInputError, match=message): + quote(DOCUMENT, dict(SHIPMENT, requested_accessorials=accessorials)) + + def test_an_empty_zone_is_refused_rather_than_looked_up(self): + with pytest.raises(CommerceInputError, match="zone is required"): + quote(DOCUMENT, dict(SHIPMENT, zone="")) + + @pytest.mark.parametrize("field", ["actual_weight_g", "volume_mm3"]) + def test_a_negative_measurement_is_refused(self, field): + with pytest.raises(CommerceInputError, match="cannot be negative"): + quote(DOCUMENT, dict(SHIPMENT, **{field: -1})) + + def test_a_pinned_version_of_zero_or_below_is_simply_not_found(self): + assert quote(DOCUMENT, dict(SHIPMENT, tariff_version=0))["error"] == { + "code": "tariff_not_found", + "fields": {"carrier_id": "acme", "service_id": "ground", "tariff_version": 0}, + } + + def test_a_negative_as_of_predates_every_version(self): + request = dict(SHIPMENT, tariff_version=None, as_of=-5) + + assert quote(DOCUMENT, request)["error"]["code"] == "no_effective_tariff" + + def test_an_unknown_service_on_a_known_carrier_is_not_found(self): + request = dict(SHIPMENT, service_id="hyperloop", tariff_version=None, as_of=0) + + assert quote(DOCUMENT, request)["error"] == { + "code": "tariff_not_found", "fields": {"carrier_id": "acme", "service_id": "hyperloop"}, + } + + +# --------------------------------------------------------------------------------- policy + +class TestPolicyAdmission: + @pytest.mark.parametrize("overrides,message", [ + ({"scope": "warehouse"}, "unsupported"), + ({"action": "maybe"}, "unsupported"), + ({"predicates": []}, "at least one predicate"), + ({"effective_at": -1}, "cannot be negative"), + ]) + def test_a_malformed_rule_fails_admission(self, overrides, message): + with pytest.raises(CommerceInputError, match=message): + load_document(policy_document(**overrides)) + + @pytest.mark.parametrize("predicate,message", [ + ({"scope": "hazmat", "field": "f", "operator": "contains", "value": 1}, "unsupported"), + ({"scope": "warehouse", "field": "f", "operator": "equals", "value": 1}, "unsupported"), + ({"scope": "hazmat", "field": "", "operator": "exists"}, "field is required"), + ({"scope": "hazmat", "field": "f", "operator": "equals"}, "requires a value"), + ({"scope": "customer", "field": "f", "operator": "exists"}, "share the rule's own scope"), + ]) + def test_a_malformed_predicate_fails_admission(self, predicate, message): + with pytest.raises(CommerceInputError, match=message): + load_document(policy_document(predicates=[predicate])) + + def test_a_unary_predicate_may_carry_no_value(self): + document = policy_document( + predicates=[{"scope": "hazmat", "field": "un_class", "operator": "absent"}]) + + assert evaluate_policy(document, {"scope": "hazmat", "context": {}, "as_of": 0})[ + "decision"]["allowed"] is False + + @pytest.mark.parametrize("payload,message", [ + ({"scope": "atlantis", "context": {}, "as_of": 0}, "unsupported policy scope"), + ({"scope": "hazmat", "context": [], "as_of": 0}, "expected an object"), + ({"scope": "hazmat", "context": {}}, "exactly one"), + ({"scope": "hazmat", "context": {}, "as_of": 0, "rule_versions": []}, "exactly one"), + ({"scope": "hazmat", "context": {}, "rule_versions": "r"}, "expected a list"), + ({"scope": "hazmat", "context": {}, "rule_versions": [["r"]]}, "pair"), + ({"scope": "hazmat", "context": {}, "rule_versions": [["r", 1, 2]]}, "pair"), + ({"scope": "hazmat", "context": {}, "rule_versions": [["r", 1], ["r", 1]]}, "same rule id twice"), + ]) + def test_a_malformed_policy_request_is_refused(self, payload, message): + with pytest.raises(CommerceInputError, match=message): + evaluate_policy(policy_document(), payload) + + def test_an_empty_pinned_snapshot_allows_everything(self): + request = {"scope": "hazmat", "context": {"un_class": "1.4"}, "rule_versions": []} + + assert evaluate_policy(policy_document(), request)["decision"] == { + "scope": "hazmat", "allowed": True, "citation": None, + } + + def test_a_rule_in_another_scope_never_decides_this_one(self): + request = {"scope": "customer", "context": {"un_class": "1.4"}, "as_of": 0} + + assert evaluate_policy(policy_document(), request)["decision"]["allowed"] is True + + def test_an_ineffective_rule_does_not_participate(self): + document = policy_document(effective_at=1000) + request = {"scope": "hazmat", "context": {"un_class": "1.4"}, "as_of": 999} + + assert evaluate_policy(document, request)["decision"]["citation"] is None + + @pytest.mark.parametrize("operator,value,context,allowed", [ + ("equals", "1.4", {"un_class": "1.4"}, False), + ("equals", "1.4", {"un_class": "1.5"}, True), + ("not_equals", "1.4", {"un_class": "1.5"}, False), + ("in", ["1.4", "1.5"], {"un_class": "1.5"}, False), + ("not_in", ["1.4"], {"un_class": "9"}, False), + ("exists", None, {"un_class": None}, False), + ("absent", None, {}, False), + ("absent", None, {"un_class": None}, True), + ]) + def test_every_operator_decides_the_way_the_contract_says( + self, operator, value, context, allowed, + ): + predicate = {"scope": "hazmat", "field": "un_class", "operator": operator} + if value is not None: + predicate["value"] = value + document = policy_document(predicates=[predicate]) + + result = evaluate_policy(document, {"scope": "hazmat", "context": context, "as_of": 0}) + + assert result["decision"]["allowed"] is allowed + + def test_a_context_value_of_a_type_the_predicate_does_not_use_simply_does_not_match(self): + document = policy_document( + predicates=[{"scope": "hazmat", "field": "un_class", "operator": "equals", + "value": "1.4"}]) + request = {"scope": "hazmat", "context": {"un_class": ["1.4"]}, "as_of": 0} + + assert evaluate_policy(document, request)["decision"]["allowed"] is True + + +# -------------------------------------------------------------------------------- catalog + +class TestCatalogAdmission: + @pytest.mark.parametrize("entry,message", [ + ({"id": "i", "dimensions_mm": [1, 1], "weight_g": 1}, "exactly 3 axes"), + ({"id": "i", "dimensions_mm": [1, 1, 1, 1], "weight_g": 1}, "exactly 3 axes"), + ({"id": "i", "dimensions_mm": [0, 1, 1], "weight_g": 1}, "must be positive"), + ({"id": "i", "dimensions_mm": [1, 1, 1], "weight_g": 0}, "must be positive"), + ({"id": "", "dimensions_mm": [1, 1, 1], "weight_g": 1}, "required"), + ({"id": "i", "dimensions_mm": "1x1x1", "weight_g": 1}, "expected a list"), + ]) + def test_a_malformed_item_fails_admission(self, entry, message): + with pytest.raises(CommerceInputError, match=message): + load_document(catalog_document( + {"effective_at": 0, "published_at": 0, "snapshot": {"items": [entry]}})) + + def test_a_pallet_carries_two_axes_and_an_optional_height(self): + document = catalog_document({"effective_at": 0, "published_at": 0, "snapshot": { + "pallets": [{"id": "p", "deck_dimensions_mm": [1200, 800], "max_payload_g": 1}]}}) + + assert catalog_version_info(document, catalog_request())["catalog"]["pallet_ids"] == ["p"] + + @pytest.mark.parametrize("pallet,message", [ + ({"id": "p", "deck_dimensions_mm": [1, 1, 1], "max_payload_g": 1}, "exactly 2 axes"), + ({"id": "p", "deck_dimensions_mm": [1, 1], "max_payload_g": 1, + "max_stack_height_mm": 0}, "must be positive"), + ({"id": "p", "deck_dimensions_mm": [1, 1], "max_payload_g": 0}, "must be positive"), + ]) + def test_a_malformed_pallet_fails_admission(self, pallet, message): + with pytest.raises(CommerceInputError, match=message): + load_document(catalog_document( + {"effective_at": 0, "published_at": 0, "snapshot": {"pallets": [pallet]}})) + + @pytest.mark.parametrize("exclusion,message", [ + ({"id": "x", "scope": "item_wheelbarrow", "subject_id": "a", "excluded_id": "b"}, + "unsupported"), + ({"id": "x", "scope": "item_carton", "subject_id": "", "excluded_id": "b"}, + "must reference both"), + ]) + def test_a_malformed_exclusion_fails_admission(self, exclusion, message): + with pytest.raises(CommerceInputError, match=message): + load_document(catalog_document( + {"effective_at": 0, "published_at": 0, "snapshot": {"exclusions": [exclusion]}})) + + @pytest.mark.parametrize("kind,payload", [ + ("item", {"id": "e", "dimensions_mm": [1, 1, 1], "weight_g": 1}), + ("carton", {"id": "e", "inner_dimensions_mm": [1, 1, 1], "max_payload_g": 1}), + ("pallet", {"id": "e", "deck_dimensions_mm": [1, 1], "max_payload_g": 1}), + ]) + def test_an_override_of_every_kind_is_admitted(self, kind, payload): + override = {"id": "o", "facility_id": "F", "entry_id": "e", + "kind": kind, "override": payload} + document = catalog_document( + {"effective_at": 0, "published_at": 0, "snapshot": {"overrides": [override]}}) + + counts = catalog_version_info(document, catalog_request())["catalog"]["entry_counts"] + + assert counts["overrides"] == 1 + + @pytest.mark.parametrize("override,message", [ + ({"id": "o", "facility_id": "F", "entry_id": "e", "kind": "crate", + "override": {"id": "e", "dimensions_mm": [1, 1, 1], "weight_g": 1}}, "expected one of"), + ({"id": "o", "facility_id": "F", "entry_id": "other", "kind": "item", + "override": {"id": "e", "dimensions_mm": [1, 1, 1], "weight_g": 1}}, "must match"), + ({"id": "o", "facility_id": "", "entry_id": "e", "kind": "item", + "override": {"id": "e", "dimensions_mm": [1, 1, 1], "weight_g": 1}}, "required"), + ]) + def test_a_malformed_override_fails_admission(self, override, message): + with pytest.raises(CommerceInputError, match=message): + load_document(catalog_document( + {"effective_at": 0, "published_at": 0, "snapshot": {"overrides": [override]}})) + + @pytest.mark.parametrize("collection,entry", [ + ("items", {"id": "d", "dimensions_mm": [1, 1, 1], "weight_g": 1}), + ("cartons", {"id": "d", "inner_dimensions_mm": [1, 1, 1], "max_payload_g": 1}), + ("pallets", {"id": "d", "deck_dimensions_mm": [1, 1], "max_payload_g": 1}), + ]) + def test_two_entries_with_the_same_id_in_one_snapshot_are_refused(self, collection, entry): + with pytest.raises(CommerceInputError, match="duplicate"): + load_document(catalog_document({"effective_at": 0, "published_at": 0, + "snapshot": {collection: [entry, entry]}})) + + def test_a_rollback_to_a_version_that_does_not_exist_yet_is_refused(self): + with pytest.raises(CommerceInputError, match="no version 2"): + load_document(catalog_document( + {"effective_at": 0, "published_at": 0, "snapshot": {}}, + {"rollback_to": 2, "published_at": 1})) + + def test_a_rollback_to_itself_is_refused(self): + with pytest.raises(CommerceInputError, match="no version 1"): + load_document(catalog_document({"rollback_to": 1, "published_at": 1})) + + def test_a_rollback_defaults_its_effective_date_to_its_publication(self): + document = catalog_document( + {"effective_at": 0, "published_at": 0, "snapshot": {}}, + {"rollback_to": 1, "published_at": 42}) + + catalog = catalog_version_info(document, catalog_request(version=2))["catalog"] + + assert catalog["effective_at"] == catalog["published_at"] == 42 + assert catalog["note"] == "rollback to version 1" + + def test_a_snapshot_and_a_rollback_in_one_version_takes_the_rollback_path(self): + # `rollback_to` decides which form this is, so a stray `snapshot` beside it is an + # unrecognised key rather than a silently-ignored one. + with pytest.raises(CommerceInputError, match="unrecognised key"): + load_document(catalog_document( + {"effective_at": 0, "published_at": 0, "snapshot": {}}, + {"rollback_to": 1, "published_at": 1, "snapshot": {}})) + + @pytest.mark.parametrize("payload,message", [ + ({"catalog_id": "c"}, "missing required key"), + ({"resolved_at": 1}, "missing required key"), + ({"catalog_id": "c", "resolved_at": 1, "version": 1, "as_of": 1}, "at most one"), + ({"catalog_id": "c", "resolved_at": 1, "extra": 1}, "unrecognised key"), + ]) + def test_a_malformed_catalog_request_is_refused(self, payload, message): + document = catalog_document({"effective_at": 0, "published_at": 0, "snapshot": {}}) + + with pytest.raises(CommerceInputError, match=message): + catalog_version_info(document, payload) + + +# ------------------------------------------------------------ legal but surprising inputs + +class TestLegalButSurprising: + def test_an_id_may_be_any_non_empty_string_including_punctuation_and_spaces(self): + weird = ' \t"\\/kľúč 🙂 ' + document = catalog_document({"effective_at": 0, "published_at": 0, "snapshot": { + "items": [{"id": weird, "dimensions_mm": [1, 1, 1], "weight_g": 1}]}}) + + catalog = catalog_version_info(document, catalog_request())["catalog"] + + assert catalog["item_ids"] == [weird] + assert json.loads(canonical_json({"catalog": catalog}))["catalog"]["item_ids"] == [weird] + + def test_a_zone_name_may_contain_anything_a_json_key_can(self): + document = tariff_document(cost_per_dimensional_kg_minor={"zóna/1": 450}) + + assert quote(document, dict(SHIPMENT, zone="zóna/1"))["status"] == "ok" + + def test_a_quote_far_beyond_a_64_bit_product_stays_exact(self): + document = tariff_document( + dimensional_weight_divisor=1, + cost_per_dimensional_kg_minor={"zone-a": 10 ** 12}, + minimum_charge_minor=0, fuel_surcharge_permille=0, accessorials=[]) + + result = quote(document, dict(SHIPMENT, actual_weight_g=0, volume_mm3=10 ** 12)) + + assert result["quote"]["total_minor"] == 10 ** 21 + assert isinstance(result["quote"]["total_minor"], int) + + def test_a_thousand_versions_resolve_to_the_last_effective_one(self): + versions = [dict(TARIFF_VERSION, effective_at=index) for index in range(1000)] + document = {"tariffs": [{"carrier_id": "acme", "service_id": "ground", + "versions": versions}]} + + request = dict(SHIPMENT, tariff_version=None, as_of=500) + + assert quote(document, request)["quote"]["tariff_version"] == 501 + + def test_canonical_json_does_not_escape_non_ascii_or_slashes(self): + rendered = canonical_json({"note": "zóna/1 🙂"}) + + assert rendered == '{"note":"zóna/1 🙂"}' + + def test_two_documents_that_differ_only_in_key_order_price_identically(self): + shuffled = {"tariffs": [{"versions": [dict(reversed(list(TARIFF_VERSION.items())))], + "service_id": "ground", "carrier_id": "acme"}]} + + assert canonical_json(quote(DOCUMENT, SHIPMENT)) == canonical_json(quote(shuffled, SHIPMENT)) diff --git a/tests/test_commerce_models.py b/tests/test_commerce_models.py new file mode 100644 index 0000000..f720852 --- /dev/null +++ b/tests/test_commerce_models.py @@ -0,0 +1,380 @@ +"""The commerce models' own guards, reached by constructing them directly. + +`packvium.commerce.rating`, `.policy` and `.catalog` are public: a caller can build a +`Tariff` or a `PolicyDecision` by hand instead of going through a document. That path +skips every check the document parser performs, so the models validate themselves — and +a guard nothing ever trips is a guard nobody knows still works. + +Each test here constructs an object the parser would never produce, and asserts the +model refuses it rather than carrying the contradiction forward into an answer. +""" + +from __future__ import annotations + +import pytest + +from packvium.commerce.catalog import ( + CartonMaster, + CatalogEntryKind, + CatalogEntryNotFoundError, + CatalogError, + CatalogReference, + CatalogRegistry, + CatalogSnapshot, + CatalogVersion, + CatalogVersionNotFoundError, + ExclusionRule, + ExclusionScope, + FacilityOverride, + ItemMaster, + PalletMaster, + ResolvedCatalog, +) +from packvium.commerce.policy import ( + PolicyAction, + PolicyCitation, + PolicyDecision, + PolicyOperator, + PolicyPredicate, + PolicyRegistry, + PolicyRule, + PolicyScope, + UnsupportedPredicateError, +) +from packvium.commerce.rating import AccessorialCharge, Tariff, _ceil_div + + +def item(id="i"): + return ItemMaster(id, (10, 10, 10), 1) + + +def carton(id="c"): + return CartonMaster(id, (10, 10, 10), 1) + + +def pallet(id="p"): + return PalletMaster(id, (10, 10), 1) + + +def predicate(scope=PolicyScope.HAZMAT): + return PolicyPredicate(scope=scope, field="f", operator=PolicyOperator.EXISTS) + + +# --------------------------------------------------------------------------------- rating + +class TestRatingGuards: + @pytest.mark.parametrize("denominator", [0, -1, -1000]) + def test_ceil_div_refuses_a_non_positive_denominator(self, denominator): + # Unreachable through a parsed document -- the divisor is validated positive + # before it gets here -- so this is the only place the guard can be exercised. + with pytest.raises(ValueError, match="denominator must be positive"): + _ceil_div(1, denominator) + + @pytest.mark.parametrize("numerator,denominator,expected", [ + (0, 7, 0), (1, 7, 1), (7, 7, 1), (8, 7, 2), (13, 7, 2), (14, 7, 2), + ]) + def test_ceil_div_always_rounds_up(self, numerator, denominator, expected): + assert _ceil_div(numerator, denominator) == expected + + def test_a_tariff_needs_a_carrier_id(self): + with pytest.raises(ValueError, match="carrier_id is required"): + self.tariff(carrier_id="") + + def test_a_tariff_needs_a_service_id(self): + with pytest.raises(ValueError, match="service_id is required"): + self.tariff(service_id="") + + @pytest.mark.parametrize("version", [0, -1]) + def test_a_tariff_version_number_is_positive(self, version): + with pytest.raises(ValueError, match="version must be positive"): + self.tariff(version=version) + + def test_an_accessorial_map_key_must_match_its_own_id(self): + charge = AccessorialCharge("liftgate", flat_charge_minor=1) + + with pytest.raises(ValueError, match="must match its own accessorial_id"): + self.tariff(accessorials={"lift-gate": charge}) + + @staticmethod + def tariff(**overrides): + fields = { + "carrier_id": "acme", "service_id": "ground", "version": 1, "effective_at": 0, + "dimensional_weight_divisor": 1, "cost_per_dimensional_kg_minor": {"z": 1}, + "minimum_charge_minor": 0, "fuel_surcharge_permille": 0, "accessorials": {}, + } + fields.update(overrides) + return Tariff(**fields) + + +# --------------------------------------------------------------------------------- policy + +class TestPolicyGuards: + def test_a_rule_needs_a_rule_id(self): + with pytest.raises(ValueError, match="rule_id is required"): + self.rule(rule_id="") + + @pytest.mark.parametrize("version", [0, -3]) + def test_a_rule_version_number_is_positive(self, version): + with pytest.raises(ValueError, match="version must be positive"): + self.rule(version=version) + + def test_a_rejection_without_a_citation_cannot_be_constructed(self): + with pytest.raises(ValueError, match="must carry a citation"): + PolicyDecision(scope=PolicyScope.HAZMAT, allowed=False, citation=None) + + def test_an_allow_decision_may_carry_no_citation(self): + decision = PolicyDecision(scope=PolicyScope.HAZMAT, allowed=True) + + assert decision.citation is None + + def test_a_rejection_with_a_citation_is_fine(self): + citation = PolicyCitation(rule_id="r", version=1, action=PolicyAction.REJECT, + priority=0, reason="") + + assert PolicyDecision(PolicyScope.HAZMAT, False, citation).citation is citation + + @staticmethod + def rule(**overrides): + fields = { + "rule_id": "r", "version": 1, "scope": PolicyScope.HAZMAT, + "action": PolicyAction.REJECT, "predicates": (predicate(),), + "priority": 0, "effective_at": 0, + } + fields.update(overrides) + return PolicyRule(**fields) + + +# -------------------------------------------------------------------------------- catalog + +class TestCatalogGuards: + def test_a_carton_needs_exactly_three_axes(self): + with pytest.raises(ValueError, match="exactly three axes"): + CartonMaster("c", (10, 10), 1) + + def test_an_exclusion_rule_between_two_real_ids_is_accepted(self): + rule = ExclusionRule("x", ExclusionScope.ITEM_PALLET, "i", "p", reason="hazmat") + + assert rule.scope is ExclusionScope.ITEM_PALLET + + @pytest.mark.parametrize("override,kind", [ + (item("e"), CatalogEntryKind.ITEM), + (carton("e"), CatalogEntryKind.CARTON), + (pallet("e"), CatalogEntryKind.PALLET), + ]) + def test_an_override_derives_its_kind_from_what_it_overrides(self, override, kind): + facility_override = FacilityOverride("o", "F", "e", override) + + assert facility_override.entry_kind is kind + + @pytest.mark.parametrize("fields,message", [ + ({"number": 0}, "version number must be positive"), + ({"effective_at": -1}, "effective_at cannot be negative"), + ({"published_at": -1}, "published_at cannot be negative"), + ({"rolled_back_from": 0}, "rolled_back_from must reference a positive"), + ]) + def test_a_malformed_version_cannot_be_constructed(self, fields, message): + base = {"number": 1, "snapshot": CatalogSnapshot(), "effective_at": 0, + "published_at": 0} + base.update(fields) + + with pytest.raises(ValueError, match=message): + CatalogVersion(**base) + + @pytest.mark.parametrize("fields,message", [ + ({"catalog_id": ""}, "catalog_id is required"), + ({"version": 0}, "version must be positive"), + ({"effective_at": -1}, "effective_at cannot be negative"), + ({"resolved_at": -1}, "resolved_at cannot be negative"), + ]) + def test_a_malformed_reference_cannot_be_constructed(self, fields, message): + base = {"catalog_id": "c", "version": 1, "effective_at": 0, "resolved_at": 0} + base.update(fields) + + with pytest.raises(ValueError, match=message): + CatalogReference(**base) + + def test_a_reference_serializes_to_its_wire_shape(self): + reference = CatalogReference("c", 2, 10, 20) + + assert reference.as_dict() == { + "catalog_id": "c", "version": 2, "effective_at": 10, "resolved_at": 20, + } + + def test_a_registry_needs_a_catalog_id_and_reports_it(self): + with pytest.raises(ValueError, match="catalog_id is required"): + CatalogRegistry("") + + assert CatalogRegistry("dc-1").catalog_id == "dc-1" + + def test_every_entry_kind_is_reachable_through_a_resolved_catalog(self): + snapshot = CatalogSnapshot(items=(item(),), cartons=(carton(),), pallets=(pallet(),)) + registry = CatalogRegistry("dc-1") + registry.publish(snapshot, effective_at=0, published_at=0) + + resolved = registry.resolve(resolved_at=1, version=1) + + assert resolved.item("i").id == "i" + assert resolved.carton("c").id == "c" + assert resolved.pallet("p").id == "p" + + @pytest.mark.parametrize("lookup", ["item", "carton", "pallet"]) + def test_an_absent_entry_is_a_structured_error_not_none(self, lookup): + resolved = ResolvedCatalog(CatalogReference("c", 1, 0, 0), CatalogSnapshot()) + + with pytest.raises(CatalogEntryNotFoundError, match=f"no {lookup} with id 'ghost'"): + getattr(resolved, lookup)("ghost") + + +# ------------------------------------------------- guards the document parser bypasses + +class TestGuardsOnlyDirectConstructionReaches: + """Paths a parsed document never takes, because the parser checks first. + + The models are public, so a caller can reach these without a document at all. Each + one is a place where the model — not the parser — is the last line of defence. + """ + + @pytest.mark.parametrize("dimensions", [(10, 10), (10, 10, 10, 10), ()]) + def test_an_item_needs_exactly_three_axes(self, dimensions): + with pytest.raises(ValueError, match="exactly three axes"): + ItemMaster("i", dimensions, 1) + + def test_a_pallet_needs_exactly_two_deck_axes(self): + with pytest.raises(ValueError, match="exactly two axes"): + PalletMaster("p", (10, 10, 10), 1) + + @pytest.mark.parametrize("fields,message", [ + ({"max_payload_g": 0}, "max_payload_g must be positive"), + ({"cost_minor": -1}, "cost_minor cannot be negative"), + ]) + def test_a_cartons_payload_and_cost_are_range_checked(self, fields, message): + base = {"id": "c", "inner_dimensions_mm": (10, 10, 10), "max_payload_g": 1} + base.update(fields) + + with pytest.raises(ValueError, match=message): + CartonMaster(**base) + + @pytest.mark.parametrize("scope,operator,message", [ + ("warehouse", PolicyOperator.EQUALS, "unsupported policy scope"), + (PolicyScope.HAZMAT, "contains", "unsupported policy operator"), + ]) + def test_a_predicate_built_from_raw_strings_still_fails_admission( + self, scope, operator, message, + ): + # A predicate deserialized straight off a wire payload arrives as strings; the + # model coerces them to enum members and refuses anything outside the vocabulary. + with pytest.raises(UnsupportedPredicateError, match=message): + PolicyPredicate(scope=scope, field="f", operator=operator, value=1) + + def test_a_predicate_may_be_built_from_valid_raw_strings(self): + built = PolicyPredicate(scope="hazmat", field="f", operator="equals", value=1) + + assert built.scope is PolicyScope.HAZMAT + assert built.operator is PolicyOperator.EQUALS + + def test_a_predicate_with_a_hand_forced_operator_is_refused_at_evaluation(self): + # `matches` re-checks rather than trusting construction, because a frozen + # dataclass can still be bypassed with object.__setattr__. + built = PolicyPredicate(scope=PolicyScope.HAZMAT, field="f", + operator=PolicyOperator.EQUALS, value=1) + object.__setattr__(built, "operator", "contains") + + with pytest.raises(UnsupportedPredicateError, match="unsupported policy operator"): + built.matches({"f": 1}) + + +class TestHistoricalReplay: + def test_a_recorded_reference_replays_to_the_data_it_was_made_against(self): + registry = CatalogRegistry("dc-1") + registry.publish(CatalogSnapshot(items=(item("old"),)), effective_at=0, published_at=0) + reference = registry.resolve(resolved_at=5, version=1).reference + registry.publish(CatalogSnapshot(items=(item("new"),)), effective_at=10, published_at=10) + + replayed = registry.resolve_reference(reference, resolved_at=99) + + assert [entry.id for entry in replayed.snapshot.items] == ["old"] + assert replayed.reference.resolved_at == 99 + + def test_a_reference_from_another_catalog_is_refused(self): + registry = CatalogRegistry("dc-1") + registry.publish(CatalogSnapshot(), effective_at=0, published_at=0) + foreign = CatalogReference("dc-2", 1, 0, 0) + + with pytest.raises(CatalogError, match="reference is for catalog 'dc-2'"): + registry.resolve_reference(foreign, resolved_at=1) + + def test_an_empty_catalog_has_no_version_to_resolve(self): + with pytest.raises(CatalogVersionNotFoundError, match="no published versions"): + CatalogRegistry("dc-1").resolve(resolved_at=1) + + +class TestLookupWalksPastNonMatches: + """A snapshot lookup is a scan, not a dictionary. Every earlier test happened to ask + for the first entry, which is the one arrangement that never exercises the skip.""" + + @pytest.mark.parametrize( + ("kind", "snapshot", "wanted"), + [ + ("item", CatalogSnapshot(items=(item("a"), item("b"), item("c"))), "c"), + ("carton", CatalogSnapshot(cartons=(carton("a"), carton("b"))), "b"), + ("pallet", CatalogSnapshot(pallets=(pallet("a"), pallet("b"))), "b"), + ], + ) + def test_the_last_entry_is_found_after_skipping_the_others(self, kind, snapshot, wanted): + assert getattr(snapshot, kind)(wanted).id == wanted + + @pytest.mark.parametrize("kind", ["item", "carton", "pallet"]) + def test_a_full_scan_that_matches_nothing_names_the_kind(self, kind): + snapshot = CatalogSnapshot( + items=(item("a"), item("b")), + cartons=(carton("a"), carton("b")), + pallets=(pallet("a"), pallet("b")), + ) + + with pytest.raises(CatalogEntryNotFoundError, match=f"no {kind} with id 'z'"): + getattr(snapshot, kind)("z") + + +class TestPublishRevalidatesSmuggledPredicates: + """`PolicyPredicate.__post_init__` is the first line of defence, and `publish` is + documented as a deliberate second one. A predicate whose enum was overwritten after + construction is the only way to reach it -- and it is exactly what an unpickled or + hand-patched object looks like.""" + + def _smuggled(self, field, value): + built = PolicyPredicate( + scope=PolicyScope.HAZMAT, field="f", operator=PolicyOperator.EXISTS + ) + object.__setattr__(built, field, value) + return built + + @pytest.mark.parametrize( + ("field", "value"), + [("scope", "nowhere"), ("operator", "contains")], + ) + def test_an_unsupported_scope_or_operator_is_refused_at_publish(self, field, value): + registry = PolicyRegistry() + + with pytest.raises(UnsupportedPredicateError, match="unsupported scope/operator"): + registry.publish( + "r", + scope=PolicyScope.HAZMAT, + action=PolicyAction.REJECT, + predicates=(self._smuggled(field, value),), + priority=0, + effective_at=0, + ) + + def test_a_well_formed_predicate_still_publishes(self): + registry = PolicyRegistry() + + rule = registry.publish( + "r", + scope=PolicyScope.HAZMAT, + action=PolicyAction.REJECT, + predicates=(predicate(),), + priority=0, + effective_at=0, + ) + + assert rule.version == 1, "the guard must not reject the ordinary case" diff --git a/tests/test_objective.py b/tests/test_objective.py index 330f871..19f2f97 100644 --- a/tests/test_objective.py +++ b/tests/test_objective.py @@ -253,10 +253,15 @@ def test_shipping_cost_prefers_the_smaller_bulky_container(): def test_shipping_cost_requires_a_divisor_and_reports_the_objective_used(): - from packvium.extensions import UnknownObjectiveError + from packvium.extensions import ShippingCostSolutionScorer, UnknownObjectiveError items = [item("a", 40, 40, 40)] containers = [container("c", 100, 100, 100)] + with pytest.raises( + UnknownObjectiveError, + match="the shipping_cost objective requires configuration.dimensional_weight_divisor", + ): + ShippingCostSolutionScorer.from_config(None) with pytest.raises(UnknownObjectiveError): pack(items, containers, PackingConfig.balanced(objective="shipping_cost")) @@ -359,10 +364,166 @@ def test_a_weight_above_the_last_bracket_has_no_price_and_says_so(): """Clamping to the top price would under-quote every oversize shipment silently.""" from packvium.models import RateTable, UnratedWeightError + with pytest.raises(UnratedWeightError, match="no published price"): + RateTable((1,), (100,)).charge_minor(2) + + +def test_unpriceable_container_answers_for_a_container_with_no_tariff_at_all(): + """`unpriceable_container` is exported, so it cannot assume the packer's admission ran. + + `Packer` never reaches this branch: `LandedCostSolutionScorer` refuses a missing + `rate_table` while scoring, before the final guard is consulted. The function is + public API all the same, and a caller checking a result it assembled itself must get + an answer rather than an `AttributeError` off a `None` table. + """ + from packvium.config import PackingConfig + from packvium.extensions import unpriceable_container + + cube = Item.create("cube", Dimensions.mm(10, 10, 10), weight="1g") + box = Container.create("box", Dimensions.mm(20, 20, 20)) + packed = (filled(box, cube.instances(), [(0, 0, 0)]),) + config = PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + ) + # 20 mm cube = 2 cm a side -> 8 cm^3 / 5000 = 1.6 kg... of a gram, rounded up to 2 g, + # which beats the item's own 1 g. No table means no bracket to name, hence 0. + assert unpriceable_container(packed, config) == ("box", 2, 0) + + +def test_the_scorer_ranks_an_unpriceable_packing_worst_rather_than_raising(): + """The search has to *compare* an unpriceable candidate, not abort on one. + + Raising here is what made a request with one short tariff and one perfectly good + alternative fail outright instead of shipping. Ranking it `UNPRICEABLE_MINOR` + is what lets the priceable container win the round; `Packer.pack` is where the + refusal belongs, once nothing priceable is left to prefer. + """ + from packvium.extensions import UNPRICEABLE_MINOR + from packvium.models import RateTable + light = Item.create("cube", Dimensions.mm(10, 10, 10), weight="1g") box = Container.create("box", Dimensions.mm(20, 20, 20), rate_table=RateTable((1,), (100,))) - with pytest.raises(UnratedWeightError, match="no published price"): - landed(box, light) + assert landed(box, light)[1] == UNPRICEABLE_MINOR + + +def test_packing_refuses_when_no_container_on_offer_can_price_the_load(): + """The sentinel is a search device; reaching a result with it still standing would + quote a number the carrier never published.""" + from packvium.config import PackingConfig + from packvium.models import RateTable, UnratedWeightError + from packvium.packer import Packer + + light = Item.create("cube", Dimensions.mm(10, 10, 10), weight="1g") + box = Container.create("box", Dimensions.mm(20, 20, 20), rate_table=RateTable((1,), (100,))) + packer = Packer(PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + )) + with pytest.raises(UnratedWeightError, match="'box' bills at .* no published price"): + packer.pack([light], [box]) + + +def test_an_unpriceable_container_loses_to_a_priceable_one_in_the_greedy_round(): + """The general per-round choice, not the closed-form path. + + Eight units is past the single-item shape the fast paths take. The round key ranked + by billed weight, and `alpha` bills lighter (5400 g of dimensional weight against + 12800 g) while its tariff stops at 2000 g -- so the objective chose the one shipment + the caller cannot buy over one available at 1500. Ranking the round by the money the + finished score will charge is what fixes it; Rust, PHP and the JavaScript fallback + reach the identical answer. + """ + from packvium.config import PackingConfig + from packvium.models import RateTable + from packvium.packer import Packer + + box = Item.create("box", Dimensions.mm(100, 100, 100), weight="500g", quantity=8) + alpha = Container.create( + "alpha_unpriceable", Dimensions.mm(300, 300, 300), + rate_table=RateTable((2_000,), (900,)), + ) + beta = Container.create( + "beta_priceable", Dimensions.mm(400, 400, 400), + rate_table=RateTable((20_000,), (1_500,)), + ) + packer = Packer(PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + )) + result = packer.pack([box], [alpha, beta]) + assert [c.container.id for c in result.containers] == ["beta_priceable"] + assert result.score[1] == 1_500 + assert result.unpacked == () + + +def test_a_bracket_step_makes_the_cheaper_shipment_the_heavier_one(): + """Grams and money order candidates alike only while price rises smoothly with + weight. Here the heavier container is the cheaper one, which is the whole reason + this objective exists next to `shipping_cost`.""" + from packvium.config import PackingConfig + from packvium.models import RateTable + from packvium.packer import Packer + + box = Item.create("box", Dimensions.mm(100, 100, 100), weight="500g", quantity=8) + dear = Container.create( + "light_but_dear", Dimensions.mm(300, 300, 300), + rate_table=RateTable((20_000,), (900,)), + ) + cheap = Container.create( + "heavy_but_cheap", Dimensions.mm(400, 400, 400), + rate_table=RateTable((20_000,), (400,)), + ) + packer = Packer(PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + )) + result = packer.pack([box], [dear, cheap]) + assert [c.container.id for c in result.containers] == ["heavy_but_cheap"] + assert result.score[1] == 400 + + +def test_a_quantity_compressed_round_still_prices_the_true_payload(): + """ compact states carry no per-item `Placement`s, so a round key that sums + `state.placements` prices a quantity-compressed trial as tare alone. Eight 2000 g + cubes bill 16000 g -- past alpha's last bracket -- but alpha's dimensional 5400 g + is not, so a tare-only key committed alpha and refused a request that beta ships + at 1500. The key reads the lattice-aware `payload_ticks`/`placement_count`; the + fast profile with coordinates waived is the configuration that takes this path. + """ + from packvium.config import PackingConfig, SolverProfile + from packvium.models import RateTable + from packvium.packer import Packer + + box = Item.create("box", Dimensions.mm(100, 100, 100), weight="2000g", quantity=8) + alpha = Container.create( + "alpha_unpriceable", Dimensions.mm(300, 300, 300), + rate_table=RateTable((10_000,), (800,)), + ) + beta = Container.create( + "beta_priceable", Dimensions.mm(400, 400, 400), + rate_table=RateTable((20_000,), (1_500,)), + ) + packer = Packer(PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + profile=SolverProfile.FAST, + require_placement_coordinates=False, + )) + result = packer.pack([box], [alpha, beta]) + assert [c.container.id for c in result.containers] == ["beta_priceable"] + assert result.score[1] == 1_500 + assert result.unpacked == () # --------------------------------------------------------- maximum value @@ -539,3 +700,114 @@ def test_open_dimension_height_matches_the_exact_solver_on_a_small_instance(): # achievable height is exactly one cube's height, 100mm (1_600_000 ticks). assert exact_height == 1_600_000 assert heuristic_height == exact_height + + +def test_exact_small_does_not_prune_a_heavier_promotional_rate_band(): + """A tariff may dip: two 100 g parcels cost 100, while a 100 g + 800 g pair + costs 10. Exact search must retain the heavier equal-count branch rather than use + volume/weight monotonicity the public rate-table contract does not promise.""" + from packvium.models import RateTable + + parcels = [ + Item.create("a-light", Dimensions.mm(100, 100, 100), weight="100g"), + Item.create("b-light", Dimensions.mm(100, 100, 100), weight="100g"), + Item.create("z-heavy", Dimensions.mm(100, 100, 100), weight="800g"), + ] + bin_type = Container.create( + "bin", + Dimensions.mm(200, 100, 100), + rate_table=RateTable((200, 900), (100, 10)), + quantity=1, + ) + result = pack(parcels, [bin_type], PackingConfig.exact_small( + objective="lowest_landed_cost", + dimensional_weight_divisor=10_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + max_containers=1, + )) + assert result.score[:2] == (1, 10) + assert "z-heavy#1" in { + placement.instance.id + for packed_container in result.containers + for placement in packed_container.placements + } + + +def test_a_missing_rate_table_is_refused_at_admission_even_when_unused(): + """A missing tariff is a static property of the request: rating some containers and + not others would rank a priced packing against an unpriced one as though the + unpriced were free. Rust and the JavaScript fallback already refused up front; + Python enforced this only if the search happened to touch the untabled container, + so the same request answered or aborted depending on search internals ( + review).""" + from packvium.config import PackingConfig + from packvium.extensions import UnknownObjectiveError + from packvium.models import RateTable + from packvium.packer import Packer + + tiny = Item.create("tiny", Dimensions.mm(10, 10, 10), weight="1g") + tabled = Container.create( + "tabled", Dimensions.mm(100, 100, 100), rate_table=RateTable((1_000,), (100,)), + ) + untabled = Container.create("untabled", Dimensions.mm(500, 500, 500)) + packer = Packer(PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + )) + with pytest.raises(UnknownObjectiveError, match="requires a rate_table on every container; 'untabled'"): + packer.pack([tiny], [tabled, untabled]) + + +def test_a_missing_divisor_is_refused_at_admission_naming_the_right_objective(): + """The late scorer check inherits shipping_cost's sentence, so a landed-cost + request used to run the whole search and then die blaming the wrong objective.""" + from packvium.config import PackingConfig + from packvium.extensions import UnknownObjectiveError + from packvium.models import RateTable + from packvium.packer import Packer + + tiny = Item.create("tiny", Dimensions.mm(10, 10, 10), weight="1g") + tabled = Container.create( + "tabled", Dimensions.mm(100, 100, 100), rate_table=RateTable((1_000,), (100,)), + ) + with pytest.raises( + UnknownObjectiveError, + match="the lowest_landed_cost objective requires configuration.dimensional_weight_divisor", + ): + Packer(PackingConfig(objective="lowest_landed_cost")).pack([tiny], [tabled]) + + +def test_alternatives_never_quote_the_sentinel(): + """The refusal guarded only the winner; `alternatives` (top_k defaults to 3) could + carry a feasible-status packing of an unpriceable container with the sentinel as + its landed cost -- the exact number this objective exists to never invent. Runner- + ups the tariff cannot price are dropped before the slice ( review).""" + from packvium.config import PackingConfig, SolverProfile + from packvium.extensions import UNPRICEABLE_MINOR, unpriceable_container + from packvium.models import RateTable + from packvium.packer import Packer + + box = Item.create("box", Dimensions.mm(100, 100, 100), weight="500g", quantity=8) + alpha = Container.create( + "alpha_unpriceable", Dimensions.mm(300, 300, 300), + rate_table=RateTable((2_000,), (900,)), + ) + beta = Container.create( + "beta_priceable", Dimensions.mm(400, 400, 400), + rate_table=RateTable((20_000,), (1_500,)), + ) + config = PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + profile=SolverProfile.QUALITY, + ) + result = Packer(config).pack([box], [alpha, beta]) + assert result.score[1] == 1_500 + for alternative in result.alternatives: + assert alternative.score[1] != UNPRICEABLE_MINOR + assert unpriceable_container(alternative.containers, config) is None diff --git a/tests/test_rebalance.py b/tests/test_rebalance.py index fbc66d4..162eadc 100644 --- a/tests/test_rebalance.py +++ b/tests/test_rebalance.py @@ -234,3 +234,104 @@ def test_rebalance_never_widens_the_payload_spread(seed): # what separates the starting layout from the finishing one, in either direction. if outcome.moves: assert before != after + + +def test_a_rebalance_move_never_prices_a_container_past_its_bracket(): + """The only spread-improving move -- one brick into the lighter box -- would bill it + at 2000 g, past its 1500 g card. Under lowest_landed_cost that is not an + improvement: the sentinel must never ride out through a rebalanced packing any more + than through a packed one ( review).""" + from packvium import Container, Dimensions, Item + from packvium.models import RateTable + from packvium.packer import Packer + + bricks = Item.create("brick", Dimensions.mm(100, 100, 100), weight="1000g", quantity=4) + wide = Container.create( + "wide", Dimensions.mm(300, 100, 100), rate_table=RateTable((4_000,), (500,)), + ) + narrow = Container.create( + "narrow", Dimensions.mm(400, 100, 100), rate_table=RateTable((1_500,), (300,)), + ) + config = PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + ) + result = Packer(config).pack([bricks], [wide, narrow]) + assert [c.container.id for c in result.containers] == ["wide", "narrow"] + request = PackingRequest((bricks,), (wide, narrow)) + rebalanced = rebalance_weight(request, result.containers, result.unpacked, config) + assert rebalanced.moves == () + assert [c.container.id for c in rebalanced.containers] == ["wide", "narrow"] + # The veto is objective-gated: the identical packing under a plain config makes the + # spread-improving move, so this is not a general rebalance regression. + moved = rebalance_weight(request, result.containers, result.unpacked, PackingConfig()) + assert len(moved.moves) == 1 + + +def test_rebalance_refuses_an_unpriceable_input(): + """A caller handing rebalance a packing whose container already bills past its + bracket gets the same refusal `Packer.pack` gives on the way out, not a rebalanced + version of a shipment with no published price ( review).""" + from packvium import Container, Dimensions, Item + from packvium.models import RateTable, UnratedWeightError + from packvium.packer import Packer + + bricks = Item.create("brick", Dimensions.mm(100, 100, 100), weight="1000g", quantity=4) + wide = Container.create( + "wide", Dimensions.mm(300, 100, 100), rate_table=RateTable((4_000,), (500,)), + ) + narrow = Container.create( + "narrow", Dimensions.mm(400, 100, 100), rate_table=RateTable((1_500,), (300,)), + ) + config = PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=5_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + ) + result = Packer(config).pack([bricks], [wide, narrow]) + # Shrink the wide card after the fact so the input itself is unpriceable. + stingy = Container.create( + "wide", Dimensions.mm(300, 100, 100), rate_table=RateTable((1_000,), (500,)), + ) + reshaped = tuple( + PackedContainer(stingy if c.container.id == "wide" else c.container, c.sequence, c.placements) + for c in result.containers + ) + request = PackingRequest((bricks,), (stingy, narrow)) + with pytest.raises(UnratedWeightError, match="no published price"): + rebalance_weight(request, reshaped, result.unpacked, config) + + +def test_rebalance_applies_the_same_landed_cost_admission_as_pack(): + """The public rebalance entry point must not accept a request the pack entry point + rejects: pricing requires a divisor and a rate card on every available container, + including a container the current packing did not happen to use ( review).""" + from packvium import Container, Dimensions, Item + from packvium.extensions import UnknownObjectiveError + from packvium.models import RateTable + from packvium.packer import Packer + + parcel = Item.create("parcel", Dimensions.mm(100, 100, 100), weight="500g") + rated = Container.create( + "rated", Dimensions.mm(200, 200, 200), rate_table=RateTable((2_000,), (500,)), + ) + valid = PackingConfig( + objective="lowest_landed_cost", + dimensional_weight_divisor=8_000, + dimensional_weight_length_unit="cm", + dimensional_weight_weight_unit="kg", + ) + result = Packer(valid).pack([parcel], [rated]) + + missing_divisor = PackingConfig(objective="lowest_landed_cost") + request = PackingRequest((parcel,), (rated,)) + with pytest.raises(UnknownObjectiveError, match="dimensional_weight_divisor"): + rebalance_weight(request, result.containers, result.unpacked, missing_divisor) + + untabled = Container.create("untabled", Dimensions.mm(300, 300, 300)) + request_with_unused_container = PackingRequest((parcel,), (rated, untabled)) + with pytest.raises(UnknownObjectiveError, match="rate_table on every container; 'untabled'"): + rebalance_weight(request_with_unused_container, result.containers, result.unpacked, valid)