diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 6aee0c5..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81d50d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +node_modules/ +*.tgz +packvium-native.node 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/README.md b/README.md index 5c322da..de19369 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ production. ## Quick start ```js -import { backend, pack } from '@packvium/engine'; +import { backend, commerce, pack } from '@packvium/engine'; const result = pack({ items: [{ @@ -31,6 +31,79 @@ const result = pack({ console.log(backend()); // "rust" or "javascript" console.log(result.status); // "feasible" console.log(result.containers); + +const commerceDocument = { tariffs: [{ + carrier_id: 'acme', service_id: 'ground', + versions: [{ + effective_at: 0, dimensional_weight_divisor: 5000, + cost_per_dimensional_kg_minor: { 'zone-a': 450 }, + minimum_charge_minor: 900, fuel_surcharge_permille: 120, + }], +}] }; +const quote = commerce.quote(commerceDocument, { + carrier_id: 'acme', service_id: 'ground', tariff_version: 1, + zone: 'zone-a', actual_weight_g: 1200, volume_mm3: 6000000, +}); +console.log(quote.quote.total_minor); +``` + +## Quotes, policy and catalog versions + +`commerce` has three functions, all deterministic and all over one document you supply — +no clock, no network, no hidden state. A history is a list and a version's number is its +position in that list starting at 1, so `tariff_version: 2` always means "the second +entry under this carrier and service". + +```js +import { commerce } from '@packvium/engine'; + +// Which version applies: pin it, or ask what was in force at an instant. Never both. +commerce.quote(document, { /* ... */ tariff_version: 1 }); +commerce.quote(document, { /* ... */ as_of: 1500 }); + +// The decision, and the rule id and version that made it. +const { decision } = commerce.evaluatePolicy(document, { + scope: 'hazmat', context: { un_class: '1.4' }, as_of: 0, +}); +decision.allowed; // false +decision.citation.rule_id; // "no-hazmat-air" + +// Which catalog version a pin resolves to, what it holds, whether it was a rollback. +const { catalog } = commerce.catalogVersionInfo(document, { + catalog_id: 'dc-12', version: 2, resolved_at: 1700, +}); +catalog.entry_counts; // { items: 1, cartons: 1, pallets: 0, ... } +catalog.rolled_back_from; // 1, or null for an ordinary publication + +// Store, log and compare results in the canonical form, not JSON.stringify. +commerce.canonicalJson(result); +``` + +Two kinds of failure, and they are not interchangeable: + +- a **malformed** document or request is your bug and throws `CommerceInputError`; +- a request the model simply **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 and structured fields naming what was missing. + +`commerce.backend()` reports whether the native addon or the JavaScript implementation +answered; both return the same result for the same input. A runnable walk-through of all +three functions is in [examples/commerce.mjs](examples/commerce.mjs), and the full +contract — document format, every result shape, all ten rejection codes, complexity and +limitations — is `docs/COMMERCE-API.md`. + +## 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.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. | +| [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. | + +```bash +node examples/basic.mjs ``` ## Features @@ -41,6 +114,8 @@ console.log(result.containers); - JSON input/output through `pack()` or `packJson()`. - Optional payload rebalancing with `rebalanceWeight()`. - Loading and removal sequence helpers for already placed boxes. +- Deterministic carrier quotes, policy evaluation and effective-dated catalog lookup + through `commerce`. The native addon is optional. `npm install` works on unsupported platforms too; call `backend()` if your application needs to know which implementation handled a request. @@ -48,7 +123,8 @@ The native addon is optional. `npm install` works on unsupported platforms too; ## API and support TypeScript declarations are included. See the package's `index.d.ts` for the complete -request and result types. Report security issues through [SECURITY.md](SECURITY.md). +request and result types, and `docs/COMMERCE-API.md` for the commercial/control-plane +contract. Report security issues through [SECURITY.md](SECURITY.md). ## License diff --git a/commerce-model.js b/commerce-model.js new file mode 100644 index 0000000..2a61c58 --- /dev/null +++ b/commerce-model.js @@ -0,0 +1,236 @@ +/** + * The commercial and control-plane models: carrier rating, eligibility policy and + * catalog versioning. + * + * An independent implementation of the contract in docs/COMMERCE-API.md, held to + * producing a valid result that meets each shared fixture's objective floor. For a + * quote that floor is an exact integer price, so matching it means matching exactly. + * + * Money, weight and volume arithmetic runs in BigInt and every inexact division rounds + * up, so a quote can neither drift through a double nor land a minor unit below what + * the tariff charges. Results are converted back to Number at the boundary; a component + * beyond Number.MAX_SAFE_INTEGER is refused rather than silently rounded. + * + * This module is package-internal: package.json exports only the root entry point. + */ + +export class CommerceInputError extends Error { + constructor(message) { + super(message); + this.name = 'CommerceInputError'; + } +} + +/** Exact ceil(a * b / d) for non-negative inputs, in BigInt so nothing can wrap. */ +export function ceilMulDiv(a, b, d) { + const divisor = BigInt(d); + if (divisor <= 0n) throw new CommerceInputError('divisor must be positive'); + const product = BigInt(a) * BigInt(b); + return (product + divisor - 1n) / divisor; +} + +/** Convert an exact BigInt back to a JSON number, refusing a value a double cannot hold. */ +export function exact(value) { + const number = Number(value); + if (!Number.isSafeInteger(number)) { + throw new CommerceInputError( + `${value} is outside JavaScript's exact integer range; this quote cannot be represented`, + ); + } + return number; +} + +/** + * Order two strings by Unicode code point, the way every other implementation orders + * them. + * + * JavaScript's default string comparison is by UTF-16 code unit, which disagrees with + * Python, PHP and Rust for any character outside the Basic Multilingual Plane: an emoji + * (U+1F600) sorts *before* a fullwidth Latin A (U+FF21) by code unit and *after* it by + * code point. Sorted id lists are part of this contract's answer, so a default `.sort()` + * would make this implementation disagree with the other three on exactly those inputs. + */ +export function compareCodePoints(left, right) { + const a = Array.from(left); + const b = Array.from(right); + const shared = Math.min(a.length, b.length); + for (let index = 0; index < shared; index += 1) { + const difference = a[index].codePointAt(0) - b[index].codePointAt(0); + if (difference !== 0) return difference < 0 ? -1 : 1; + } + if (a.length === b.length) return 0; + return a.length < b.length ? -1 : 1; +} + +// ------------------------------------------------------------------------------ rating + +/** The charge one accessorial adds, given the base charge it may be a permille of. */ +export function accessorialCharge(accessorial, baseChargeMinor) { + if (accessorial.flatChargeMinor !== null) return BigInt(accessorial.flatChargeMinor); + return ceilMulDiv(baseChargeMinor, accessorial.permilleOfBase, 1000); +} + +/** + * Rate a request against one already-resolved immutable tariff version. + * + * Returns either `{breakdown}` or `{rejection}`, where a rejection names structurally + * what was missing -- never a silently-zero charge. + */ +export function rateTariff(tariff, request) { + if (!Object.hasOwn(tariff.costPerDimensionalKgMinor, request.zone)) { + return { rejection: { kind: 'zone', zone: request.zone } }; + } + const unknown = request.requestedAccessorials + .filter((id) => !Object.hasOwn(tariff.accessorials, id)) + .sort(compareCodePoints); + if (unknown.length > 0) return { rejection: { kind: 'accessorial', accessorialIds: unknown } }; + + // Dimensional weight in grams is volume (mm^3) over the divisor, rounded up. + const dimensionalWeightG = ceilMulDiv(request.volumeMm3, 1, tariff.dimensionalWeightDivisor); + const billedWeightG = + BigInt(request.actualWeightG) > dimensionalWeightG + ? BigInt(request.actualWeightG) + : dimensionalWeightG; + + const rawBaseChargeMinor = ceilMulDiv( + billedWeightG, tariff.costPerDimensionalKgMinor[request.zone], 1000, + ); + const minimumChargeApplied = rawBaseChargeMinor < BigInt(tariff.minimumChargeMinor); + const baseChargeMinor = minimumChargeApplied + ? BigInt(tariff.minimumChargeMinor) + : rawBaseChargeMinor; + + const fuelSurchargeMinor = ceilMulDiv(baseChargeMinor, tariff.fuelSurchargePermille, 1000); + const accessorialCharges = request.requestedAccessorials.map((id) => [ + id, accessorialCharge(tariff.accessorials[id], baseChargeMinor), + ]); + const accessorialTotal = accessorialCharges.reduce((sum, [, amount]) => sum + amount, 0n); + + return { + breakdown: { + carrier_id: tariff.carrierId, + service_id: tariff.serviceId, + tariff_version: tariff.version, + zone: request.zone, + actual_weight_g: request.actualWeightG, + dimensional_weight_g: exact(dimensionalWeightG), + billed_weight_g: exact(billedWeightG), + base_charge_minor: exact(baseChargeMinor), + minimum_charge_applied: minimumChargeApplied, + fuel_surcharge_minor: exact(fuelSurchargeMinor), + accessorial_charges_minor: accessorialCharges.map(([id, amount]) => [id, exact(amount)]), + total_minor: exact(baseChargeMinor + fuelSurchargeMinor + accessorialTotal), + }, + }; +} + +/** + * The version effective at `asOf`: the highest `effective_at` not after it, ties broken + * by the higher (later-published) version number. Null when nothing has taken effect. + */ +export function effectiveVersion(history, asOf, numberOf) { + let winner = null; + for (const candidate of history) { + if (candidate.effectiveAt > asOf) continue; + if ( + winner === null + || candidate.effectiveAt > winner.effectiveAt + || (candidate.effectiveAt === winner.effectiveAt && numberOf(candidate) > numberOf(winner)) + ) { + winner = candidate; + } + } + return winner; +} + +// ------------------------------------------------------------------------------ policy + +export const POLICY_SCOPES = [ + 'facility', 'customer', 'carrier', 'material', 'hazmat', 'temperature', 'service', +]; +export const POLICY_OPERATORS = ['equals', 'not_equals', 'in', 'not_in', 'exists', 'absent']; +export const POLICY_ACTIONS = ['allow', 'reject']; +const UNARY_OPERATORS = ['exists', 'absent']; + +export function isUnary(operator) { + return UNARY_OPERATORS.includes(operator); +} + +/** + * Value equality over the JSON scalar types, matching the reference implementation + * exactly -- including that a boolean equals the integer it stands for, the one place a + * naive `===` would disagree and quietly change a decision. + */ +export function valuesEqual(left, right) { + if (typeof left === 'boolean' || typeof right === 'boolean') { + if (typeof left === 'boolean' && typeof right === 'boolean') return left === right; + const other = typeof left === 'boolean' ? right : left; + return typeof other === 'number' && Number(left) === Number(right); + } + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && left.every((entry, index) => valuesEqual(entry, right[index])); + } + if (Array.isArray(left) || Array.isArray(right)) return false; + if (left === null || right === null) return left === right; + if (typeof left !== typeof right) return false; + return left === right; +} + +function contains(haystack, needle) { + if (Array.isArray(haystack)) return haystack.some((entry) => valuesEqual(entry, needle)); + if (typeof haystack === 'string') return typeof needle === 'string' && haystack.includes(needle); + return false; +} + +export function predicateMatches(predicate, context) { + const present = Object.hasOwn(context, predicate.field); + if (predicate.operator === 'exists') return present; + if (predicate.operator === 'absent') return !present; + if (!present) return false; + const actual = context[predicate.field]; + switch (predicate.operator) { + case 'equals': return valuesEqual(actual, predicate.value); + case 'not_equals': return !valuesEqual(actual, predicate.value); + case 'in': return contains(predicate.value, actual); + default: return !contains(predicate.value, actual); + } +} + +export function ruleMatches(rule, context) { + return rule.predicates.every((predicate) => predicateMatches(predicate, context)); +} + +/** + * Evaluate an already-resolved immutable rule set. + * + * Deny takes precedence: an explicit REJECT always outranks an ALLOW for the same + * context. Among equals the highest priority wins, ties break on the lexicographically + * smallest rule id, and nothing matching at all is allowed with no citation. + */ +export function decide(rules, scope, context) { + const matching = rules.filter((rule) => rule.scope === scope && ruleMatches(rule, context)); + const rejects = matching.filter((rule) => rule.action === 'reject'); + const pool = rejects.length > 0 ? rejects : matching; + if (pool.length === 0) return { scope, allowed: true, citation: null }; + + // Ties break on the lexicographically smallest rule id -- by code point, because a + // default `<` on strings compares UTF-16 code units and would cite a different rule + // than the other three implementations whenever an id leaves the Basic Multilingual + // Plane. + const winner = pool.reduce((best, rule) => ( + rule.priority > best.priority + || (rule.priority === best.priority && compareCodePoints(rule.ruleId, best.ruleId) < 0) + ? rule : best + )); + return { + scope, + allowed: winner.action === 'allow', + citation: { + rule_id: winner.ruleId, + version: winner.version, + action: winner.action, + priority: winner.priority, + reason: winner.reason, + }, + }; +} diff --git a/commerce.js b/commerce.js new file mode 100644 index 0000000..b578438 --- /dev/null +++ b/commerce.js @@ -0,0 +1,664 @@ +/** + * Packvium's exported commercial and control-plane API for JavaScript. + * + * Three deterministic functions over one canonical JSON document: a quote, a policy + * decision and catalog version metadata. The contract -- document format, result + * shapes, the closed set of rejection codes, complexity and limitations -- is + * docs/COMMERCE-API.md. + * + * Parsing is strict in both directions: a missing required key and an unrecognised + * extra key are both a CommerceInputError, because a field the contract does not define + * must never be silently ignored. A well-formed request the commercial model simply + * cannot answer is not an error at all -- it is a result document whose status is + * "rejected", the same way an infeasible packing request returns a result with a status. + * + * This module is package-internal: package.json exports only the root entry point, + * which re-exports these as `commerce`. + */ + +import { + CommerceInputError, + compareCodePoints, + POLICY_ACTIONS, + POLICY_OPERATORS, + POLICY_SCOPES, + decide, + effectiveVersion, + isUnary, + rateTariff, +} from './commerce-model.js'; + +export { CommerceInputError }; + +export const API_VERSION = 1; + +/** The closed set of rejection codes, in the order docs/COMMERCE-API.md tabulates them. */ +export const 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', +]; + +const EXCLUSION_SCOPES = ['item_carton', 'item_pallet']; +const OVERRIDE_KINDS = ['carton', 'item', 'pallet']; + +// ------------------------------------------------------------------- shape primitives + +function fail(path, message) { + throw new CommerceInputError(`${path}: ${message}`); +} + +function asObject(value, path) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'expected an object'); + } + return value; +} + +function asList(value, path) { + if (!Array.isArray(value)) fail(path, 'expected a list'); + return value; +} + +function asInteger(value, path) { + // A JSON boolean where an exact integer belongs is a caller mistake, not a 0 or a 1. + if (typeof value !== 'number' || !Number.isInteger(value)) { + fail(path, 'expected an exact integer'); + } + return value; +} + +function asText(value, path) { + if (typeof value !== 'string') fail(path, 'expected a string'); + return value; +} + +function checkKeys(value, path, required, optionalKeys = []) { + const missing = required.filter((key) => !Object.hasOwn(value, key)).sort(); + if (missing.length > 0) fail(path, `missing required key(s) ${JSON.stringify(missing)}`); + const unknown = Object.keys(value) + .filter((key) => !required.includes(key) && !optionalKeys.includes(key)) + .sort(); + if (unknown.length > 0) fail(path, `unrecognised key(s) ${JSON.stringify(unknown)}`); +} + +/** An absent or explicitly-null optional field reads as absent, in every language. */ +function optional(value, key) { + const found = value[key]; + return found === undefined || found === null ? undefined : found; +} + +function asAxes(value, path, count) { + const entries = asList(value, path); + if (entries.length !== count) fail(path, `expected exactly ${count} axes`); + return entries.map((entry, index) => asInteger(entry, `${path}[${index}]`)); +} + +function asEnum(value, path, allowed, label) { + const found = asText(value, path); + if (!allowed.includes(found)) fail(path, `unsupported ${label} '${found}'`); + return found; +} + +function positive(value, path, message) { + if (value <= 0) fail(path, message); + return value; +} + +function nonNegative(value, path, message) { + if (value < 0) fail(path, message); + return value; +} + +function requireUniqueIds(entries, label, path) { + const ids = entries.map((entry) => entry.id); + if (new Set(ids).size !== ids.length) fail(path, `duplicate ${label} ids in catalog snapshot`); +} + +/** + * The shared shape of all three histories: a list of `{...identity, versions: [...]}` + * entries, keyed by identity, where a version's number is its 1-based position. + */ +function loadHistories(value, path, identityKeys, label, parseVersion) { + const histories = new Map(); + asList(value, path).forEach((entry, index) => { + const entryPath = `${path}[${index}]`; + const fields = asObject(entry, entryPath); + checkKeys(fields, entryPath, [...identityKeys, 'versions']); + const identity = identityKeys.map((name) => asText(fields[name], `${entryPath}.${name}`)); + const key = identity.join('/'); + if (histories.has(key)) fail(entryPath, `duplicate ${label} history for '${key}'`); + const versions = asList(fields.versions, `${entryPath}.versions`); + if (versions.length === 0) { + fail(`${entryPath}.versions`, `a ${label} history needs at least one version`); + } + const history = []; + versions.forEach((version, position) => { + history.push( + parseVersion(version, `${entryPath}.versions[${position}]`, identity, position + 1, history), + ); + }); + histories.set(key, history); + }); + return histories; +} + +// -------------------------------------------------------------------- document loading + +/** Build the three append-only histories one canonical commerce document describes. */ +export function loadDocument(document) { + const root = asObject(document, 'document'); + checkKeys(root, 'document', [], ['tariffs', 'policy_rules', 'catalogs']); + return { + carriers: loadHistories( + optional(root, 'tariffs') ?? [], 'document.tariffs', + ['carrier_id', 'service_id'], 'tariff', parseTariff, + ), + policies: loadHistories( + optional(root, 'policy_rules') ?? [], 'document.policy_rules', + ['rule_id'], 'rule', parseRule, + ), + catalogs: loadHistories( + optional(root, 'catalogs') ?? [], 'document.catalogs', + ['catalog_id'], 'catalog', parseCatalogVersion, + ), + }; +} + +function parseTariff(value, path, [carrierId, serviceId], number) { + const fields = asObject(value, path); + checkKeys( + fields, path, + ['effective_at', 'dimensional_weight_divisor', 'cost_per_dimensional_kg_minor'], + ['minimum_charge_minor', 'fuel_surcharge_permille', 'accessorials'], + ); + const zonesPath = `${path}.cost_per_dimensional_kg_minor`; + const costPerDimensionalKgMinor = {}; + const zones = asObject(fields.cost_per_dimensional_kg_minor, zonesPath); + for (const [zone, cost] of Object.entries(zones)) { + costPerDimensionalKgMinor[zone] = nonNegative( + asInteger(cost, `${zonesPath}[${zone}]`), zonesPath, + 'cost_per_dimensional_kg_minor entries cannot be negative', + ); + } + return { + carrierId, + serviceId, + version: number, + effectiveAt: nonNegative( + asInteger(fields.effective_at, `${path}.effective_at`), + path, 'effective_at cannot be negative', + ), + dimensionalWeightDivisor: positive( + asInteger(fields.dimensional_weight_divisor, `${path}.dimensional_weight_divisor`), + path, 'dimensional_weight_divisor must be positive', + ), + costPerDimensionalKgMinor, + minimumChargeMinor: nonNegative( + asInteger(optional(fields, 'minimum_charge_minor') ?? 0, `${path}.minimum_charge_minor`), + path, 'minimum_charge_minor cannot be negative', + ), + fuelSurchargePermille: nonNegative( + asInteger(optional(fields, 'fuel_surcharge_permille') ?? 0, `${path}.fuel_surcharge_permille`), + path, 'fuel_surcharge_permille cannot be negative', + ), + accessorials: parseAccessorials(optional(fields, 'accessorials') ?? [], `${path}.accessorials`), + }; +} + +function parseAccessorials(value, path) { + const charges = {}; + asList(value, path).forEach((entry, index) => { + const entryPath = `${path}[${index}]`; + const fields = asObject(entry, entryPath); + checkKeys(fields, entryPath, ['accessorial_id'], ['flat_charge_minor', 'permille_of_base']); + const id = asText(fields.accessorial_id, `${entryPath}.accessorial_id`); + if (Object.hasOwn(charges, id)) fail(entryPath, `duplicate accessorial_id '${id}'`); + const flat = optional(fields, 'flat_charge_minor'); + const permille = optional(fields, 'permille_of_base'); + if ((flat === undefined) === (permille === undefined)) { + fail(entryPath, 'an accessorial must set exactly one of flat_charge_minor or permille_of_base'); + } + charges[id] = { + accessorialId: id, + flatChargeMinor: flat === undefined ? null : nonNegative( + asInteger(flat, `${entryPath}.flat_charge_minor`), + entryPath, 'flat_charge_minor cannot be negative', + ), + permilleOfBase: permille === undefined ? null : nonNegative( + asInteger(permille, `${entryPath}.permille_of_base`), + entryPath, 'permille_of_base cannot be negative', + ), + }; + }); + return charges; +} + +function parseRule(value, path, [ruleId], number) { + const fields = asObject(value, path); + checkKeys(fields, path, ['scope', 'action', 'predicates', 'priority', 'effective_at'], ['reason']); + const scope = asEnum(fields.scope, `${path}.scope`, POLICY_SCOPES, 'policy scope'); + const predicates = parsePredicates(fields.predicates, `${path}.predicates`, scope); + if (predicates.length === 0) fail(path, 'a rule must have at least one predicate'); + return { + ruleId, + version: number, + scope, + action: asEnum(fields.action, `${path}.action`, POLICY_ACTIONS, 'policy action'), + predicates, + priority: asInteger(fields.priority, `${path}.priority`), + effectiveAt: nonNegative( + asInteger(fields.effective_at, `${path}.effective_at`), + path, 'effective_at cannot be negative', + ), + reason: asText(optional(fields, 'reason') ?? '', `${path}.reason`), + }; +} + +function parsePredicates(value, path, scope) { + return asList(value, path).map((entry, index) => { + const entryPath = `${path}[${index}]`; + const fields = asObject(entry, entryPath); + checkKeys(fields, entryPath, ['scope', 'field', 'operator'], ['value']); + if (asEnum(fields.scope, `${entryPath}.scope`, POLICY_SCOPES, 'policy scope') !== scope) { + fail(entryPath, "every predicate of a rule must share the rule's own scope"); + } + const operator = asEnum( + fields.operator, `${entryPath}.operator`, POLICY_OPERATORS, 'policy operator', + ); + const predicateValue = optional(fields, 'value') ?? null; + if (!isUnary(operator) && predicateValue === null) { + fail(entryPath, `operator '${operator}' requires a value`); + } + const field = asText(fields.field, `${entryPath}.field`); + if (field === '') fail(entryPath, 'field is required'); + return { scope, field, operator, value: predicateValue }; + }); +} + +function parseCatalogVersion(value, path, _identity, number, history) { + const fields = asObject(value, path); + if (Object.hasOwn(fields, 'rollback_to')) return parseRollback(fields, path, number, history); + checkKeys(fields, path, ['effective_at', 'published_at', 'snapshot'], ['note']); + return { + number, + snapshot: parseSnapshot(fields.snapshot, `${path}.snapshot`), + effectiveAt: nonNegative( + asInteger(fields.effective_at, `${path}.effective_at`), + path, 'effective_at cannot be negative', + ), + publishedAt: nonNegative( + asInteger(fields.published_at, `${path}.published_at`), + path, 'published_at cannot be negative', + ), + rolledBackFrom: null, + note: asText(optional(fields, 'note') ?? '', `${path}.note`), + }; +} + +/** A rollback is a new, higher-numbered version whose snapshot equals a prior one's. */ +function parseRollback(fields, path, number, history) { + checkKeys(fields, path, ['rollback_to', 'published_at'], ['effective_at', 'note']); + const toVersion = asInteger(fields.rollback_to, `${path}.rollback_to`); + const target = history.find((version) => version.number === toVersion); + if (target === undefined) { + fail(path, `rollback_to names version ${toVersion}, which is not published yet`); + } + const publishedAt = asInteger(fields.published_at, `${path}.published_at`); + const note = asText(optional(fields, 'note') ?? '', `${path}.note`); + return { + number, + snapshot: target.snapshot, + effectiveAt: asInteger(optional(fields, 'effective_at') ?? publishedAt, `${path}.effective_at`), + publishedAt, + rolledBackFrom: toVersion, + note: note === '' ? `rollback to version ${toVersion}` : note, + }; +} + +function parseSnapshot(value, path) { + const fields = asObject(value, path); + checkKeys(fields, path, [], ['items', 'cartons', 'pallets', 'exclusions', 'overrides']); + const collect = (key, parse) => asList(optional(fields, key) ?? [], `${path}.${key}`) + .map((entry, index) => parse( + asObject(entry, `${path}.${key}[${index}]`), `${path}.${key}[${index}]`, + )); + + const snapshot = { + items: collect('items', parseItem), + cartons: collect('cartons', parseCarton), + pallets: collect('pallets', parsePallet), + exclusions: collect('exclusions', parseExclusion), + overrides: collect('overrides', parseOverride), + }; + requireUniqueIds(snapshot.items, 'item', path); + requireUniqueIds(snapshot.cartons, 'carton', path); + requireUniqueIds(snapshot.pallets, 'pallet', path); + requireUniqueIds(snapshot.exclusions, 'exclusion', path); + requireUniqueIds(snapshot.overrides, 'facility override', path); + return snapshot; +} + +function identifier(fields, path, label) { + const id = asText(fields.id, `${path}.id`); + if (id === '') fail(path, `${label} id is required`); + return id; +} + +function parseItem(fields, path) { + checkKeys(fields, path, ['id', 'dimensions_mm', 'weight_g'], ['description']); + const dimensions = asAxes(fields.dimensions_mm, `${path}.dimensions_mm`, 3); + if (dimensions.some((axis) => axis <= 0)) fail(path, 'item dimensions must be positive'); + return { + id: identifier(fields, path, 'item'), + dimensionsMm: dimensions, + weightG: positive( + asInteger(fields.weight_g, `${path}.weight_g`), path, 'item weight must be positive', + ), + description: asText(optional(fields, 'description') ?? '', `${path}.description`), + }; +} + +function parseCarton(fields, path) { + checkKeys(fields, path, ['id', 'inner_dimensions_mm', 'max_payload_g'], ['cost_minor']); + const dimensions = asAxes(fields.inner_dimensions_mm, `${path}.inner_dimensions_mm`, 3); + if (dimensions.some((axis) => axis <= 0)) fail(path, 'carton dimensions must be positive'); + return { + id: identifier(fields, path, 'carton'), + innerDimensionsMm: dimensions, + maxPayloadG: positive( + asInteger(fields.max_payload_g, `${path}.max_payload_g`), + path, 'carton max_payload_g must be positive', + ), + costMinor: nonNegative( + asInteger(optional(fields, 'cost_minor') ?? 0, `${path}.cost_minor`), + path, 'cost_minor cannot be negative', + ), + }; +} + +function parsePallet(fields, path) { + checkKeys(fields, path, ['id', 'deck_dimensions_mm', 'max_payload_g'], ['max_stack_height_mm']); + const deck = asAxes(fields.deck_dimensions_mm, `${path}.deck_dimensions_mm`, 2); + if (deck.some((axis) => axis <= 0)) fail(path, 'pallet dimensions must be positive'); + const height = optional(fields, 'max_stack_height_mm'); + return { + id: identifier(fields, path, 'pallet'), + deckDimensionsMm: deck, + maxPayloadG: positive( + asInteger(fields.max_payload_g, `${path}.max_payload_g`), + path, 'pallet max_payload_g must be positive', + ), + maxStackHeightMm: height === undefined ? null : positive( + asInteger(height, `${path}.max_stack_height_mm`), + path, 'max_stack_height_mm must be positive', + ), + }; +} + +function parseExclusion(fields, path) { + checkKeys(fields, path, ['id', 'scope', 'subject_id', 'excluded_id'], ['reason']); + const subjectId = asText(fields.subject_id, `${path}.subject_id`); + const excludedId = asText(fields.excluded_id, `${path}.excluded_id`); + if (subjectId === '' || excludedId === '') { + fail(path, 'an exclusion rule must reference both a subject and an excluded id'); + } + return { + id: identifier(fields, path, 'exclusion'), + scope: asEnum(fields.scope, `${path}.scope`, EXCLUSION_SCOPES, 'exclusion scope'), + subjectId, + excludedId, + reason: asText(optional(fields, 'reason') ?? '', `${path}.reason`), + }; +} + +function parseOverride(fields, path) { + checkKeys(fields, path, ['id', 'facility_id', 'entry_id', 'kind', 'override']); + const kind = asEnum(fields.kind, `${path}.kind`, OVERRIDE_KINDS, 'override kind'); + const parse = { item: parseItem, carton: parseCarton, pallet: parsePallet }[kind]; + const entry = parse(asObject(fields.override, `${path}.override`), `${path}.override`); + const facilityId = asText(fields.facility_id, `${path}.facility_id`); + const entryId = asText(fields.entry_id, `${path}.entry_id`); + if (facilityId === '') fail(path, 'facility_id is required'); + if (entry.id !== entryId) fail(path, "a facility override's entry_id must match override.id"); + return { id: identifier(fields, path, 'facility override'), facilityId, entryId, kind, entry }; +} + +// --------------------------------------------------------------------------- responses + +function ok(key, payload) { + return { api_version: API_VERSION, status: 'ok', [key]: payload }; +} + +function rejected(code, fields) { + return { api_version: API_VERSION, status: 'rejected', error: { code, fields } }; +} + +function exactlyOne(request, names) { + const present = names.filter((name) => optional(request, name) !== undefined); + if (present.length !== 1) fail('request', `expected exactly one of ${JSON.stringify(names)}`); + return present[0]; +} + +// ------------------------------------------------------------------------------- quote + +/** Price one shipment against one pinned or effective-dated tariff version. */ +export function quote(document, request) { + const loaded = loadDocument(document); + const fields = asObject(request, 'request'); + checkKeys( + fields, 'request', + ['carrier_id', 'service_id', 'zone', 'actual_weight_g', 'volume_mm3'], + ['tariff_version', 'as_of', 'requested_accessorials'], + ); + const pin = exactlyOne(fields, ['tariff_version', 'as_of']); + const carrierId = asText(fields.carrier_id, 'request.carrier_id'); + const serviceId = asText(fields.service_id, 'request.service_id'); + const ratingRequest = parseRatingRequest(fields); + const identity = { carrier_id: carrierId, service_id: serviceId }; + + const resolved = resolveTariff(loaded.carriers, identity, fields, pin); + if (resolved.rejection !== undefined) { + return rejected(resolved.rejection.code, resolved.rejection.fields); + } + + const { tariff } = resolved; + const { breakdown, rejection } = rateTariff(tariff, ratingRequest); + if (rejection !== undefined) { + const where = { ...identity, tariff_version: tariff.version }; + return rejection.kind === 'zone' + ? rejected('unavailable_zone', { ...where, zone: rejection.zone }) + : rejected('unavailable_accessorial', { ...where, accessorial_ids: rejection.accessorialIds }); + } + return ok('quote', breakdown); +} + +function resolveTariff(carriers, identity, fields, pin) { + const history = carriers.get(`${identity.carrier_id}/${identity.service_id}`); + if (pin === 'tariff_version') { + const version = asInteger(fields.tariff_version, 'request.tariff_version'); + const tariff = history?.find((candidate) => candidate.version === version); + if (tariff === undefined) { + return { rejection: { code: 'tariff_not_found', fields: { ...identity, tariff_version: version } } }; + } + return { tariff }; + } + const asOf = asInteger(fields.as_of, 'request.as_of'); + if (history === undefined) { + return { rejection: { code: 'tariff_not_found', fields: identity } }; + } + const tariff = effectiveVersion(history, asOf, (candidate) => candidate.version); + if (tariff === null) { + return { rejection: { code: 'no_effective_tariff', fields: { ...identity, as_of: asOf } } }; + } + return { tariff }; +} + +function parseRatingRequest(fields) { + const requestedAccessorials = asList( + optional(fields, 'requested_accessorials') ?? [], 'request.requested_accessorials', + ).map((entry, index) => asText(entry, `request.requested_accessorials[${index}]`)); + if (requestedAccessorials.some((id) => id === '')) { + fail('request.requested_accessorials', 'accessorial ids must be non-empty'); + } + if (new Set(requestedAccessorials).size !== requestedAccessorials.length) { + fail('request.requested_accessorials', 'accessorial ids must be unique'); + } + const zone = asText(fields.zone, 'request.zone'); + if (zone === '') fail('request.zone', 'zone is required'); + return { + zone, + actualWeightG: nonNegative( + asInteger(fields.actual_weight_g, 'request.actual_weight_g'), + 'request.actual_weight_g', 'actual_weight_g cannot be negative', + ), + volumeMm3: nonNegative( + asInteger(fields.volume_mm3, 'request.volume_mm3'), + 'request.volume_mm3', 'volume_mm3 cannot be negative', + ), + requestedAccessorials, + }; +} + +// --------------------------------------------------------------------- evaluate policy + +/** Decide one eligibility question against a pinned or effective-dated rule set. */ +export function evaluatePolicy(document, request) { + const loaded = loadDocument(document); + const fields = asObject(request, 'request'); + checkKeys(fields, 'request', ['scope', 'context'], ['as_of', 'rule_versions']); + const pin = exactlyOne(fields, ['as_of', 'rule_versions']); + const scope = asEnum(fields.scope, 'request.scope', POLICY_SCOPES, 'policy scope'); + const context = asObject(fields.context, 'request.context'); + + if (pin === 'as_of') { + const asOf = asInteger(fields.as_of, 'request.as_of'); + const effective = [...loaded.policies.values()] + .map((history) => effectiveVersion(history, asOf, (rule) => rule.version)) + .filter((rule) => rule !== null); + return ok('decision', decide(effective, scope, context)); + } + const resolved = resolvePins(loaded.policies, fields.rule_versions); + if (resolved.rejection !== undefined) { + return rejected(resolved.rejection.code, resolved.rejection.fields); + } + return ok('decision', decide(resolved.rules, scope, context)); +} + +function resolvePins(policies, value) { + const pins = asList(value, 'request.rule_versions').map((entry, index) => { + const path = `request.rule_versions[${index}]`; + const pair = asList(entry, path); + if (pair.length !== 2) fail(path, 'expected a [rule_id, version] pair'); + return [asText(pair[0], `${path}[0]`), asInteger(pair[1], `${path}[1]`)]; + }); + if (new Set(pins.map(([ruleId]) => ruleId)).size !== pins.length) { + fail('request.rule_versions', 'a policy snapshot cannot pin the same rule id twice'); + } + // Sorted so an explicit snapshot is order-independent, exactly as the reference + // implementation orders it before deciding. + pins.sort(([leftId, leftVersion], [rightId, rightVersion]) => ( + leftId === rightId ? leftVersion - rightVersion : compareCodePoints(leftId, rightId) + )); + + const rules = []; + for (const [ruleId, version] of pins) { + const history = policies.get(ruleId); + if (history === undefined) { + return { rejection: { code: 'policy_rule_not_found', fields: { rule_id: ruleId } } }; + } + const rule = history.find((candidate) => candidate.version === version); + if (rule === undefined) { + return { rejection: { code: 'policy_version_not_found', fields: { rule_id: ruleId, version } } }; + } + rules.push(rule); + } + return { rules }; +} + +// ---------------------------------------------------------------- catalog version info + +/** Report which catalog version a reference resolves to, and what it contains. */ +export function catalogVersionInfo(document, request) { + const loaded = loadDocument(document); + const fields = asObject(request, 'request'); + checkKeys(fields, 'request', ['catalog_id', 'resolved_at'], ['version', 'as_of']); + const catalogId = asText(fields.catalog_id, 'request.catalog_id'); + const resolvedAt = asInteger(fields.resolved_at, 'request.resolved_at'); + const version = optional(fields, 'version') === undefined + ? undefined + : asInteger(fields.version, 'request.version'); + const asOf = optional(fields, 'as_of') === undefined + ? undefined + : asInteger(fields.as_of, 'request.as_of'); + if (version !== undefined && asOf !== undefined) { + fail('request', 'expected at most one of ["as_of","version"]'); + } + + const history = loaded.catalogs.get(catalogId); + if (history === undefined) return rejected('catalog_not_found', { catalog_id: catalogId }); + const selector = { catalog_id: catalogId }; + if (version !== undefined) selector.version = version; + if (asOf !== undefined) selector.as_of = asOf; + + const resolved = resolveCatalogVersion(history, version, asOf); + if (typeof resolved === 'string') return rejected(resolved, selector); + return ok('catalog', catalogPayload(catalogId, resolved, resolvedAt)); +} + +function resolveCatalogVersion(history, version, asOf) { + if (version !== undefined) { + return history.find((candidate) => candidate.number === version) ?? 'catalog_version_not_found'; + } + if (asOf === undefined) { + return history.length > 1 ? 'ambiguous_catalog_reference' : history[0]; + } + return effectiveVersion(history, asOf, (candidate) => candidate.number) + ?? 'no_effective_catalog_version'; +} + +function catalogPayload(catalogId, version, resolvedAt) { + const { snapshot } = version; + // Sorted so no map or insertion ordering can leak into the answer. + const ids = (entries) => entries.map((entry) => entry.id).sort(compareCodePoints); + return { + catalog_id: catalogId, + version: version.number, + effective_at: version.effectiveAt, + published_at: version.publishedAt, + resolved_at: resolvedAt, + rolled_back_from: version.rolledBackFrom, + note: version.note, + entry_counts: { + items: snapshot.items.length, + cartons: snapshot.cartons.length, + pallets: snapshot.pallets.length, + exclusions: snapshot.exclusions.length, + overrides: snapshot.overrides.length, + }, + item_ids: ids(snapshot.items), + carton_ids: ids(snapshot.cartons), + pallet_ids: ids(snapshot.pallets), + }; +} + +// ---------------------------------------------------------------------- canonical form + +/** The one byte-comparable spelling of a result document: sorted keys, no padding. */ +export function canonicalJson(result) { + const sort = (value) => { + if (Array.isArray(value)) return value.map(sort); + if (value === null || typeof value !== 'object') return value; + return Object.fromEntries( + Object.keys(value).sort(compareCodePoints).map((key) => [key, sort(value[key])]), + ); + }; + return JSON.stringify(sort(result)); +} 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/basic.mjs b/examples/basic.mjs new file mode 100644 index 0000000..3b3f6f9 --- /dev/null +++ b/examples/basic.mjs @@ -0,0 +1,67 @@ +/** + * Pack an order, read the placements, and see why anything was refused. + * + * Run it: + * + * node examples/basic.mjs + * + * `@packvium/engine` takes and returns the same JSON contract every Packvium + * implementation speaks, so a request you build here also works against the Python CLI, + * the PHP CLI or the Rust core, and comes back with the same answer. + * + * The package prefers the compiled N-API addon when `@packvium/native` is installed and + * falls back to a deterministic JavaScript engine otherwise. You do not choose, and you + * do not need to: `backend()` reports which one answered, and both answer the same. + */ + +import { backend, pack, version } from '../index.js'; + +console.log(`engine ${version()} using the ${backend()} backend\n`); + +const request = { + items: [ + // Lengths and weights are strings on purpose. They are parsed into exact integers, + // so '0.1' means a tenth of a millimetre and never 0.09999999999999999. Plain + // integers and fractions like '3/16' work too. + { id: 'mug', quantity: 6, dimensions: { length: '120', width: '120', height: '100' }, weight: '400 g' }, + { id: 'plate', quantity: 8, dimensions: { length: '260', width: '260', height: '20' }, weight: '600 g' }, + // Too long for the box in every orientation, so it cannot be placed. + { id: 'ladder', quantity: 1, dimensions: { length: '1800', width: '300', height: '100' }, weight: '6 kg' }, + ], + containers: [ + { + id: 'box', + inner_dimensions: { length: '400', width: '400', height: '400' }, + max_payload: '15 kg', + cost_minor: 180, + }, + ], +}; + +const result = pack(request); + +console.log(`status: ${result.status}`); +console.log(`containers opened: ${result.containers.length}`); + +for (const [index, container] of result.containers.entries()) { + console.log(`\nbox #${index + 1}: ${container.placements.length} placement(s), ` + + `${container.volume_utilization} of the volume used`); + for (const placement of container.placements) { + // Every measurement arrives as { ticks, value, unit }: `ticks` is the exact integer + // the engine reasoned about, `value` is that same number written for a human. + const { x, y, z } = placement.position; + console.log( + ` ${placement.item_type.padEnd(8)} at (${x.value}, ${y.value}, ${z.value}) ${x.unit}` + + ` orientation ${placement.orientation}`, + ); + } +} + +// A refusal is an answer, not an error. Each entry says which instance was refused and +// the structured reason, so you can act on it rather than re-guessing. +if (result.unpacked_items.length > 0) { + console.log('\nnot packed:'); + for (const unpacked of result.unpacked_items) { + console.log(` ${unpacked.item_id.padEnd(10)} ${unpacked.reason}`); + } +} diff --git a/examples/commerce.mjs b/examples/commerce.mjs new file mode 100644 index 0000000..3aaa73c --- /dev/null +++ b/examples/commerce.mjs @@ -0,0 +1,163 @@ +/** + * Quote a shipment, apply a policy rule, and inspect a catalog version. + * + * Run it: + * + * node examples/commerce.mjs + * + * 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. + * + * `commerce` picks the native addon when @packvium/native is installed and the + * deterministic JavaScript engine otherwise. Both return the same answer; + * `commerce.backend()` says which one answered. + */ + +import { commerce } from '../index.js'; + +// One document, three histories. You would normally load this from your own storage. +const 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', + }, + ], + }], +}; + +const show = (title, result) => { + console.log(`\n== ${title}`); + console.log(JSON.stringify(result, null, 2)); +}; + +console.log(`backend: ${commerce.backend()}`); + +// 1. Quote: what does this shipment cost? +const pinned = commerce.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: 6000000, + requested_accessorials: ['liftgate'], +}); +show('a quote pinned to tariff version 1', pinned); +console.log(` -> the caller pays ${pinned.quote.total_minor} minor units`); + +const effective = commerce.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: 6000000, + requested_accessorials: ['liftgate'], +}); +console.log(`\n as of instant 1500 the tariff is version ${effective.quote.tariff_version},` + + ` and the price is ${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. +show('a zone this tariff does not price', commerce.quote(document, { + carrier_id: 'acme', service_id: 'ground', tariff_version: 1, + zone: 'zone-nowhere', actual_weight_g: 1200, volume_mm3: 6000000, +})); + +// A *malformed* request is a different thing entirely: that is your bug, and it throws. +try { + commerce.quote(document, { + carrier_id: 'acme', service_id: 'ground', tariff_version: 1, + zone: 'zone-a', actual_weight_g: -1, volume_mm3: 6000000, + }); +} catch (error) { + console.log(`\n a negative weight is refused before anything is priced: ${error.message}`); +} + +// 2. Policy: may this shipment go at all? +show('a policy decision, with the rule that made it', commerce.evaluatePolicy(document, { + scope: 'hazmat', + context: { un_class: '1.4' }, + as_of: 0, +})); + +const allowed = commerce.evaluatePolicy(document, { + scope: 'hazmat', context: { un_class: '9' }, as_of: 0, +}); +console.log('\n nothing matched, so the shipment is allowed with no citation:' + + ` ${allowed.decision.citation}`); + +// 3. Catalog: which master data was this decision made against? +const catalog = commerce.catalogVersionInfo(document, { + catalog_id: 'dc-12', + version: 2, + resolved_at: 1700, +}); +show('catalog version metadata', catalog); +console.log(`\n version ${catalog.catalog.version} is a rollback of version` + + ` ${catalog.catalog.rolled_back_from}`); + +// Storing or comparing a result: use the canonical form, never JSON.stringify directly. +console.log('\n== the canonical form is what you store, log and compare'); +console.log(commerce.canonicalJson(pinned)); diff --git a/fallback.js b/fallback.js index 58967b3..198ebfc 100644 --- a/fallback.js +++ b/fallback.js @@ -72,6 +72,9 @@ function chargeMinor(table,grams){ // property of the request -- this depends on how the search happened to fill the box, // so it must lose a candidate rather than abort the run. const UNPRICEABLE=Number.MAX_SAFE_INTEGER; +// One wording for the refusal wherever it fires (outermost solve frame, rebalancing), +// so the four engines stay literally comparable. +const unpriceableRefusal=({id,grams,bound})=>new RangeError(`container ${JSON.stringify(id)} bills at ${grams} g, above its rate table's last bracket (${bound} g); the shipment has no published price`); const addLanded=(total,template,billedTicks)=>{ if(total===UNPRICEABLE)return total; const charge=template.rate==null?null:chargeMinor(template.rate,billedGrams(billedTicks)); @@ -523,10 +526,12 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver templates.push({...container,d:inner,outerD:outer,max:container.max_payload==null?null:scalar(container.max_payload,'g',WT), tare:scalar(container.tare_weight??0,'g',WT),rate:parseRateTable(container.rate_table)}) } - // `lowest_landed_cost` shares this key with `shipping_cost` deliberately: when a - // container is opened its final billed weight is not yet known, so the tariff has - // nothing to price. Billable weight is the monotone proxy the key already used, and - // the finished answer is priced exactly below. + // `lowest_landed_cost` never reaches this path: packFallback forces `compact=null` + // for that objective (see the exclusion beside the policy-rule gate), because this + // path commits to one container from the billed-weight proxy with no priced + // alternative to correct it. The branch below is kept only so the key stays whole for + // `shipping_cost`, whose proxy it is; re-enabling compact for landed cost would + // resurrect the MAX_SAFE_INTEGER leak, since this return path has no refusal. templates.sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost' ?dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d)) :(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))); @@ -663,6 +668,10 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver const inner=volume(template.d);if(inner>0n)scoreUnused+=Number((inner-used)*1000000n/inner); if(template.d[2]>0)scoreHeight+=Number(BigInt(layers*best.envelope[2])*1000000n/BigInt(template.d[2])); scoreAchievedHeight+=layers*best.envelope[2]; + // `lowest_landed_cost` cannot reach this path (packFallback excludes compact for + // it); if it ever could again, `addLanded`'s UNPRICEABLE sentinel would flow into + // `score` unrefused -- this return path never checks the finished answer against + // the tariff the way the outermost general-path frame does. if(objective==='shipping_cost'||objective==='lowest_landed_cost'){ const billed=Math.max(payload+template.tare,dimensionalWeight(template.outerD)); if(objective==='shipping_cost')scoreBillable+=billed;else scoreLanded=addLanded(scoreLanded,template,billed); @@ -774,6 +783,19 @@ const restartLimit=effort?.max_restarts??Number.MAX_SAFE_INTEGER; // a k-start request consume up to k*time_limit_ms while still reporting one portfolio // deadline, which is both a determinism and an observability defect. const deadline=sharedDeadline??new Deadline(req.configuration?.time_limit_ms??1000,clock); +// second review: the lowest_landed_cost refusal fires once, at the single +// outermost frame, on the packing actually selected for return -- the same choke point +// Rust, Python and PHP refuse at. A child solver/start run instead hands its result +// back sentinel and all, so a portfolio sibling with a priceable answer is not aborted +// by one run's refusal. Idempotent: the quality re-entry finalizes inside its callee. +const finalizeOutermost=result=>{ + if(solverAlias!==null||startIndex!==null)return result; + if(result.unpriceableDetail!=null)throw unpriceableRefusal(result.unpriceableDetail); + // Belt and braces behind the portfolio branch's own filter: a sentinel-scored run + // must never leave the outermost frame by any route. + if(result.alternatives?.length)result.alternatives=result.alternatives.filter(a=>!a.unpriceableDetail); + return result +}; if(solverAlias===null&&requestedSolvers.length===0&&(req.configuration?.solver_profile??'balanced')==='quality'){ const child={...req,configuration:{...(req.configuration??{}),solvers:['homogeneous_blocks','extreme_points','maximal_spaces','layer']}}; return packFallback(child,clock,null,null,deadline) @@ -803,8 +825,9 @@ if(solverAlias===null&&requestedSolvers.length){ winner.termination=aggregateTermination(starts); winner.algorithm=withPortfolioEffort(winner,runs); const alternativeLimit=Math.max(0,(req.configuration?.alternatives??3)-1); - winner.alternatives=runs.filter((_,index)=>index!==winnerIndex).sort((a,b)=>compareScore(a.score,b.score)).slice(0,alternativeLimit); - return winner; + // The sentinel is a search device, never an answer -- alternatives included ( review). + winner.alternatives=runs.filter((run,index)=>index!==winnerIndex&&!run.unpriceableDetail).sort((a,b)=>compareScore(a.score,b.score)).slice(0,alternativeLimit); + return finalizeOutermost(winner); } // This value used to be accepted and never read: raising it produced no extra // work and no extra start record, so a caller asking for eight restarts got one. Each @@ -829,7 +852,7 @@ if(startIndex===null&&multiStartOrders>1){ winner.termination=aggregateTermination(starts); winner.algorithm=withPortfolioEffort(winner,runs); if(winnerIndex>0)winner.algorithm={...winner.algorithm,solver:`${winner.algorithm.solver}:seeded_${winnerIndex}`}; - return winner; + return finalizeOutermost(winner); } const u=req.units?.length??'mm',ou=req.output?.length_unit??u,ow=req.output?.weight_unit??'g',clear=scalar(req.configuration?.clearance??0,u,LEN); const objective=req.configuration?.objective??'default';if(!['default','lowest_cost','shipping_cost','lowest_landed_cost','open_dimension_height','maximum_value'].includes(objective))throw new RangeError(`unknown objective ${JSON.stringify(objective)}`); @@ -1177,7 +1200,10 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ const weights=future.map(item=>profiles.get(item.id).weight).sort((a,b)=>a-b); const grossWeight=weights.slice(0,placeable).reduce((sum,value)=>sum+value,work.state.payload+tmpl.tare); const billable=objective==='shipping_cost'||objective==='lowest_landed_cost'?Math.max(grossWeight,dimensionalWeight(tmpl.outerD)):0; - const landed=objective==='lowest_landed_cost'?addLanded(0,tmpl,billable):0; + // A promotional bracket may be cheaper than a lighter bracket, so pricing the + // lightest possible completion is not an admissible lower bound. Tariff charges are + // non-negative; zero is the general money floor and only loosens this exact search. + const landed=0; const cost=tmpl.cost_minor??0; if(objective==='lowest_cost')return [unpackedFloor,cost,1,unused,height]; if(objective==='shipping_cost')return [unpackedFloor,billable,1,unused,height]; @@ -1315,7 +1341,21 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ const trial=packIntoTemplate(tmpl,remaining); if(!trial.state.placements.length)continue; let score,better; - if(solverAlias==='exact_small'){ + // `lowest_landed_cost` ranks the round in money: the trial's charge first, then + // estimated rounds remaining, then progress -- the key order Rust, Python and PHP + // use. `planScore`'s finished vector leads with unpacked count, which is + // right for whole plans but inverted for one round: an unpriceable-but-roomier + // trial out-ranked a priceable one on progress, refusing or over-paying requests + // the other three engines ship. The greedy loop commits the trial verbatim, so + // its billed weight is final here and the tariff can be read now; an unpriceable + // trial still sorts behind every priceable alternative via `addLanded`'s sentinel. + if(objective==='lowest_landed_cost'){ + const placed=trial.state.placements.length; + const billed=Math.max(trial.state.payload+tmpl.tare,dimensionalWeight(tmpl.outerD)); + score=[addLanded(0,tmpl,billed),Math.ceil(remaining.length/Math.max(placed,1)),-placed]; + const comparison=winnerScore==null?-1:compareScore(score,winnerScore); + better=comparison<0||(comparison===0&&tmpl.idsum+(i.value??0),0); const defaultScore=[unpacked.length,containers.length,scoreCost,scoreUnused,scoreHeight],score=objective==='lowest_cost'?[defaultScore[0],defaultScore[2],defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='shipping_cost'?[defaultScore[0],scoreBillable,defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='lowest_landed_cost'?[defaultScore[0],scoreLanded,defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='open_dimension_height'?[defaultScore[0],scoreAchievedHeight,defaultScore[1],defaultScore[2],defaultScore[3]]:objective==='maximum_value'?[defaultScore[0],scoreValueForgone,defaultScore[1],defaultScore[2],defaultScore[3]]:defaultScore; -return {status,feasibility:{code:complete?'feasible':'unknown'},termination,optimality:{code:complete?'not_proven':'best_found'},complete,objective,algorithm:{profile:req.configuration?.solver_profile??'balanced',solver:solverName,duration_ms:0,seed:req.configuration?.seed??42,time_limit_reached:timeLimitReached,effort_limit_reached:effortLimitReached,candidates_evaluated:metrics.feasible_candidates,placements_attempted:metrics.orientations_considered,metrics},summary:{container_count:containers.length,packed_item_count:items.length-unpacked.length,unpacked_item_count:unpacked.length},score,containers,unpacked_items:unpacked,catalog_versions_used:catalogVersionsUsed(req.catalog_versions_used),warnings:['JavaScript fallback is active; build the Rust addon for the native portfolio'],alternatives:[]}} +const result={status,feasibility:{code:complete?'feasible':'unknown'},termination,optimality:{code:complete?'not_proven':'best_found'},complete,objective,algorithm:{profile:req.configuration?.solver_profile??'balanced',solver:solverName,duration_ms:0,seed:req.configuration?.seed??42,time_limit_reached:timeLimitReached,effort_limit_reached:effortLimitReached,candidates_evaluated:metrics.feasible_candidates,placements_attempted:metrics.orientations_considered,metrics},summary:{container_count:containers.length,packed_item_count:items.length-unpacked.length,unpacked_item_count:unpacked.length},score,containers,unpacked_items:unpacked,catalog_versions_used:catalogVersionsUsed(req.catalog_versions_used),warnings:['JavaScript fallback is active; build the Rust addon for the native portfolio'],alternatives:[]}; +if(unpriceableDetail!=null)Object.defineProperty(result,'unpriceableDetail',{value:unpriceableDetail,enumerable:false,writable:false,configurable:true}); +return finalizeOutermost(result)} function resultTicks(value,name){ const ticks=value&&typeof value==='object'&&Number.isSafeInteger(value.ticks)?value.ticks:null; @@ -1519,8 +1580,39 @@ function publicRebalancedContainers(req,context){ */ export function rebalanceWeight(req,result,{maxMoves=64}={}){ if(!Number.isSafeInteger(maxMoves)||maxMoves<0)throw new RangeError('maxMoves must be a non-negative safe integer'); + const objective=req.configuration?.objective??'default',dimDivisor=req.configuration?.dimensional_weight_divisor??null; + if((objective==='shipping_cost'||objective==='lowest_landed_cost')&&dimDivisor==null)throw new RangeError(`the ${objective} objective requires configuration.dimensional_weight_divisor`); + if(objective==='lowest_landed_cost'){ + const unrated=(req.containers??[]).find(container=>container.rate_table==null); + if(unrated!=null)throw new RangeError(`the lowest_landed_cost objective requires a rate_table on every container; ${JSON.stringify(unrated.id)} has none`) + } const context=rebalanceContext(req,result),moves=[]; if(!rebalanceValid(context,result))throw new TypeError('result is not a valid packing of this request'); + // second review: under `lowest_landed_cost` a move is a re-pricing -- shifting + // payload can push a destination past its rate table's last bracket, leaving the + // "balanced" packing with no published price. States are priced with the same helpers + // the packer bills with: an unpriceable input is refused up front in the standard + // words, and a trial that turns any state unpriceable fails exactly like an invalid + // one. Gated on the objective and divisor so every other request is byte-identical. + let statesPriceable=null; + if(objective==='lowest_landed_cost'){ + const lengthUnit=req.configuration?.dimensional_weight_length_unit??'in',weightUnit=req.configuration?.dimensional_weight_weight_unit??'lb'; + const dimensionalTicks=d=>Number(volume(d)*BigInt(WT[weightUnit])/(BigInt(LEN[lengthUnit])**3n*BigInt(dimDivisor))); + // rebalanceContext keeps the raw rate_table; parse it once per container type with + // the packer's own parser so both entry points refuse the same malformed tariffs. + const pricing=new Map(); + const priceEntry=tmpl=>{ + let entry=pricing.get(tmpl.id); + if(entry===undefined){entry={rate:parseRateTable(tmpl.rate_table),dimTicks:dimensionalTicks(tmpl.outerD)};pricing.set(tmpl.id,entry)} + return entry}; + const unpriceableState=state=>{ + const entry=priceEntry(state.tmpl),payload=state.placements.reduce((total,placement)=>total+placement.item.w,0); + const grams=billedGrams(Math.max(payload+state.tmpl.tare,entry.dimTicks)); + if(entry.rate!=null&&chargeMinor(entry.rate,grams)!=null)return null; + return {id:state.tmpl.id,grams,bound:entry.rate==null?0:entry.rate.brackets[entry.rate.brackets.length-1]}}; + statesPriceable=()=>context.states.every(state=>unpriceableState(state)==null); + for(const state of context.states){const detail=unpriceableState(state);if(detail!=null)throw unpriceableRefusal(detail)} + } for(let moveNumber=0;moveNumberstate.placements.reduce((total,placement)=>total+placement.item.w,0)); @@ -1540,7 +1632,7 @@ export function rebalanceWeight(req,result,{maxMoves=64}={}){ const [relocated]=trial[sourceIndex].placements.splice(placementIndex,1); relocated.x=x;relocated.y=y;relocated.z=z;trial[destinationIndex].placements.push(relocated); const originalStates=context.states;context.states=trial; - if(rebalanceValid(context,result)){ + if(rebalanceValid(context,result)&&(statesPriceable==null||statesPriceable())){ committed={item_id:moving.item.id,from_container_id:originalStates[sourceIndex].publicContainer.id,to_container_id:originalStates[destinationIndex].publicContainer.id}; break search } diff --git a/index.d.ts b/index.d.ts index 7e9c8d7..9c16ed8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -51,3 +51,15 @@ export function packJson(input:string):string; export function rebalanceWeight(request:PackingRequest,result:PackingResult,options?:{maxMoves?:number}):RebalanceResult; export function backend():"rust"|"javascript"; export function version():string; +/** One canonical commerce result document: see docs/COMMERCE-API.md. */ +export type CommerceResult=Record; +export class CommerceInputError extends Error{readonly name:'CommerceInputError'} +export const commerce:{ + backend():"rust"|"javascript"; + readonly API_VERSION:number; + readonly REJECTION_CODES:readonly string[]; + canonicalJson(result:CommerceResult):string; + quote(document:unknown,request:unknown):CommerceResult; + evaluatePolicy(document:unknown,request:unknown):CommerceResult; + catalogVersionInfo(document:unknown,request:unknown):CommerceResult; +}; diff --git a/index.js b/index.js index aef0c0b..ab2b166 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,8 @@ export { explanationForUnpackedItem, explainUnpackedItem, } from './fallback.js'; export { UnsupportedFeatureError }; +import * as commerceFallback from './commerce.js'; +export { CommerceInputError } from './commerce.js'; const require=createRequire(import.meta.url); let native=null; for(const candidate of ['./packvium-native.node','@packvium/native']){try{native=require(candidate);break}catch{}} @@ -27,4 +29,42 @@ export function rebalanceWeight(request,result,{maxMoves=64}={}){ } return rebalanceFallback(request,result,{maxMoves}); } -export const version=()=>native?.version?.()??'0.1.0-js-fallback'; +export const version=()=>native?.version?.()??'0.1.1-js-fallback'; + +/** + * The exported commercial and control-plane API: a quote, a policy decision and catalog + * version metadata over one canonical JSON document (docs/COMMERCE-API.md). + * + * Native-first with a deterministic JavaScript fallback, the same backend selection the + * packing entry points use. The two agree on every shared fixture; `backend()` reports + * which one answered a `pack`, and `commerce.backend()` which one answers these. + */ +export const commerce = { + backend: () => (native?.commerceQuoteJson ? 'rust' : 'javascript'), + API_VERSION: commerceFallback.API_VERSION, + REJECTION_CODES: commerceFallback.REJECTION_CODES, + canonicalJson: commerceFallback.canonicalJson, + quote: (document, request) => + viaNative(native?.commerceQuoteJson, document, request) ?? commerceFallback.quote(document, request), + evaluatePolicy: (document, request) => + viaNative(native?.commerceEvaluatePolicyJson, document, request) + ?? commerceFallback.evaluatePolicy(document, request), + catalogVersionInfo: (document, request) => + viaNative(native?.commerceCatalogVersionInfoJson, document, request) + ?? commerceFallback.catalogVersionInfo(document, request), +}; + +/** + * Call one native commerce entry point, or report that there is none. + * + * A native input error is re-thrown as the same `CommerceInputError` the fallback + * raises, so a caller never has to know which backend answered to catch the failure. + */ +function viaNative(entry, document, request) { + if (!entry) return null; + try { + return JSON.parse(entry(JSON.stringify({ document, request }))); + } catch (error) { + throw new commerceFallback.CommerceInputError(error.message); + } +} diff --git a/package.json b/package.json index 67bed53..b08f791 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name":"@packvium/engine", - "version":"0.1.0", + "version":"0.1.1", "description":"Native-first 3D cartonization with deterministic JS fallback", "type":"module", "main":"index.js", "types":"index.d.ts", "exports":{".":{"types":"./index.d.ts","import":"./index.js"}}, - "files":["index.js","fallback.js","contact-graph.js","policy.js","index.d.ts","README.md"], + "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","examples","index.d.ts","README.md","SECURITY.md"], "engines":{"node":">=16"}, - "optionalDependencies":{"@packvium/native":"0.1.0"}, + "optionalDependencies":{"@packvium/native":"0.1.1"}, "scripts":{ "test":"node --test \"test/*.test.mjs\"", "test:legacy":"node test/legacy-conformance.mjs" diff --git a/policy.js b/policy.js new file mode 100644 index 0000000..703453d --- /dev/null +++ b/policy.js @@ -0,0 +1,226 @@ +/** + * Versioned eligibility rules, compiled into the checks the packer already runs. + * + * See docs/POLICY-RULES.md for the contract and the reasoning behind its shape. The + * short version: rules travel in the request as data because an engine is driven over + * JSON as a subprocess, so a rule registered inside one process has no wire + * representation and nothing can check that four engines agree about it. + * + * Deliberately not a predicate language. `eligible_container_tags`, `incompatible_tags` + * and `tag_limits` already express the predicates in every engine, so each rule form + * here compiles to a check the packer already performs. What a rule adds is only what + * tags cannot carry: identity, effective dating, priority, and the shipment-scoped facts + * a request had nowhere to put. + * + * This module is package-internal: package.json exports only the root entry point. + */ + +// The shipment-scoped facts a rule may select on. Properties of the shipment rather +// than of any item or container, which is why the request had nowhere to put them. +const SHIPMENT_FACTS = ['facility', 'customer', 'carrier', 'service']; + +// Wire name -> the keys the form requires, in the order they are read. Exactly one may +// appear on a rule: a rule naming two forms would have no single meaning for a citation. +const FORMS = { + separate_tags: ['tag', 'from_tag'], + require_container_tag: ['item_tag', 'container_tag'], + limit_tag_per_container: ['tag', 'max'], +}; + +const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); +const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); +// The detail strings are part of the cross-language contract, and every other engine +// renders a tag the way its own language quotes a short string literal. Single quotes +// are what those agree on; a tag is a request-supplied string, so it is escaped here +// rather than interpolated raw. +const quoted = tag => `'${tag.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + +/** + * A rule set this engine cannot honour exactly as written. + * + * Structured rather than skipped: a rule silently dropped for being malformed would let + * a request pack in a way its own policy forbids, which is the failure the whole + * contract exists to prevent. + */ +export class PolicyError extends Error { + constructor(message) { + super(message); + this.name = 'PolicyError'; + this.code = 'policy_error'; + } +} + +function integer(value, where, minimum) { + if (!Number.isSafeInteger(value) || value < minimum) { + throw new PolicyError(`${where} must be an integer >= ${minimum}`); + } + return value; +} + +/** + * Declared facts, or the absence of one. A fact nobody declared is not a wildcard: a + * rule naming it simply never participates, so an unstated facility cannot silently + * match a rule written for a specific one. + */ +function shipmentContext(raw, where) { + const context = {}; + for (const fact of SHIPMENT_FACTS) context[fact] = null; + if (raw == null) return context; + if (!isPlainObject(raw)) throw new PolicyError(`${where} must be an object`); + const unknown = Object.keys(raw).filter(key => !SHIPMENT_FACTS.includes(key)).sort(); + if (unknown.length) throw new PolicyError(`${where} names unknown shipment facts: ${unknown.join(', ')}`); + for (const [name, value] of Object.entries(raw)) { + if (typeof value !== 'string' || !value) throw new PolicyError(`${where}.${name} must be a non-empty string`); + context[name] = value; + } + return context; +} + +/** Whether every fact this selector names equals the shipment's own. */ +const satisfiedBy = (selector, shipment) => + SHIPMENT_FACTS.every(fact => selector[fact] === null || selector[fact] === shipment[fact]); + +function parseForm(name, raw, where) { + if (!isPlainObject(raw)) throw new PolicyError(`${where} must be an object`); + const keys = FORMS[name]; + const unknown = Object.keys(raw).filter(key => !keys.includes(key)).sort(); + if (unknown.length) throw new PolicyError(`${where} has unknown keys: ${unknown.join(', ')}`); + const missing = keys.filter(key => !hasOwn(raw, key)); + if (missing.length) throw new PolicyError(`${where} is missing ${missing.join(', ')}`); + const form = {kind: name}; + for (const key of keys) { + if (key === 'max') { form.max = integer(raw.max, `${where}.max`, 0); continue } + if (typeof raw[key] !== 'string' || !raw[key]) throw new PolicyError(`${where}.${key} must be a non-empty string`); + form[key] = raw[key]; + } + return form; +} + +function parseRule(raw, index) { + const where = `policy.rules[${index}]`; + if (!isPlainObject(raw)) throw new PolicyError(`${where} must be an object`); + const named = Object.keys(FORMS).filter(name => hasOwn(raw, name)); + if (named.length !== 1) { + throw new PolicyError( + `${where} must name exactly one rule form (${Object.keys(FORMS).sort().join(', ')}), not ${named.length}`); + } + if (typeof raw.id !== 'string' || !raw.id) throw new PolicyError(`${where}.id must be a non-empty string`); + const version = integer(raw.version, `${where}.version`, 1); + return { + id: raw.id, + version, + citation: `${raw.id}@${version}`, + effective_at: integer(raw.effective_at, `${where}.effective_at`, 0), + priority: integer(raw.priority, `${where}.priority`, 0), + applies_to: shipmentContext(raw.applies_to, `${where}.applies_to`), + form: parseForm(named[0], raw[named[0]], `${where}.${named[0]}`), + }; +} + +/** + * Resolution, fixed by the contract and identical in every engine — or the same request + * packs differently depending on which one answered it. + */ +function resolve(rules, asOf, shipment) { + const participating = rules.filter( + rule => rule.effective_at <= asOf && satisfiedBy(rule.applies_to, shipment)); + // Append-only per id: among participating versions of one id the highest + // `effective_at` wins, ties broken by the highest `version`. The same resolution the + // catalog registry already uses for `as_of` lookups, deliberately, so a reader learns + // one rule and not two. + const latest = new Map(); + for (const rule of participating) { + const current = latest.get(rule.id); + if (current === undefined || rule.effective_at > current.effective_at + || (rule.effective_at === current.effective_at && rule.version > current.version)) { + latest.set(rule.id, rule); + } + } + // Citation order, not evaluation order: the first rule that rejects a candidate is the + // one cited, so sorting here is what makes the citation deterministic. Ties go to the + // lexicographically smallest id -- never to insertion order, which would make the + // citation depend on the order the caller happened to write. + return [...latest.values()].sort((a, b) => b.priority - a.priority || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} + +/** The rules that participate in one request, already resolved and ordered. */ +export function parsePolicy(raw) { + if (raw == null) return []; + if (!isPlainObject(raw)) throw new PolicyError('policy must be an object'); + const unknown = Object.keys(raw).filter(key => !['as_of', 'shipment', 'rules'].includes(key)).sort(); + if (unknown.length) throw new PolicyError(`policy has unknown keys: ${unknown.join(', ')}`); + const declared = raw.rules ?? []; + if (!Array.isArray(declared)) throw new PolicyError('policy.rules must be an array'); + if (!declared.length) return []; + // No default: a guessed instant silently activates or hides a restriction, and reading + // a clock here would make one request pack differently on different days. + if (!hasOwn(raw, 'as_of')) throw new PolicyError('policy.as_of is required whenever policy.rules is non-empty'); + const asOf = integer(raw.as_of, 'policy.as_of', 0); + const shipment = shipmentContext(raw.shipment, 'policy.shipment'); + return resolve(declared.map(parseRule), asOf, shipment); +} + +/** + * Whether every rule permits `itemTags` in a container tagged `containerTags` that + * already holds `presentTags`, and the citation of the first that does not. + * + * `O(m + r)` for `m` placements already in the container and `r` resolved rules: one + * pass collecting the tags present, then one pass over the rules. The same bound class + * as the tag-count check it compiles onto, so the published complexity bounds are + * unchanged. Rules arrive in citation order, so the first rejection is already the one + * the contract says to cite. + */ +export function policyRejection(rules, itemTags, containerTags, presentTags) { + for (const rule of rules) { + const form = rule.form; + if (form.kind === 'require_container_tag') { + if (itemTags.includes(form.item_tag) && !containerTags.includes(form.container_tag)) { + return `${rule.citation}: requires a container tagged ${quoted(form.container_tag)}`; + } + } else if (form.kind === 'separate_tags') { + if (itemTags.includes(form.tag) && presentTags.get(form.from_tag)) { + return `${rule.citation}: ${quoted(form.tag)} may not share a container with ${quoted(form.from_tag)}`; + } + if (itemTags.includes(form.from_tag) && presentTags.get(form.tag)) { + return `${rule.citation}: ${quoted(form.from_tag)} may not share a container with ${quoted(form.tag)}`; + } + } else if (itemTags.includes(form.tag) && (presentTags.get(form.tag) ?? 0) >= form.max) { + return `${rule.citation}: at most ${form.max} item(s) tagged ${quoted(form.tag)} per container`; + } + } + return null; +} + +/** + * The rule that rules an item out of every offered container, if one does. + * + * Only `require_container_tag` can be answered here, and that is not a gap. It is a + * statement about the request alone -- this item carries the tag, no offered container + * carries the one it requires -- so it holds however the search goes. Segregation and + * per-container caps depend on what else was packed, so an item they leave behind was + * left behind by the search, and reporting that as proven would claim more than the + * engine knows. + * + * `O(r * c)` for `r` rules and `c` container templates, once per unpacked item rather + * than per candidate. + */ +export function provesUnplaceable(rules, itemTags, templates) { + for (const rule of rules) { + const form = rule.form; + if (form.kind !== 'require_container_tag' || !itemTags.includes(form.item_tag)) continue; + if (!templates.some(template => (template.tags ?? []).includes(form.container_tag))) { + return `${rule.citation}: requires a container tagged ${quoted(form.container_tag)}, ` + + 'which none of the containers offered carries'; + } + } + return null; +} + +/** Tag occurrence counts across placements, for the two forms that need them. */ +export function tagOccurrences(placements) { + const counts = new Map(); + for (const placement of placements) { + for (const tag of placement.item.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1); + } + return counts; +} diff --git a/test/commerce-edge-cases.test.mjs b/test/commerce-edge-cases.test.mjs new file mode 100644 index 0000000..2906a5a --- /dev/null +++ b/test/commerce-edge-cases.test.mjs @@ -0,0 +1,303 @@ +/** + * Every way to hand the JavaScript commerce API something it should refuse, plus the + * few legal inputs that look like they should be refused and are not. + * + * The shared fixtures in `commerce.test.mjs` prove this implementation agrees with the + * other three. This file covers what a shared fixture cannot reach from outside: + * JavaScript's own hazards. A `Number` cannot hold every exact integer the other three + * can; a string and an object are both iterable where a list belongs; `1` and `1.0` are + * the same value; and the default string sort is by UTF-16 code unit, not code point — + * each one a place where this port could silently answer differently from the others. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { compareCodePoints } from '../commerce-model.js'; +import { + CommerceInputError, + canonicalJson, + catalogVersionInfo, + evaluatePolicy, + loadDocument, + quote, +} from '../commerce.js'; + +const TARIFF = { + 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 }], +}; + +function tariffDocument(overrides = {}) { + return { + tariffs: [{ + carrier_id: 'acme', + service_id: 'ground', + versions: [{ ...TARIFF, ...overrides }], + }], + }; +} + +function catalogDocument(...versions) { + return { catalogs: [{ catalog_id: 'c', versions }] }; +} + +function policyDocument(overrides = {}) { + return { + policy_rules: [{ + rule_id: 'r', + versions: [{ + scope: 'hazmat', + action: 'reject', + priority: 1, + effective_at: 0, + predicates: [{ scope: 'hazmat', field: 'un_class', operator: 'equals', value: '1.4' }], + ...overrides, + }], + }], + }; +} + +function shipment(overrides = {}) { + const request = { + carrier_id: 'acme', + service_id: 'ground', + tariff_version: 1, + zone: 'zone-a', + actual_weight_g: 1200, + volume_mm3: 6000000, + ...overrides, + }; + return Object.fromEntries(Object.entries(request).filter(([, value]) => value !== null)); +} + +function refuses(run, fragment) { + assert.throws(run, (error) => { + assert.ok(error instanceof CommerceInputError, `expected a CommerceInputError, got ${error}`); + assert.match(error.message, fragment); + return true; + }); +} + +// ------------------------------------------------------------------- the number problem + +test('an integer beyond the exact range is refused, never silently rounded', () => { + const document = tariffDocument({ + dimensional_weight_divisor: 1, + cost_per_dimensional_kg_minor: { 'zone-a': 1_000_000 }, + minimum_charge_minor: 0, + fuel_surcharge_permille: 0, + accessorials: [], + }); + + // 10^15 grams at 10^6 minor units per kilogram is 10^18, well past 2^53. + refuses( + () => quote(document, shipment({ actual_weight_g: 0, volume_mm3: 10 ** 15 })), + /outside JavaScript's exact integer range/, + ); +}); + +test('the largest exactly representable quote is still answered', () => { + const document = tariffDocument({ + dimensional_weight_divisor: 1, + cost_per_dimensional_kg_minor: { 'zone-a': 1000 }, + minimum_charge_minor: 0, + fuel_surcharge_permille: 0, + accessorials: [], + }); + + const result = quote(document, shipment({ actual_weight_g: 0, volume_mm3: 9_007_199_254_740 })); + + assert.equal(result.quote.total_minor, 9_007_199_254_740); + assert.ok(Number.isSafeInteger(result.quote.total_minor)); +}); + +// --------------------------------------------------------------------------- code points + +test('code-point ordering matches the other implementations, not UTF-16 order', () => { + const ids = ['z', '\u{1F600}', 'A']; + + assert.deepEqual([...ids].sort(compareCodePoints), ['z', 'A', '\u{1F600}']); + assert.notDeepEqual([...ids].sort(), [...ids].sort(compareCodePoints)); +}); + +test('code-point ordering falls back to length for a shared prefix', () => { + assert.equal(compareCodePoints('ab', 'ab'), 0); + assert.equal(compareCodePoints('ab', 'abc'), -1); + assert.equal(compareCodePoints('abc', 'ab'), 1); +}); + +test('catalog id lists come back in code-point order', () => { + const entry = (id) => ({ id, dimensions_mm: [1, 1, 1], weight_g: 1 }); + const document = catalogDocument({ + effective_at: 0, + published_at: 0, + snapshot: { items: [entry('\u{1F600}'), entry('A'), entry('z')] }, + }); + + const result = catalogVersionInfo(document, { catalog_id: 'c', resolved_at: 1 }); + + assert.deepEqual(result.catalog.item_ids, ['z', 'A', '\u{1F600}']); +}); + +test('a pinned policy snapshot is ordered by code point too', () => { + const rule = (id) => ({ + rule_id: id, + versions: [{ + scope: 'carrier', action: 'reject', priority: 1, effective_at: 0, reason: id, + predicates: [{ scope: 'carrier', field: 'f', operator: 'exists' }], + }], + }); + const document = { policy_rules: [rule('\u{1F600}'), rule('A')] }; + const request = { + scope: 'carrier', + context: { f: 1 }, + rule_versions: [['A', 1], ['\u{1F600}', 1]], + }; + + const forward = evaluatePolicy(document, request); + const reversed = evaluatePolicy(document, { + ...request, rule_versions: [...request.rule_versions].reverse(), + }); + + assert.equal(canonicalJson(forward), canonicalJson(reversed)); + assert.equal(forward.decision.citation.rule_id, 'A'); +}); + +// --------------------------------------------------------------------- malformed documents + +test('a document that is not an object is refused', () => { + for (const document of [null, [], 'x', 7, true]) { + refuses(() => loadDocument(document), /document: expected an object/); + } +}); + +test('a nested value that is not an object is refused', () => { + refuses( + () => loadDocument({ tariffs: [{ carrier_id: 'a', service_id: 'g', versions: ['nope'] }] }), + /expected an object/, + ); +}); + +test('a history with no versions is refused', () => { + for (const document of [ + { tariffs: [{ carrier_id: 'a', service_id: 'g', versions: [] }] }, + { policy_rules: [{ rule_id: 'r', versions: [] }] }, + { catalogs: [{ catalog_id: 'c', versions: [] }] }, + ]) { + refuses(() => loadDocument(document), /at least one version/); + } +}); + +test('an accessorial must set exactly one kind of charge', () => { + for (const accessorial of [ + { accessorial_id: 'x' }, + { accessorial_id: 'x', flat_charge_minor: 1, permille_of_base: 1 }, + ]) { + refuses(() => loadDocument(tariffDocument({ accessorials: [accessorial] })), /exactly one/); + } +}); + +test('a binary predicate without a value is refused', () => { + refuses( + () => loadDocument(policyDocument({ + predicates: [{ scope: 'hazmat', field: 'f', operator: 'equals' }], + })), + /requires a value/, + ); +}); + +test('an exclusion rule needs both ends', () => { + refuses( + () => loadDocument(catalogDocument({ + effective_at: 0, + published_at: 0, + snapshot: { + exclusions: [{ id: 'x', scope: 'item_carton', subject_id: 'a', excluded_id: '' }], + }, + })), + /must reference both/, + ); +}); + +test('a duplicate id inside one snapshot is refused', () => { + const entry = { id: 'same', dimensions_mm: [1, 1, 1], weight_g: 1 }; + + refuses( + () => loadDocument(catalogDocument({ + effective_at: 0, published_at: 0, snapshot: { items: [entry, entry] }, + })), + /duplicate item ids/, + ); +}); + +// ---------------------------------------------------------------------- malformed requests + +test('an as_of quote against a carrier with no history is not found', () => { + const result = quote(tariffDocument(), shipment({ + carrier_id: 'ghost', tariff_version: null, as_of: 0, + })); + + assert.deepEqual(result.error, { + code: 'tariff_not_found', fields: { carrier_id: 'ghost', service_id: 'ground' }, + }); +}); + +test('an empty accessorial id is refused', () => { + refuses( + () => quote(tariffDocument(), shipment({ requested_accessorials: [''] })), + /non-empty/, + ); +}); + +test('a catalog request may pin a version or an instant, never both', () => { + const document = catalogDocument({ effective_at: 0, published_at: 0, snapshot: {} }); + + refuses( + () => catalogVersionInfo(document, { + catalog_id: 'c', resolved_at: 1, version: 1, as_of: 1, + }), + /at most one/, + ); +}); + +// ------------------------------------------------------------ legal but easily mishandled + +test('a value of a type the predicate does not compare simply does not match', () => { + const document = policyDocument(); + const result = evaluatePolicy(document, { + scope: 'hazmat', context: { un_class: { nested: true } }, as_of: 0, + }); + + assert.equal(result.decision.allowed, true); +}); + +test('an in predicate over a non-list, non-string value matches nothing', () => { + const document = policyDocument({ + predicates: [{ scope: 'hazmat', field: 'f', operator: 'in', value: 7 }], + }); + + const result = evaluatePolicy(document, { scope: 'hazmat', context: { f: 7 }, as_of: 0 }); + + assert.equal(result.decision.allowed, true); +}); + +test('an explicitly null optional field means the same as an omitted one', () => { + const document = tariffDocument({ + minimum_charge_minor: null, fuel_surcharge_permille: null, accessorials: null, + }); + + const result = quote(document, shipment()); + + assert.equal(result.quote.minimum_charge_applied, false); + assert.equal(result.quote.fuel_surcharge_minor, 0); + assert.deepEqual(result.quote.accessorial_charges_minor, []); +}); + +test('canonical output leaves non-ASCII and slashes unescaped', () => { + assert.equal(canonicalJson({ note: 'zóna/1 🙂' }), '{"note":"zóna/1 🙂"}'); +}); diff --git a/test/commerce-native-dispatch.test.mjs b/test/commerce-native-dispatch.test.mjs new file mode 100644 index 0000000..56f26c8 --- /dev/null +++ b/test/commerce-native-dispatch.test.mjs @@ -0,0 +1,157 @@ +/** + * The native commerce dispatch inside `index.js`. + * + * `commerce.test.mjs` proves the real Rust addon and the JavaScript fallback agree, but + * it loads the addon itself, by its in-workspace build path. The package's own probe + * list is `['./packvium-native.node', '@packvium/native']`, and neither resolves in this + * workspace -- so `viaNative`, the branch every installed user with the addon takes, was + * never executed by any test or measured by any coverage run. + * + * This supplies a stub at the specifier the package actually probes. `index.js` captures + * the module object once and reads each entry point off it per call, so one import is + * enough: every case below reshapes the same stub rather than reloading the package. + * That matters for more than tidiness -- importing `index.js` twice under different URLs + * puts two records for one file into the coverage report, which the coverage gate + * refuses as untrustworthy. + * + * No file is written, and the interception is removed as soon as the import completes, + * so nothing here can leak into another suite -- or, worse, into a published package + * that then believes it has a native backend. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import Module from 'node:module'; + +/** Reshaped per case; `index.js` reads its entry points at call time, not at load time. */ +const stub = {}; + +const previousLoad = Module._load; +Module._load = function loadWithStubbedNativeBackend(request, parent, isMain) { + if (request === '@packvium/native') return stub; + return previousLoad.call(this, request, parent, isMain); +}; +const engine = await import('../index.js'); +Module._load = previousLoad; + +const DOCUMENT = { tariffs: [] }; +const REQUEST = { marker: 'probe' }; + +/** @param {Record} entries what this case's addon exports */ +function addon(entries) { + for (const key of Object.keys(stub)) delete stub[key]; + Object.assign(stub, entries); +} + +test('each commerce function reaches its own native entry point', () => { + const seen = {}; + const entry = (name) => (json) => { + seen[name] = JSON.parse(json); + return JSON.stringify({ entry: name }); + }; + addon({ + commerceQuoteJson: entry('commerceQuoteJson'), + commerceEvaluatePolicyJson: entry('commerceEvaluatePolicyJson'), + commerceCatalogVersionInfoJson: entry('commerceCatalogVersionInfoJson'), + }); + + assert.equal(engine.commerce.backend(), 'rust', 'a loadable addon must be reported as the backend'); + + for (const [method, name] of [ + ['quote', 'commerceQuoteJson'], + ['evaluatePolicy', 'commerceEvaluatePolicyJson'], + ['catalogVersionInfo', 'commerceCatalogVersionInfoJson'], + ]) { + assert.deepEqual(engine.commerce[method](DOCUMENT, REQUEST), { entry: name }); + // One `{document, request}` envelope, not two arguments and not a flattened object. + assert.deepEqual(seen[name], { document: DOCUMENT, request: REQUEST }); + } +}); + +test('a native failure surfaces as the same error the fallback raises', () => { + addon({ commerceQuoteJson: () => { throw new Error('request.zone: expected a string'); } }); + + assert.throws( + () => engine.commerce.quote(DOCUMENT, REQUEST), + (error) => error instanceof engine.CommerceInputError + && error.message === 'request.zone: expected a string', + 'a caller must not have to know which backend answered in order to catch the failure', + ); +}); + +test('an addon that answers with something other than JSON is not silently accepted', () => { + addon({ commerceQuoteJson: () => '{ this is not JSON' }); + + assert.throws(() => engine.commerce.quote(DOCUMENT, REQUEST), engine.CommerceInputError); +}); + +test('an addon carrying only some of the three functions falls back for the rest', () => { + addon({ commerceQuoteJson: () => JSON.stringify({ entry: 'native' }) }); + + assert.deepEqual(engine.commerce.quote(DOCUMENT, REQUEST), { entry: 'native' }); + + // No native `commerceEvaluatePolicyJson`, so this must reach the JavaScript fallback + // and be refused there for the same reason the fallback always refuses it. + assert.throws( + () => engine.commerce.evaluatePolicy(DOCUMENT, REQUEST), + engine.CommerceInputError, + 'a partial addon must not make an operation disappear', + ); +}); + +test('an addon with no commerce surface at all leaves the package on the fallback', () => { + addon({ packJson: (input) => input }); + + assert.equal(engine.commerce.backend(), 'javascript'); + assert.equal(engine.backend(), 'rust', 'packing still uses the addon it does carry'); +}); + +/** + * `rebalanceWeight` is the one other native dispatch on this entry point, and it was + * unreachable for the same reason. Covered here rather than in its own file because the + * stub is what makes it reachable, and a second module instance is what the coverage + * gate refuses. + */ +test('rebalanceWeight routes to the addon with its arguments already serialised', () => { + let seen = null; + addon({ + rebalanceJson: (request, result, maxMoves) => { + seen = { request, result, maxMoves }; + return JSON.stringify({ moves: [] }); + }, + }); + + const answer = engine.rebalanceWeight({ id: 'r' }, { id: 's' }, { maxMoves: 3 }); + + assert.deepEqual(answer, { moves: [] }); + assert.deepEqual(seen, { request: '{"id":"r"}', result: '{"id":"s"}', maxMoves: 3 }); +}); + +test('rebalanceWeight falls back when the addon does not carry it', () => { + addon({}); + const side = { length: '100', width: '100', height: '100' }; + const request = { + units: { length: 'mm' }, + items: [{ id: 'a', dimensions: side }, { id: 'b', dimensions: side }], + containers: [{ id: 'box', inner_dimensions: { length: '300', width: '100', height: '100' } }], + }; + + const balanced = engine.rebalanceWeight(request, engine.pack(request), { maxMoves: 0 }); + + assert.deepEqual(balanced.moves, [], 'a zero-move budget can only produce no moves'); + assert.equal(balanced.containers.length, 1); +}); + +test('an out-of-range maxMoves is refused before either backend is consulted', () => { + let called = false; + addon({ rebalanceJson: () => { called = true; return '{}'; } }); + + for (const maxMoves of [-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws( + () => engine.rebalanceWeight({}, {}, { maxMoves }), + RangeError, + `expected ${maxMoves} to be refused`, + ); + } + assert.equal(called, false, 'the guard must run before the addon is called'); +}); diff --git a/test/commerce.test.mjs b/test/commerce.test.mjs new file mode 100644 index 0000000..81b4f36 --- /dev/null +++ b/test/commerce.test.mjs @@ -0,0 +1,202 @@ +/** + * The exported commercial and control-plane API. + * + * JavaScript is an independent implementation of the contract, so it is held to + * producing a *valid* result that meets each shared fixture's objective floor. For a + * quote that floor is an exact integer price, so "no worse than the floor" and "equal + * to it" coincide: the fixture half of this suite compares against the committed golden + * documents, and the rejection half checks that every documented code is reachable and + * carries only structured fields. + * + * The shared fixtures live in the surrounding workspace; a published copy of this + * package does not carry them, so that half skips rather than fails when they are gone. + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +import { commerce } from '../index.js'; +import { CommerceInputError, canonicalJson, quote } from '../commerce.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SHARED = path.resolve(HERE, '../../../../conformance/commerce'); +const hasFixtures = fs.existsSync(path.join(SHARED, 'fixtures')); + +const cases = hasFixtures + ? fs.readdirSync(path.join(SHARED, 'fixtures')) + .filter((name) => name.endsWith('.json')) + .sort() + .map((name) => ({ + name: name.replace(/\.json$/, ''), + expects: 'result', + ...JSON.parse(fs.readFileSync(path.join(SHARED, 'fixtures', name), 'utf8')), + })) + : []; + +const answered = cases.filter((testCase) => testCase.expects === 'result'); +const malformed = cases.filter((testCase) => testCase.expects === 'input_error'); + +const OPERATIONS = { + quote: commerce.quote, + evaluate_policy: commerce.evaluatePolicy, + catalog_version_info: commerce.catalogVersionInfo, +}; + +function run(testCase) { + return OPERATIONS[testCase.operation](testCase.document, testCase.request); +} + +const MINIMAL_DOCUMENT = { + tariffs: [{ + carrier_id: 'acme', + service_id: 'ground', + versions: [{ + 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 }], + }], + }], +}; + +function shipment(overrides = {}) { + const request = { + carrier_id: 'acme', + service_id: 'ground', + tariff_version: 1, + zone: 'zone-a', + actual_weight_g: 1200, + volume_mm3: 6000000, + ...overrides, + }; + return Object.fromEntries(Object.entries(request).filter(([, value]) => value !== null)); +} + +if (!hasFixtures) { + test('the shared commerce fixtures are not part of this package', { skip: true }, () => {}); +} + +for (const testCase of answered) { + test(`${testCase.name} matches the golden document`, () => { + const golden = fs.readFileSync(path.join(SHARED, 'golden', `${testCase.name}.json`), 'utf8'); + + assert.equal(canonicalJson(run(testCase)), golden.trim(), testCase.description); + }); +} + +for (const testCase of malformed) { + test(`${testCase.name} is refused rather than answered`, () => { + assert.throws(() => run(testCase), CommerceInputError, testCase.description); + }); +} + +if (hasFixtures) { + test('the fixture set still covers every documented rejection code', () => { + const produced = answered + .map(run) + .filter((result) => result.status === 'rejected') + .map((result) => result.error.code); + + assert.deepEqual([...new Set(produced)].sort(), [...commerce.REJECTION_CODES].sort()); + }); + + test('the native backend agrees with the fallback wherever both can answer', (t) => { + const require = createRequire(import.meta.url); + let native = null; + // The same specifiers `force-fallback.cjs` blocks, so a coverage run pinned to the + // fallback cannot accidentally load -- and measure -- the native addon here. + for (const candidate of ['./packvium-native.node', '@packvium/native', + '../../packvium-rust/bindings/node']) { + try { native = require(candidate); break; } catch { /* not installed or blocked */ } + } + if (!native?.commerceQuoteJson) { + t.skip('no native addon exporting the commerce surface is loadable here'); + return; + } + const entries = { + quote: native.commerceQuoteJson, + evaluate_policy: native.commerceEvaluatePolicyJson, + catalog_version_info: native.commerceCatalogVersionInfoJson, + }; + for (const testCase of answered) { + const call = JSON.stringify({ document: testCase.document, request: testCase.request }); + // Compared as canonical text rather than as objects, so neither key order nor a + // deep-equality coercion can hide a difference between the two backends. + assert.equal( + canonicalJson(JSON.parse(entries[testCase.operation](call))), + canonicalJson(run(testCase)), + `${testCase.name}: the native and fallback backends disagree`, + ); + } + }); +} + +test('a malformed request is a caller error, not a rejection', () => { + for (const overrides of [ + { zone: 7 }, + { actual_weight_g: -1 }, + { actual_weight_g: true }, + { requested_accessorials: ['liftgate', 'liftgate'] }, + { discount_code: 'FREE' }, + { tariff_version: null }, + { as_of: 0 }, + ]) { + assert.throws(() => quote(MINIMAL_DOCUMENT, shipment(overrides)), CommerceInputError, + `expected ${JSON.stringify(overrides)} to be refused`); + } +}); + +test('an unpriceable zone is an answer, not an exception', () => { + const result = quote(MINIMAL_DOCUMENT, shipment({ zone: 'zone-nowhere' })); + + assert.equal(result.status, 'rejected'); + assert.deepEqual(result.error, { + code: 'unavailable_zone', + fields: { + carrier_id: 'acme', service_id: 'ground', tariff_version: 1, zone: 'zone-nowhere', + }, + }); +}); + +test('a permille accessorial and a minimum charge both round up', () => { + const document = { + tariffs: [{ + carrier_id: 'acme', + service_id: 'ground', + versions: [{ + effective_at: 0, + dimensional_weight_divisor: 5000, + cost_per_dimensional_kg_minor: { 'zone-a': 1 }, + minimum_charge_minor: 7, + fuel_surcharge_permille: 1, + accessorials: [{ accessorial_id: 'residential', permille_of_base: 1 }], + }], + }], + }; + + const result = quote(document, shipment({ + actual_weight_g: 1, volume_mm3: 1, requested_accessorials: ['residential'], + })); + + assert.equal(result.quote.minimum_charge_applied, true); + assert.equal(result.quote.base_charge_minor, 7); + assert.equal(result.quote.fuel_surcharge_minor, 1, 'ceil(7 * 1 / 1000) is 1, never 0'); + assert.deepEqual(result.quote.accessorial_charges_minor, [['residential', 1]]); + assert.equal(result.quote.total_minor, 9); +}); + +test('canonical output is independent of input key order', () => { + const forward = shipment(); + const reversed = Object.fromEntries(Object.entries(forward).reverse()); + + assert.equal( + canonicalJson(quote(MINIMAL_DOCUMENT, forward)), + canonicalJson(quote(MINIMAL_DOCUMENT, reversed)), + ); +}); diff --git a/test/fallback.test.mjs b/test/fallback.test.mjs index bb58ee2..4065c80 100644 --- a/test/fallback.test.mjs +++ b/test/fallback.test.mjs @@ -1142,11 +1142,124 @@ test('a bracket step makes two different billed weights cost exactly the same', assert.notDeepEqual(billable[0], billable[1]); }); -test('a weight the tariff does not price ranks worst, never free', () => { +test('a weight the tariff does not price is never reported as an answer', () => { // Billed 1000 g against a ladder that stops at 500 g. Scoring an unpriceable container - // as 0 would make this objective actively prefer the packing the caller cannot ship. - const result = packFallback(landed({ weight_brackets_g: [500], prices_minor: [700] }, '200 g')); - assert.equal(result.score[1], Number.MAX_SAFE_INTEGER); + // as 0 would make this objective actively prefer the packing the caller cannot ship, + // so it still ranks worst *during* search -- that is what lets a priceable container + // win a round. This asks the other half: with no priceable alternative on offer, the + // sentinel must not surface. It used to -- the run returned `feasible` with a + // landed cost of MAX_SAFE_INTEGER, quoting a price the carrier never published. + assert.throws( + () => packFallback(landed({ weight_brackets_g: [500], prices_minor: [700] }, '200 g')), + /container "c" bills at 1000 g, above its rate table's last bracket \(500 g\); the shipment has no published price/, + ); +}); + +test('an unpriceable container loses to a priceable one in the general greedy path', () => { + // Eight units is past the single-item shape the compact path takes, and `try_grid` + // stands down for this objective anyway, so this lands in the general per-round key. + // That key was `[-placed, cost_minor, unused]` -- it never looked at the objective at + // all -- so it chose the snuggest box, and the snuggest box here is the one whose + // tariff runs out at 2000 g while it bills at 5400 g. The looser container prices the + // same load at 1500. The money-first round key prices each trial instead; + // Rust reaches the identical answer through `container_selection_key`. + const req = request( + [cube('box', 100, { weight: '500 g', quantity: 8 })], + [ + box('alpha_unpriceable', 300, 300, 300, { rate_table: { weight_brackets_g: [2000], prices_minor: [900] } }), + box('beta_priceable', 400, 400, 400, { rate_table: { weight_brackets_g: [20000], prices_minor: [1500] } }), + ], + { + configuration: { + objective: 'lowest_landed_cost', + dimensional_weight_divisor: 5000, + dimensional_weight_length_unit: 'cm', + dimensional_weight_weight_unit: 'kg', + }, + }, + ); + const result = packFallback(req); + assert.equal(result.containers.length, 1); + assert.equal(result.containers[0].container_type, 'beta_priceable'); + assert.equal(result.score[1], 1500); + assert.equal(result.unpacked_items.length, 0); +}); + +test('a bracket step makes the cheaper shipment the heavier one', () => { + // Ranking by billed weight and ranking by money agree only while price rises smoothly + // with weight. `heavy_but_cheap` bills at 12800 g for 400; `light_but_dear` bills at + // 5400 g -- less than half -- for 900. The objective is named lowest_landed_*cost*. + const req = request( + [cube('box', 100, { weight: '500 g', quantity: 8 })], + [ + box('light_but_dear', 300, 300, 300, { rate_table: { weight_brackets_g: [20000], prices_minor: [900] } }), + box('heavy_but_cheap', 400, 400, 400, { rate_table: { weight_brackets_g: [20000], prices_minor: [400] } }), + ], + { + configuration: { + objective: 'lowest_landed_cost', + dimensional_weight_divisor: 5000, + dimensional_weight_length_unit: 'cm', + dimensional_weight_weight_unit: 'kg', + }, + }, + ); + const result = packFallback(req); + assert.equal(result.containers[0].container_type, 'heavy_but_cheap'); + assert.equal(result.score[1], 400); +}); + +test('an unpriceable trial cannot win the round on placing more items', () => { + // The snug box holds both bricks but bills 2000 g against a ladder that stops at + // 1500 g; the per-unit box ships one brick at 100. A round key that ranked progress + // first committed the snug box and refused this request, while Rust, Python and PHP + // ship it in two per-unit boxes at 200 ( second review). + const req = request( + [cube('brick', 100, { weight: '1000 g', quantity: 2 })], + [ + box('snug_unpriceable', 200, 100, 100, { rate_table: { weight_brackets_g: [1500], prices_minor: [900] } }), + box('unit_priced', 100, 100, 100, { rate_table: { weight_brackets_g: [1500], prices_minor: [100] } }), + ], + { + configuration: { + objective: 'lowest_landed_cost', + dimensional_weight_divisor: 5000, + dimensional_weight_length_unit: 'cm', + dimensional_weight_weight_unit: 'kg', + }, + }, + ); + const result = packFallback(req); + assert.deepEqual(result.containers.map((c) => c.container_type), ['unit_priced', 'unit_priced']); + assert.equal(result.score[1], 200); + assert.equal(result.unpacked_items.length, 0); +}); + +test('the round key ranks money ahead of progress, matching the other engines', () => { + // Both containers are priceable: the snug box takes both bricks in one round for + // 5000, the per-unit box takes one brick for 100. Ranking progress first paid the + // 5000 -- 25x the answer Rust, Python and PHP return -- and the engines split + // silently, because the corpus fixtures tie the placed counts. Money first, two + // per-unit rounds at 200 win; the finished score agrees this is the better packing. + const req = request( + [cube('brick', 100, { weight: '1000 g', quantity: 2 })], + [ + box('snug_dear', 200, 100, 100, { rate_table: { weight_brackets_g: [20000], prices_minor: [5000] } }), + box('unit_cheap', 100, 100, 100, { rate_table: { weight_brackets_g: [1500], prices_minor: [100] } }), + ], + { + configuration: { + objective: 'lowest_landed_cost', + dimensional_weight_divisor: 5000, + dimensional_weight_length_unit: 'cm', + dimensional_weight_weight_unit: 'kg', + }, + }, + ); + const result = packFallback(req); + assert.deepEqual(result.containers.map((c) => c.container_type), ['unit_cheap', 'unit_cheap']); + assert.equal(result.score[1], 200); + assert.equal(result.unpacked_items.length, 0); }); test('the compact lattice path no longer commits to an unpriceable container (quality profile)', () => { @@ -1158,10 +1271,10 @@ test('the compact lattice path no longer commits to an unpriceable container (qu // `balanced` general search, prices every candidate container exactly rather than by // proxy, so it is the one shape that already gets this right; this pins that it stays // right now that the compact path is excluded rather than silently overriding it. - // tracks the residual gap: the default `balanced` profile's own general search - // uses the same proxy as the excluded compact path and can still choose wrong on a - // larger request, which needs the quality search's exact per-candidate pricing wired - // into the default path rather than a fast-path exclusion to close. + // The residual gap tracked -- the default `balanced` profile's general search + // sharing the excluded fast path's proxy -- is closed by the per-round key pricing + // each trial; `an unpriceable container loses to a priceable one in the general + // greedy path` above is the case that used to fail. const req = request( [cube('dense', 100, { weight: '500 g' })], [ @@ -1201,6 +1314,226 @@ test('lowest_landed_cost refuses a request it cannot price', () => { )), /requires configuration\.dimensional_weight_divisor/); }); +// One pinned solver crams everything into the snug box, the other splits the load and +// prices it. `extreme_points` (volume-descending) seats the lid first and stacks every +// brick on it, so both boxes take all nine items in one round; both bill 3300 g, the +// tie falls to the snug box, and its ladder stops at 2000 g -- unpriceable. `layer` +// (height-descending) floors the bricks first, which walls the lid out of the spot the +// other order used, so it ships bricks in the snug box (1350 g billed, 900) and the lid +// in the roomy one (2500 g billed, 1500). +const splitPortfolio = (betaBracketG, extra = {}) => request( + [ + { id: 'lid', dimensions: mm(300, 300, 100), weight: '2500 g' }, + { id: 'brick', dimensions: mm(100, 100, 150), weight: '100 g', quantity: 8 }, + ], + [ + box('alpha_snug', 300, 300, 300, { rate_table: { weight_brackets_g: [2000], prices_minor: [900] } }), + box('beta_room', 400, 400, 250, { rate_table: { weight_brackets_g: [betaBracketG], prices_minor: [1500] } }), + ], + { + configuration: { + objective: 'lowest_landed_cost', + dimensional_weight_divisor: 20000, + dimensional_weight_length_unit: 'cm', + dimensional_weight_weight_unit: 'kg', + minimum_support_ratio: 1, + solvers: ['extreme_points', 'layer'], + alternatives: 3, + ...extra, + }, + }, +); + +test('a portfolio returns the priceable sibling instead of propagating a child run refusal', () => { + // The engine used to throw the no-published-price refusal inside the extreme_points + // child run, aborting the request its layer sibling could price at 2400. The refusal + // now fires once, at the outermost frame, on the packing actually selected for + // return -- the choke point Rust, Python and PHP already refuse at ( second + // review) -- so the priceable sibling wins on the ordinary score comparison. + const result = packFallback(splitPortfolio(2600)); + assert.equal(result.status, 'feasible'); + assert.equal(result.score[1], 2400); + assert.deepEqual(result.containers.map((container) => container.container_type), ['alpha_snug', 'beta_room']); + assert.equal(result.unpacked_items.length, 0); + assert.ok(!('unpriceableDetail' in result)); +}); + +test('alternatives never surface the unpriceable sentinel', () => { + // The losing extreme_points run carries score[1] = MAX_SAFE_INTEGER. The sentinel is + // a search device, never an answer -- alternatives included ( review): the run + // is filtered out rather than offered as a packing costing 2^53-1 minor units. + const filtered = packFallback(splitPortfolio(2600)); + assert.equal(filtered.alternatives.length, 0); + assert.ok(!JSON.stringify(filtered).includes('9007199254740991')); + // With the roomy box's ladder raised both runs price, the winner flips to the + // single-container packing, and the sibling is reported: the filter removes + // sentinels, not siblings. + const populated = packFallback(splitPortfolio(20000)); + assert.equal(populated.score[1], 1500); + assert.equal(populated.alternatives.length, 1); + assert.equal(populated.alternatives[0].score[1], 2400); + assert.ok(populated.alternatives.every((alternative) => !('unpriceableDetail' in alternative))); + assert.ok(!JSON.stringify(populated).includes('9007199254740991')); +}); + +test('a child run hands its unpriceable packing to the portfolio instead of throwing', () => { + // Billed 1000 g against a ladder that stops at 500 g, with no sibling to win. The + // refusal is the outermost frame's job: a solver child and a seeded-start child must + // both return the sentinel-scored result, because throwing there is what aborted + // portfolios whose other runs had a priceable answer. The detail steering the + // outermost frame is non-enumerable and never serializes. + for (const [solverAlias, startIndex] of [['extreme_points', null], [null, 1]]) { + const run = packFallback( + landed({ weight_brackets_g: [500], prices_minor: [700] }, '200 g'), + Date.now, solverAlias, startIndex, + ); + assert.equal(run.score[1], Number.MAX_SAFE_INTEGER); + assert.deepEqual(run.unpriceableDetail, { id: 'c', grams: 1000, bound: 500 }); + assert.equal(Object.getOwnPropertyDescriptor(run, 'unpriceableDetail').enumerable, false); + assert.ok(!JSON.stringify(run).includes('unpriceableDetail')); + } +}); + +test('a portfolio with no priceable run anywhere still refuses at the outermost frame', () => { + // Every pinned solver, every seeded start and every quality re-entry reaches the same + // unpriceable packing; deferring the refusal to the outermost frame must not soften + // it into a sentinel-scored answer. + const refusal = /container "c" bills at 1000 g, above its rate table's last bracket \(500 g\); the shipment has no published price/; + for (const configuration of [ + { solvers: ['extreme_points', 'layer'] }, + { multi_start_orders: 2 }, + { solver_profile: 'quality' }, + ]) { + const req = landed({ weight_brackets_g: [500], prices_minor: [700] }, '200 g'); + req.configuration = { ...req.configuration, ...configuration }; + assert.throws(() => packFallback(req), refusal); + } +}); + +test('the quality portfolio prices the scene instead of refusing it', () => { + // The second-review scene: eight 500 g cubes, a snug box whose ladder stops at 2000 g + // and a roomy one priced to 20000 g. Each quality-profile child settles on the roomy + // box, and the portfolio must ship it at 1500 rather than refuse because some frame + // ranked the snug box along the way. + const req = request( + [cube('box', 100, { weight: '500 g', quantity: 8 })], + [ + box('alpha_unpriceable', 300, 300, 300, { rate_table: { weight_brackets_g: [2000], prices_minor: [900] } }), + box('beta_priceable', 400, 400, 400, { rate_table: { weight_brackets_g: [20000], prices_minor: [1500] } }), + ], + { + configuration: { + objective: 'lowest_landed_cost', + solver_profile: 'quality', + dimensional_weight_divisor: 5000, + dimensional_weight_length_unit: 'cm', + dimensional_weight_weight_unit: 'kg', + }, + }, + ); + const result = packFallback(req); + assert.equal(result.status, 'feasible'); + assert.equal(result.containers[0].container_type, 'beta_priceable'); + assert.equal(result.score[1], 1500); +}); + +test('rebalancing refuses an input packing the tariff cannot price', () => { + // Same admission the solve path applies, in the same words: a packing that already + // bills past its rate table's last bracket has no published price to rebalance around. + const req = request( + [cube('a', 100, { weight: '1000 g' })], + [box('c', 200, 200, 200, { rate_table: { weight_brackets_g: [500], prices_minor: [700] } })], + ); + const original = packSound(req); + const landedReq = { + ...req, + configuration: { + objective: 'lowest_landed_cost', + dimensional_weight_divisor: 8000, + dimensional_weight_length_unit: 'cm', + dimensional_weight_weight_unit: 'kg', + }, + }; + assert.throws( + () => rebalanceWeight(landedReq, original), + /container "c" bills at 1000 g, above its rate table's last bracket \(500 g\); the shipment has no published price/, + ); +}); + +test('rebalancing applies the same landed-cost admission as packing', () => { + // The current packing uses only `rated`, but `untabled` remains a request option. + // Letting the direct rebalance API ignore it would make its contract weaker than + // packFallback and the native implementation ( second review). + const base = request( + [cube('parcel', 100, { weight: '500 g' })], + [box('rated', 200, 200, 200, { rate_table: { weight_brackets_g: [2000], prices_minor: [500] } })], + ); + const original = packSound(base); + assert.throws( + () => rebalanceWeight({ ...base, configuration: { objective: 'lowest_landed_cost' } }, original), + /requires configuration\.dimensional_weight_divisor/, + ); + const withUntabled = { + ...base, + containers: [...base.containers, box('untabled', 300, 300, 300)], + configuration: { + objective: 'lowest_landed_cost', + dimensional_weight_divisor: 8000, + dimensional_weight_length_unit: 'cm', + dimensional_weight_weight_unit: 'kg', + }, + }; + assert.throws( + () => rebalanceWeight(withUntabled, original), + /requires a rate_table on every container; "untabled" has none/, + ); +}); + +test('a rebalance move that would leave the destination unpriceable is vetoed', () => { + // Three 500 g bricks in the big box against a 100 g pebble in the tight one. Moving a + // brick narrows the spread from 1400 g to 400 g, but bills the tight box at 600 g + // against a ladder that stops at 500 g: under `lowest_landed_cost` that trade sells + // balance for a shipment with no published price, so every such candidate fails + // exactly like an invalid one. The same scene under the default objective keeps + // moving, so anything not landed-priced is untouched by the guard. + const req = request( + [ + cube('brick', 100, { weight: '500 g', quantity: 3 }), + cube('pebble', 100, { weight: '100 g' }), + ], + [ + box('alpha_hold', 300, 300, 300, { max_items: 3, rate_table: { weight_brackets_g: [20000], prices_minor: [900] } }), + box('gamma_tight', 200, 200, 200, { max_items: 2, rate_table: { weight_brackets_g: [500], prices_minor: [300] } }), + ], + ); + const original = packSound(req); + assert.deepEqual( + original.containers.map((container) => container.payload_weight.ticks), + [1500 * 8_000_000, 100 * 8_000_000], + ); + const landedReq = { + ...req, + configuration: { + objective: 'lowest_landed_cost', + dimensional_weight_divisor: 20000, + dimensional_weight_length_unit: 'cm', + dimensional_weight_weight_unit: 'kg', + }, + }; + const vetoed = rebalanceWeight(landedReq, original, { maxMoves: 8 }); + assert.deepEqual(vetoed.moves, []); + assert.equal(vetoed.improved, false); + assert.deepEqual(vetoed.containers, original.containers); + const balanced = rebalanceWeight(req, original, { maxMoves: 8 }); + assert.deepEqual(balanced.moves, [ + { item_id: 'brick#1', from_container_id: 'alpha_hold#1', to_container_id: 'gamma_tight#2' }, + ]); + assert.deepEqual( + balanced.containers.map((container) => container.payload_weight.ticks), + [1000 * 8_000_000, 600 * 8_000_000], + ); +}); + // ---------------------------------------------------- staged rollout test('the guard refuses exactly the fields the unsupported lists name', () => { @@ -1427,6 +1760,28 @@ test('exact_small searches equal-count branches for a better objective tie-break ); }); +test('exact_small does not prune a heavier promotional rate band', () => { + const payload = request( + [ + cube('a-light', 100, { weight: '100 g' }), + cube('b-light', 100, { weight: '100 g' }), + cube('z-heavy', 100, { weight: '800 g' }), + ], + [box('bin', 200, 100, 100, { + quantity: 1, + rate_table: { weight_brackets_g: [200, 900], prices_minor: [100, 10] }, + })], + { configuration: { + solvers: ['exact_small'], objective: 'lowest_landed_cost', max_containers: 1, + dimensional_weight_divisor: 10000, + dimensional_weight_length_unit: 'cm', dimensional_weight_weight_unit: 'kg', + } }, + ); + const result = packFallback(payload); + assert.deepEqual(result.score.slice(0, 2), [1, 10]); + assert.ok(placements(result).some(placement => placement.item_id === 'z-heavy#1')); +}); + test('exact_small stops at an admissible complete objective floor', () => { const payload = request( [cube('cube', 45, { quantity: 8, allowed_rotations: ['LWH'] })], diff --git a/test/force-fallback.cjs b/test/force-fallback.cjs index a1df4a2..fd444e1 100644 --- a/test/force-fallback.cjs +++ b/test/force-fallback.cjs @@ -14,8 +14,20 @@ const Module = require('node:module'); +// Exactly what `index.js` probes, and nothing else -- `force-fallback.test.mjs` asserts +// the two lists stay identical, which is what makes "this hook blocks the package's +// native backend" a checkable claim rather than a comment. const NATIVE_CANDIDATES = ['./packvium-native.node', '@packvium/native']; +// Paths only a test reaches for: the in-workspace build directory the commerce suite +// loads when it compares the native and fallback backends against each other. Blocked +// too, because "forced fallback" has to mean forced everywhere -- a suite that loaded +// the addon by a path this hook did not know would measure the wrong backend and pull a +// file outside `package.json`'s `files` into the coverage report. +const TEST_ONLY_CANDIDATES = ['../../packvium-rust/bindings/node']; + +const BLOCKED = [...NATIVE_CANDIDATES, ...TEST_ONLY_CANDIDATES]; + class NativeBackendBlockedError extends Error { constructor(specifier) { super(`native backend '${specifier}' is blocked: this process is pinned to the JavaScript fallback`); @@ -26,8 +38,8 @@ class NativeBackendBlockedError extends Error { const nextResolveFilename = Module._resolveFilename; Module._resolveFilename = function resolveWithoutNativeBackend(request, parent, isMain, options) { - if (NATIVE_CANDIDATES.includes(request)) throw new NativeBackendBlockedError(request); + if (BLOCKED.includes(request)) throw new NativeBackendBlockedError(request); return nextResolveFilename.call(this, request, parent, isMain, options); }; -module.exports = { NATIVE_CANDIDATES, NativeBackendBlockedError }; +module.exports = { NATIVE_CANDIDATES, TEST_ONLY_CANDIDATES, NativeBackendBlockedError };