@packvium/engine 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ production.
15
15
  ## Quick start
16
16
 
17
17
  ```js
18
- import { backend, pack } from '@packvium/engine';
18
+ import { backend, commerce, pack } from '@packvium/engine';
19
19
 
20
20
  const result = pack({
21
21
  items: [{
@@ -31,6 +31,79 @@ const result = pack({
31
31
  console.log(backend()); // "rust" or "javascript"
32
32
  console.log(result.status); // "feasible"
33
33
  console.log(result.containers);
34
+
35
+ const commerceDocument = { tariffs: [{
36
+ carrier_id: 'acme', service_id: 'ground',
37
+ versions: [{
38
+ effective_at: 0, dimensional_weight_divisor: 5000,
39
+ cost_per_dimensional_kg_minor: { 'zone-a': 450 },
40
+ minimum_charge_minor: 900, fuel_surcharge_permille: 120,
41
+ }],
42
+ }] };
43
+ const quote = commerce.quote(commerceDocument, {
44
+ carrier_id: 'acme', service_id: 'ground', tariff_version: 1,
45
+ zone: 'zone-a', actual_weight_g: 1200, volume_mm3: 6000000,
46
+ });
47
+ console.log(quote.quote.total_minor);
48
+ ```
49
+
50
+ ## Quotes, policy and catalog versions
51
+
52
+ `commerce` has three functions, all deterministic and all over one document you supply —
53
+ no clock, no network, no hidden state. A history is a list and a version's number is its
54
+ position in that list starting at 1, so `tariff_version: 2` always means "the second
55
+ entry under this carrier and service".
56
+
57
+ ```js
58
+ import { commerce } from '@packvium/engine';
59
+
60
+ // Which version applies: pin it, or ask what was in force at an instant. Never both.
61
+ commerce.quote(document, { /* ... */ tariff_version: 1 });
62
+ commerce.quote(document, { /* ... */ as_of: 1500 });
63
+
64
+ // The decision, and the rule id and version that made it.
65
+ const { decision } = commerce.evaluatePolicy(document, {
66
+ scope: 'hazmat', context: { un_class: '1.4' }, as_of: 0,
67
+ });
68
+ decision.allowed; // false
69
+ decision.citation.rule_id; // "no-hazmat-air"
70
+
71
+ // Which catalog version a pin resolves to, what it holds, whether it was a rollback.
72
+ const { catalog } = commerce.catalogVersionInfo(document, {
73
+ catalog_id: 'dc-12', version: 2, resolved_at: 1700,
74
+ });
75
+ catalog.entry_counts; // { items: 1, cartons: 1, pallets: 0, ... }
76
+ catalog.rolled_back_from; // 1, or null for an ordinary publication
77
+
78
+ // Store, log and compare results in the canonical form, not JSON.stringify.
79
+ commerce.canonicalJson(result);
80
+ ```
81
+
82
+ Two kinds of failure, and they are not interchangeable:
83
+
84
+ - a **malformed** document or request is your bug and throws `CommerceInputError`;
85
+ - a request the model simply **cannot answer** — no tariff effective at that instant, no
86
+ rate for that zone — is a successful call returning `"status": "rejected"` with a code
87
+ from a closed set and structured fields naming what was missing.
88
+
89
+ `commerce.backend()` reports whether the native addon or the JavaScript implementation
90
+ answered; both return the same result for the same input. A runnable walk-through of all
91
+ three functions is in [examples/commerce.mjs](examples/commerce.mjs), and the full
92
+ contract — document format, every result shape, all ten rejection codes, complexity and
93
+ limitations — is `docs/COMMERCE-API.md`.
94
+
95
+ ## Examples
96
+
97
+ Runnable, in [`examples/`](examples). Each one is a single file you can read top to bottom
98
+ and execute without a project around it.
99
+
100
+ | File | What it shows |
101
+ | --- | --- |
102
+ | [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. |
103
+ | [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. |
104
+
105
+ ```bash
106
+ node examples/basic.mjs
34
107
  ```
35
108
 
36
109
  ## Features
@@ -41,14 +114,33 @@ console.log(result.containers);
41
114
  - JSON input/output through `pack()` or `packJson()`.
42
115
  - Optional payload rebalancing with `rebalanceWeight()`.
43
116
  - Loading and removal sequence helpers for already placed boxes.
117
+ - Deterministic carrier quotes, policy evaluation and effective-dated catalog lookup
118
+ through `commerce`.
44
119
 
45
120
  The native addon is optional. `npm install` works on unsupported platforms too; call
46
121
  `backend()` if your application needs to know which implementation handled a request.
47
122
 
123
+ ## The Packvium family
124
+
125
+ One request and result contract, implemented independently in four engines (Rust,
126
+ Python, PHP, JavaScript) and held to identical placements on a shared fixture set.
127
+ Pick the package for your stack; mixing them in one system is safe.
128
+
129
+ | Package | Install | Source |
130
+ | --- | --- | --- |
131
+ | Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) |
132
+ | PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) |
133
+ | Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "0.1"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
134
+ | Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) |
135
+ | Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) |
136
+ | PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) |
137
+ | Python native selector — `packvium-native` | from source until the native wheels ship | [packvium-python-adapter](https://github.com/toxakara/packvium-python-adapter) |
138
+
48
139
  ## API and support
49
140
 
50
141
  TypeScript declarations are included. See the package's `index.d.ts` for the complete
51
- request and result types. Report security issues through [SECURITY.md](SECURITY.md).
142
+ request and result types, and `docs/COMMERCE-API.md` for the commercial/control-plane
143
+ contract. Report security issues through [SECURITY.md](SECURITY.md).
52
144
 
53
145
  ## License
54
146
 
package/SECURITY.md ADDED
@@ -0,0 +1,77 @@
1
+ # Security policy
2
+
3
+ ## Supported versions
4
+
5
+ Only the latest `0.1.x` release receives fixes. This is an early release; there is no
6
+ long-term support branch yet.
7
+
8
+ ## Reporting a vulnerability
9
+
10
+ Please report privately, not in a public issue. Use GitHub's **Report a vulnerability**
11
+ button under the Security tab, which opens a private advisory.
12
+
13
+ Include the version, the platform, a minimal request that reproduces the problem, and
14
+ what you observed. A crash, a hang or a wildly wrong result on a small input is worth
15
+ reporting even if you are unsure it is a security matter.
16
+
17
+ You can expect an acknowledgement within a few working days and an assessment after that.
18
+ Please give us a chance to ship a fix before disclosing publicly.
19
+
20
+ ## Threat model
21
+
22
+ This is a computation library. It has **no runtime dependencies**, opens no network
23
+ connections, spawns no processes, reads no files except through the CLI's standard input,
24
+ and executes no user-supplied code except the extension objects you register yourself.
25
+
26
+ The realistic risks are therefore about untrusted **input**:
27
+
28
+ - **Resource exhaustion.** A request with a large item count, many container types or a
29
+ permissive solver profile can consume substantial CPU and memory. If you accept
30
+ requests from untrusted callers, set `time_limit_ms`, cap item quantities and container
31
+ inventory, and run the call where you can bound memory. The deadline is honoured by the
32
+ search, but a single enormous request can still allocate a lot before the first check.
33
+ - **Integer magnitude.** Dimensions are converted to exact integer ticks — 16 000 per
34
+ millimetre. Absurd inputs produce very large integers rather than overflow, but they
35
+ cost time and memory. Validate dimensions against a sane maximum before passing them in.
36
+ - **Malformed input.** Bad units, unparseable numbers and contradictory constraints raise
37
+ errors rather than producing a wrong packing. Do not suppress those errors.
38
+
39
+ A result that reports success has passed independent validation, but validation checks
40
+ the constraints you declared. It cannot know about a constraint you did not express.
41
+
42
+ ## Extensions
43
+
44
+ Custom constraints, orderings, scorers, container selectors and solvers run with the
45
+ privileges of your process. Treat a third-party extension as you would any other
46
+ dependency: read it before you register it.
47
+
48
+ ## Signing and keyless publishing
49
+
50
+ Every release is built and published from CI, never from a maintainer's workstation,
51
+ so there is one attacker-controlled surface to defend: the release workflow itself.
52
+
53
+ - **Signed tags.** The tag a release is cut from is a signed git tag (`git tag -s`),
54
+ verifiable against the maintainers' published keys. An unsigned tag is not released
55
+ from.
56
+ - **OIDC trusted publishing instead of long-lived tokens, where the registry supports
57
+ it.** PyPI and npm both accept a short-lived token minted from the release workflow's
58
+ GitHub Actions OIDC identity instead of a static API token stored as a secret — there
59
+ is no password to leak because none is issued until the moment of publish, and it is
60
+ scoped to that one run. Packagist resolves packages directly from the tagged git
61
+ repository and has no comparable upload token to eliminate. crates.io does not yet
62
+ support trusted publishing for this ecosystem; until it does, its token is scoped to
63
+ this crate only, stored as a CI secret, and rotated on the schedule below regardless
64
+ of whether compromise is suspected.
65
+ - **Provenance attestations** (see the release process)
66
+ bind each published artifact back to the exact workflow run, commit and tag that
67
+ produced it, independent of which upload method the registry used.
68
+ - **Recovery.** If the release workflow, its OIDC trust configuration or the crates.io
69
+ token is suspected compromised: revoke the trust relationship (or rotate the token)
70
+ immediately, audit the workflow's recent runs and any artifacts they published,
71
+ and re-cut the release from a clean, re-reviewed commit under a new version — never
72
+ by force-pushing or reusing the affected tag.
73
+ - **Revocation.** A compromised or defective published version is pulled from
74
+ circulation using each registry's own mechanism (PyPI yank, npm deprecate, Packagist
75
+ abandon, crates.io yank) rather than deleted outright, so that projects already
76
+ pinned to it get a clear signal instead of a broken install. The security advisory
77
+ for the issue names the affected versions explicitly.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The commercial and control-plane models: carrier rating, eligibility policy and
3
+ * catalog versioning.
4
+ *
5
+ * An independent implementation of the contract in docs/COMMERCE-API.md, held to
6
+ * producing a valid result that meets each shared fixture's objective floor. For a
7
+ * quote that floor is an exact integer price, so matching it means matching exactly.
8
+ *
9
+ * Money, weight and volume arithmetic runs in BigInt and every inexact division rounds
10
+ * up, so a quote can neither drift through a double nor land a minor unit below what
11
+ * the tariff charges. Results are converted back to Number at the boundary; a component
12
+ * beyond Number.MAX_SAFE_INTEGER is refused rather than silently rounded.
13
+ *
14
+ * This module is package-internal: package.json exports only the root entry point.
15
+ */
16
+
17
+ export class CommerceInputError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'CommerceInputError';
21
+ }
22
+ }
23
+
24
+ /** Exact ceil(a * b / d) for non-negative inputs, in BigInt so nothing can wrap. */
25
+ export function ceilMulDiv(a, b, d) {
26
+ const divisor = BigInt(d);
27
+ if (divisor <= 0n) throw new CommerceInputError('divisor must be positive');
28
+ const product = BigInt(a) * BigInt(b);
29
+ return (product + divisor - 1n) / divisor;
30
+ }
31
+
32
+ /** Convert an exact BigInt back to a JSON number, refusing a value a double cannot hold. */
33
+ export function exact(value) {
34
+ const number = Number(value);
35
+ if (!Number.isSafeInteger(number)) {
36
+ throw new CommerceInputError(
37
+ `${value} is outside JavaScript's exact integer range; this quote cannot be represented`,
38
+ );
39
+ }
40
+ return number;
41
+ }
42
+
43
+ /**
44
+ * Order two strings by Unicode code point, the way every other implementation orders
45
+ * them.
46
+ *
47
+ * JavaScript's default string comparison is by UTF-16 code unit, which disagrees with
48
+ * Python, PHP and Rust for any character outside the Basic Multilingual Plane: an emoji
49
+ * (U+1F600) sorts *before* a fullwidth Latin A (U+FF21) by code unit and *after* it by
50
+ * code point. Sorted id lists are part of this contract's answer, so a default `.sort()`
51
+ * would make this implementation disagree with the other three on exactly those inputs.
52
+ */
53
+ export function compareCodePoints(left, right) {
54
+ const a = Array.from(left);
55
+ const b = Array.from(right);
56
+ const shared = Math.min(a.length, b.length);
57
+ for (let index = 0; index < shared; index += 1) {
58
+ const difference = a[index].codePointAt(0) - b[index].codePointAt(0);
59
+ if (difference !== 0) return difference < 0 ? -1 : 1;
60
+ }
61
+ if (a.length === b.length) return 0;
62
+ return a.length < b.length ? -1 : 1;
63
+ }
64
+
65
+ // ------------------------------------------------------------------------------ rating
66
+
67
+ /** The charge one accessorial adds, given the base charge it may be a permille of. */
68
+ export function accessorialCharge(accessorial, baseChargeMinor) {
69
+ if (accessorial.flatChargeMinor !== null) return BigInt(accessorial.flatChargeMinor);
70
+ return ceilMulDiv(baseChargeMinor, accessorial.permilleOfBase, 1000);
71
+ }
72
+
73
+ /**
74
+ * Rate a request against one already-resolved immutable tariff version.
75
+ *
76
+ * Returns either `{breakdown}` or `{rejection}`, where a rejection names structurally
77
+ * what was missing -- never a silently-zero charge.
78
+ */
79
+ export function rateTariff(tariff, request) {
80
+ if (!Object.hasOwn(tariff.costPerDimensionalKgMinor, request.zone)) {
81
+ return { rejection: { kind: 'zone', zone: request.zone } };
82
+ }
83
+ const unknown = request.requestedAccessorials
84
+ .filter((id) => !Object.hasOwn(tariff.accessorials, id))
85
+ .sort(compareCodePoints);
86
+ if (unknown.length > 0) return { rejection: { kind: 'accessorial', accessorialIds: unknown } };
87
+
88
+ // Dimensional weight in grams is volume (mm^3) over the divisor, rounded up.
89
+ const dimensionalWeightG = ceilMulDiv(request.volumeMm3, 1, tariff.dimensionalWeightDivisor);
90
+ const billedWeightG =
91
+ BigInt(request.actualWeightG) > dimensionalWeightG
92
+ ? BigInt(request.actualWeightG)
93
+ : dimensionalWeightG;
94
+
95
+ const rawBaseChargeMinor = ceilMulDiv(
96
+ billedWeightG, tariff.costPerDimensionalKgMinor[request.zone], 1000,
97
+ );
98
+ const minimumChargeApplied = rawBaseChargeMinor < BigInt(tariff.minimumChargeMinor);
99
+ const baseChargeMinor = minimumChargeApplied
100
+ ? BigInt(tariff.minimumChargeMinor)
101
+ : rawBaseChargeMinor;
102
+
103
+ const fuelSurchargeMinor = ceilMulDiv(baseChargeMinor, tariff.fuelSurchargePermille, 1000);
104
+ const accessorialCharges = request.requestedAccessorials.map((id) => [
105
+ id, accessorialCharge(tariff.accessorials[id], baseChargeMinor),
106
+ ]);
107
+ const accessorialTotal = accessorialCharges.reduce((sum, [, amount]) => sum + amount, 0n);
108
+
109
+ return {
110
+ breakdown: {
111
+ carrier_id: tariff.carrierId,
112
+ service_id: tariff.serviceId,
113
+ tariff_version: tariff.version,
114
+ zone: request.zone,
115
+ actual_weight_g: request.actualWeightG,
116
+ dimensional_weight_g: exact(dimensionalWeightG),
117
+ billed_weight_g: exact(billedWeightG),
118
+ base_charge_minor: exact(baseChargeMinor),
119
+ minimum_charge_applied: minimumChargeApplied,
120
+ fuel_surcharge_minor: exact(fuelSurchargeMinor),
121
+ accessorial_charges_minor: accessorialCharges.map(([id, amount]) => [id, exact(amount)]),
122
+ total_minor: exact(baseChargeMinor + fuelSurchargeMinor + accessorialTotal),
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * The version effective at `asOf`: the highest `effective_at` not after it, ties broken
129
+ * by the higher (later-published) version number. Null when nothing has taken effect.
130
+ */
131
+ export function effectiveVersion(history, asOf, numberOf) {
132
+ let winner = null;
133
+ for (const candidate of history) {
134
+ if (candidate.effectiveAt > asOf) continue;
135
+ if (
136
+ winner === null
137
+ || candidate.effectiveAt > winner.effectiveAt
138
+ || (candidate.effectiveAt === winner.effectiveAt && numberOf(candidate) > numberOf(winner))
139
+ ) {
140
+ winner = candidate;
141
+ }
142
+ }
143
+ return winner;
144
+ }
145
+
146
+ // ------------------------------------------------------------------------------ policy
147
+
148
+ export const POLICY_SCOPES = [
149
+ 'facility', 'customer', 'carrier', 'material', 'hazmat', 'temperature', 'service',
150
+ ];
151
+ export const POLICY_OPERATORS = ['equals', 'not_equals', 'in', 'not_in', 'exists', 'absent'];
152
+ export const POLICY_ACTIONS = ['allow', 'reject'];
153
+ const UNARY_OPERATORS = ['exists', 'absent'];
154
+
155
+ export function isUnary(operator) {
156
+ return UNARY_OPERATORS.includes(operator);
157
+ }
158
+
159
+ /**
160
+ * Value equality over the JSON scalar types, matching the reference implementation
161
+ * exactly -- including that a boolean equals the integer it stands for, the one place a
162
+ * naive `===` would disagree and quietly change a decision.
163
+ */
164
+ export function valuesEqual(left, right) {
165
+ if (typeof left === 'boolean' || typeof right === 'boolean') {
166
+ if (typeof left === 'boolean' && typeof right === 'boolean') return left === right;
167
+ const other = typeof left === 'boolean' ? right : left;
168
+ return typeof other === 'number' && Number(left) === Number(right);
169
+ }
170
+ if (Array.isArray(left) && Array.isArray(right)) {
171
+ return left.length === right.length && left.every((entry, index) => valuesEqual(entry, right[index]));
172
+ }
173
+ if (Array.isArray(left) || Array.isArray(right)) return false;
174
+ if (left === null || right === null) return left === right;
175
+ if (typeof left !== typeof right) return false;
176
+ return left === right;
177
+ }
178
+
179
+ function contains(haystack, needle) {
180
+ if (Array.isArray(haystack)) return haystack.some((entry) => valuesEqual(entry, needle));
181
+ if (typeof haystack === 'string') return typeof needle === 'string' && haystack.includes(needle);
182
+ return false;
183
+ }
184
+
185
+ export function predicateMatches(predicate, context) {
186
+ const present = Object.hasOwn(context, predicate.field);
187
+ if (predicate.operator === 'exists') return present;
188
+ if (predicate.operator === 'absent') return !present;
189
+ if (!present) return false;
190
+ const actual = context[predicate.field];
191
+ switch (predicate.operator) {
192
+ case 'equals': return valuesEqual(actual, predicate.value);
193
+ case 'not_equals': return !valuesEqual(actual, predicate.value);
194
+ case 'in': return contains(predicate.value, actual);
195
+ default: return !contains(predicate.value, actual);
196
+ }
197
+ }
198
+
199
+ export function ruleMatches(rule, context) {
200
+ return rule.predicates.every((predicate) => predicateMatches(predicate, context));
201
+ }
202
+
203
+ /**
204
+ * Evaluate an already-resolved immutable rule set.
205
+ *
206
+ * Deny takes precedence: an explicit REJECT always outranks an ALLOW for the same
207
+ * context. Among equals the highest priority wins, ties break on the lexicographically
208
+ * smallest rule id, and nothing matching at all is allowed with no citation.
209
+ */
210
+ export function decide(rules, scope, context) {
211
+ const matching = rules.filter((rule) => rule.scope === scope && ruleMatches(rule, context));
212
+ const rejects = matching.filter((rule) => rule.action === 'reject');
213
+ const pool = rejects.length > 0 ? rejects : matching;
214
+ if (pool.length === 0) return { scope, allowed: true, citation: null };
215
+
216
+ // Ties break on the lexicographically smallest rule id -- by code point, because a
217
+ // default `<` on strings compares UTF-16 code units and would cite a different rule
218
+ // than the other three implementations whenever an id leaves the Basic Multilingual
219
+ // Plane.
220
+ const winner = pool.reduce((best, rule) => (
221
+ rule.priority > best.priority
222
+ || (rule.priority === best.priority && compareCodePoints(rule.ruleId, best.ruleId) < 0)
223
+ ? rule : best
224
+ ));
225
+ return {
226
+ scope,
227
+ allowed: winner.action === 'allow',
228
+ citation: {
229
+ rule_id: winner.ruleId,
230
+ version: winner.version,
231
+ action: winner.action,
232
+ priority: winner.priority,
233
+ reason: winner.reason,
234
+ },
235
+ };
236
+ }