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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .DS_Store
Binary file not shown.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.DS_Store
node_modules/
*.tgz
packvium-native.node
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 78 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [{
Expand All @@ -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
Expand All @@ -41,14 +114,17 @@ 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.

## 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

Expand Down
236 changes: 236 additions & 0 deletions commerce-model.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
}
Loading
Loading