@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 +94 -2
- package/SECURITY.md +77 -0
- package/commerce-model.js +236 -0
- package/commerce.js +664 -0
- package/examples/basic.mjs +67 -0
- package/examples/commerce.mjs +163 -0
- package/examples/objectives.mjs +114 -0
- package/fallback.js +103 -11
- package/index.d.ts +12 -0
- package/index.js +41 -1
- package/package.json +7 -3
- package/policy.js +226 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pack an order, read the placements, and see why anything was refused.
|
|
3
|
+
*
|
|
4
|
+
* Run it:
|
|
5
|
+
*
|
|
6
|
+
* node examples/basic.mjs
|
|
7
|
+
*
|
|
8
|
+
* `@packvium/engine` takes and returns the same JSON contract every Packvium
|
|
9
|
+
* implementation speaks, so a request you build here also works against the Python CLI,
|
|
10
|
+
* the PHP CLI or the Rust core, and comes back with the same answer.
|
|
11
|
+
*
|
|
12
|
+
* The package prefers the compiled N-API addon when `@packvium/native` is installed and
|
|
13
|
+
* falls back to a deterministic JavaScript engine otherwise. You do not choose, and you
|
|
14
|
+
* do not need to: `backend()` reports which one answered, and both answer the same.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { backend, pack, version } from '../index.js';
|
|
18
|
+
|
|
19
|
+
console.log(`engine ${version()} using the ${backend()} backend\n`);
|
|
20
|
+
|
|
21
|
+
const request = {
|
|
22
|
+
items: [
|
|
23
|
+
// Lengths and weights are strings on purpose. They are parsed into exact integers,
|
|
24
|
+
// so '0.1' means a tenth of a millimetre and never 0.09999999999999999. Plain
|
|
25
|
+
// integers and fractions like '3/16' work too.
|
|
26
|
+
{ id: 'mug', quantity: 6, dimensions: { length: '120', width: '120', height: '100' }, weight: '400 g' },
|
|
27
|
+
{ id: 'plate', quantity: 8, dimensions: { length: '260', width: '260', height: '20' }, weight: '600 g' },
|
|
28
|
+
// Too long for the box in every orientation, so it cannot be placed.
|
|
29
|
+
{ id: 'ladder', quantity: 1, dimensions: { length: '1800', width: '300', height: '100' }, weight: '6 kg' },
|
|
30
|
+
],
|
|
31
|
+
containers: [
|
|
32
|
+
{
|
|
33
|
+
id: 'box',
|
|
34
|
+
inner_dimensions: { length: '400', width: '400', height: '400' },
|
|
35
|
+
max_payload: '15 kg',
|
|
36
|
+
cost_minor: 180,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const result = pack(request);
|
|
42
|
+
|
|
43
|
+
console.log(`status: ${result.status}`);
|
|
44
|
+
console.log(`containers opened: ${result.containers.length}`);
|
|
45
|
+
|
|
46
|
+
for (const [index, container] of result.containers.entries()) {
|
|
47
|
+
console.log(`\nbox #${index + 1}: ${container.placements.length} placement(s), ` +
|
|
48
|
+
`${container.volume_utilization} of the volume used`);
|
|
49
|
+
for (const placement of container.placements) {
|
|
50
|
+
// Every measurement arrives as { ticks, value, unit }: `ticks` is the exact integer
|
|
51
|
+
// the engine reasoned about, `value` is that same number written for a human.
|
|
52
|
+
const { x, y, z } = placement.position;
|
|
53
|
+
console.log(
|
|
54
|
+
` ${placement.item_type.padEnd(8)} at (${x.value}, ${y.value}, ${z.value}) ${x.unit}` +
|
|
55
|
+
` orientation ${placement.orientation}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A refusal is an answer, not an error. Each entry says which instance was refused and
|
|
61
|
+
// the structured reason, so you can act on it rather than re-guessing.
|
|
62
|
+
if (result.unpacked_items.length > 0) {
|
|
63
|
+
console.log('\nnot packed:');
|
|
64
|
+
for (const unpacked of result.unpacked_items) {
|
|
65
|
+
console.log(` ${unpacked.item_id.padEnd(10)} ${unpacked.reason}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote a shipment, apply a policy rule, and inspect a catalog version.
|
|
3
|
+
*
|
|
4
|
+
* Run it:
|
|
5
|
+
*
|
|
6
|
+
* node examples/commerce.mjs
|
|
7
|
+
*
|
|
8
|
+
* Everything the three functions need arrives in one *commerce document*: the tariffs
|
|
9
|
+
* you publish, the eligibility rules you publish, and the catalog versions you publish.
|
|
10
|
+
* Each history is a list, and a version's number is simply its position in that list
|
|
11
|
+
* starting at 1 — so `tariff_version: 2` always means "the second entry under this
|
|
12
|
+
* carrier and service", with no separate numbering to keep in sync.
|
|
13
|
+
*
|
|
14
|
+
* `commerce` picks the native addon when @packvium/native is installed and the
|
|
15
|
+
* deterministic JavaScript engine otherwise. Both return the same answer;
|
|
16
|
+
* `commerce.backend()` says which one answered.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { commerce } from '../index.js';
|
|
20
|
+
|
|
21
|
+
// One document, three histories. You would normally load this from your own storage.
|
|
22
|
+
const document = {
|
|
23
|
+
tariffs: [{
|
|
24
|
+
carrier_id: 'acme',
|
|
25
|
+
service_id: 'ground',
|
|
26
|
+
// Two published versions. The second takes effect at instant 1000.
|
|
27
|
+
versions: [
|
|
28
|
+
{
|
|
29
|
+
effective_at: 0,
|
|
30
|
+
// Volume in mm^3 divided by this gives dimensional weight in grams.
|
|
31
|
+
dimensional_weight_divisor: 5000,
|
|
32
|
+
// Minor currency units (cents) per billed kilogram, per zone.
|
|
33
|
+
cost_per_dimensional_kg_minor: { 'zone-a': 450, 'zone-b': 610 },
|
|
34
|
+
minimum_charge_minor: 900,
|
|
35
|
+
// Permille: 120 means 12.0%.
|
|
36
|
+
fuel_surcharge_permille: 120,
|
|
37
|
+
accessorials: [
|
|
38
|
+
{ accessorial_id: 'liftgate', flat_charge_minor: 250 },
|
|
39
|
+
{ accessorial_id: 'residential', permille_of_base: 75 },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
effective_at: 1000,
|
|
44
|
+
dimensional_weight_divisor: 4000,
|
|
45
|
+
cost_per_dimensional_kg_minor: { 'zone-a': 480 },
|
|
46
|
+
minimum_charge_minor: 950,
|
|
47
|
+
fuel_surcharge_permille: 140,
|
|
48
|
+
accessorials: [{ accessorial_id: 'liftgate', flat_charge_minor: 275 }],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
}],
|
|
52
|
+
policy_rules: [{
|
|
53
|
+
rule_id: 'no-hazmat-air',
|
|
54
|
+
versions: [{
|
|
55
|
+
scope: 'hazmat',
|
|
56
|
+
action: 'reject',
|
|
57
|
+
priority: 10,
|
|
58
|
+
effective_at: 0,
|
|
59
|
+
reason: 'class 1.4 is not accepted on air services',
|
|
60
|
+
predicates: [
|
|
61
|
+
{ scope: 'hazmat', field: 'un_class', operator: 'equals', value: '1.4' },
|
|
62
|
+
],
|
|
63
|
+
}],
|
|
64
|
+
}],
|
|
65
|
+
catalogs: [{
|
|
66
|
+
catalog_id: 'dc-12',
|
|
67
|
+
versions: [
|
|
68
|
+
{
|
|
69
|
+
effective_at: 0,
|
|
70
|
+
published_at: 0,
|
|
71
|
+
note: 'initial',
|
|
72
|
+
snapshot: {
|
|
73
|
+
items: [{ id: 'sku-1', dimensions_mm: [100, 200, 300], weight_g: 1200 }],
|
|
74
|
+
cartons: [{
|
|
75
|
+
id: 'box-m', inner_dimensions_mm: [320, 240, 180],
|
|
76
|
+
max_payload_g: 15000, cost_minor: 85,
|
|
77
|
+
}],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
// A rollback is a new, higher-numbered version, never an edit of history.
|
|
81
|
+
{
|
|
82
|
+
rollback_to: 1, published_at: 900, effective_at: 900,
|
|
83
|
+
note: 'revert the weight correction',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
}],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const show = (title, result) => {
|
|
90
|
+
console.log(`\n== ${title}`);
|
|
91
|
+
console.log(JSON.stringify(result, null, 2));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
console.log(`backend: ${commerce.backend()}`);
|
|
95
|
+
|
|
96
|
+
// 1. Quote: what does this shipment cost?
|
|
97
|
+
const pinned = commerce.quote(document, {
|
|
98
|
+
carrier_id: 'acme',
|
|
99
|
+
service_id: 'ground',
|
|
100
|
+
tariff_version: 1, // replay against exactly this version...
|
|
101
|
+
zone: 'zone-a',
|
|
102
|
+
actual_weight_g: 1200,
|
|
103
|
+
volume_mm3: 6000000,
|
|
104
|
+
requested_accessorials: ['liftgate'],
|
|
105
|
+
});
|
|
106
|
+
show('a quote pinned to tariff version 1', pinned);
|
|
107
|
+
console.log(` -> the caller pays ${pinned.quote.total_minor} minor units`);
|
|
108
|
+
|
|
109
|
+
const effective = commerce.quote(document, {
|
|
110
|
+
carrier_id: 'acme',
|
|
111
|
+
service_id: 'ground',
|
|
112
|
+
as_of: 1500, // ...or against whatever was in force at this instant
|
|
113
|
+
zone: 'zone-a',
|
|
114
|
+
actual_weight_g: 1200,
|
|
115
|
+
volume_mm3: 6000000,
|
|
116
|
+
requested_accessorials: ['liftgate'],
|
|
117
|
+
});
|
|
118
|
+
console.log(`\n as of instant 1500 the tariff is version ${effective.quote.tariff_version},`
|
|
119
|
+
+ ` and the price is ${effective.quote.total_minor}`);
|
|
120
|
+
|
|
121
|
+
// A request the model cannot answer is not an exception. It is a result with a status,
|
|
122
|
+
// a code from a closed set, and the structured fields that say what was missing.
|
|
123
|
+
show('a zone this tariff does not price', commerce.quote(document, {
|
|
124
|
+
carrier_id: 'acme', service_id: 'ground', tariff_version: 1,
|
|
125
|
+
zone: 'zone-nowhere', actual_weight_g: 1200, volume_mm3: 6000000,
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
// A *malformed* request is a different thing entirely: that is your bug, and it throws.
|
|
129
|
+
try {
|
|
130
|
+
commerce.quote(document, {
|
|
131
|
+
carrier_id: 'acme', service_id: 'ground', tariff_version: 1,
|
|
132
|
+
zone: 'zone-a', actual_weight_g: -1, volume_mm3: 6000000,
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.log(`\n a negative weight is refused before anything is priced: ${error.message}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. Policy: may this shipment go at all?
|
|
139
|
+
show('a policy decision, with the rule that made it', commerce.evaluatePolicy(document, {
|
|
140
|
+
scope: 'hazmat',
|
|
141
|
+
context: { un_class: '1.4' },
|
|
142
|
+
as_of: 0,
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
const allowed = commerce.evaluatePolicy(document, {
|
|
146
|
+
scope: 'hazmat', context: { un_class: '9' }, as_of: 0,
|
|
147
|
+
});
|
|
148
|
+
console.log('\n nothing matched, so the shipment is allowed with no citation:'
|
|
149
|
+
+ ` ${allowed.decision.citation}`);
|
|
150
|
+
|
|
151
|
+
// 3. Catalog: which master data was this decision made against?
|
|
152
|
+
const catalog = commerce.catalogVersionInfo(document, {
|
|
153
|
+
catalog_id: 'dc-12',
|
|
154
|
+
version: 2,
|
|
155
|
+
resolved_at: 1700,
|
|
156
|
+
});
|
|
157
|
+
show('catalog version metadata', catalog);
|
|
158
|
+
console.log(`\n version ${catalog.catalog.version} is a rollback of version`
|
|
159
|
+
+ ` ${catalog.catalog.rolled_back_from}`);
|
|
160
|
+
|
|
161
|
+
// Storing or comparing a result: use the canonical form, never JSON.stringify directly.
|
|
162
|
+
console.log('\n== the canonical form is what you store, log and compare');
|
|
163
|
+
console.log(commerce.canonicalJson(pinned));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Objectives: six ways to be "best", and the scenes where they disagree.
|
|
3
|
+
*
|
|
4
|
+
* Run it:
|
|
5
|
+
*
|
|
6
|
+
* node examples/objectives.mjs
|
|
7
|
+
*
|
|
8
|
+
* Every solve returns the arrangement that scores best -- but "best" is a choice, and it
|
|
9
|
+
* is the one setting most likely to make the library look wrong when it is merely
|
|
10
|
+
* answering a different question than you meant to ask. This example builds scenes where
|
|
11
|
+
* two objectives genuinely pick different containers, so the difference is visible
|
|
12
|
+
* rather than asserted.
|
|
13
|
+
*
|
|
14
|
+
* The score is always a lexicographic array of exact integers, never a float, and its
|
|
15
|
+
* first key is always the unpacked count: no objective will ever leave an item behind to
|
|
16
|
+
* save money. The same request handed to the Python, PHP or Rust engine prints the same
|
|
17
|
+
* vector.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { pack } from '../index.js';
|
|
21
|
+
|
|
22
|
+
const widgets = [{
|
|
23
|
+
id: 'widget', quantity: 8,
|
|
24
|
+
dimensions: { length: '100', width: '100', height: '100' },
|
|
25
|
+
weight: '500 g',
|
|
26
|
+
}];
|
|
27
|
+
|
|
28
|
+
const solve = (configuration, containers) => {
|
|
29
|
+
const result = pack({ units: { length: 'mm' }, configuration, items: widgets, containers });
|
|
30
|
+
const chosen = result.containers.length > 0 ? result.containers[0].container_type : 'none';
|
|
31
|
+
return `${chosen.padEnd(6)} score=${JSON.stringify(result.score)}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const box = (id, side, extra = {}) => ({
|
|
35
|
+
id,
|
|
36
|
+
inner_dimensions: { length: side, width: side, height: side },
|
|
37
|
+
max_payload: '20 kg',
|
|
38
|
+
...extra,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const weightPricing = {
|
|
42
|
+
dimensional_weight_divisor: 5000,
|
|
43
|
+
dimensional_weight_length_unit: 'cm',
|
|
44
|
+
dimensional_weight_weight_unit: 'kg',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const snug = box('snug', '300', { cost_minor: 500 });
|
|
48
|
+
const roomy = box('roomy', '400', { cost_minor: 150 });
|
|
49
|
+
|
|
50
|
+
// `default` -- fewest containers, then tightest fit. What you want when the boxes are
|
|
51
|
+
// interchangeable and you are simply trying not to open another one.
|
|
52
|
+
console.log('default ', solve({ seed: 42 }, [snug, roomy]));
|
|
53
|
+
|
|
54
|
+
// `lowest_cost` -- the cheapest *packaging*. `cost_minor` is what the box costs you, so
|
|
55
|
+
// this is the objective for a warehouse buying cartons, not a shipper paying a carrier.
|
|
56
|
+
console.log('lowest_cost ', solve({ seed: 42, objective: 'lowest_cost' }, [snug, roomy]));
|
|
57
|
+
|
|
58
|
+
// `shipping_cost` -- carrier-billable *weight*: the greater of actual gross weight and
|
|
59
|
+
// dimensional weight. A big light box can bill more than a small heavy one, which is why
|
|
60
|
+
// this is not the same objective as `lowest_cost`. It needs a divisor and refuses rather
|
|
61
|
+
// than guessing one, because a wrong divisor silently misprices every shipment.
|
|
62
|
+
console.log('shipping_cost ',
|
|
63
|
+
solve({ seed: 42, objective: 'shipping_cost', ...weightPricing }, [snug, roomy]));
|
|
64
|
+
|
|
65
|
+
// `lowest_landed_cost` -- carrier-billable *money*, with the rate card arriving as
|
|
66
|
+
// request data. Weight and money do not always agree: a bracket step, or a minimum
|
|
67
|
+
// charge, can make the cheaper shipment the heavier one. Below the roomy box bills
|
|
68
|
+
// heavier (12,800 g of dimensional weight against the snug box's 5,400) and still costs
|
|
69
|
+
// less, because the snug box's carrier charges a steep first bracket.
|
|
70
|
+
const dearPerGram = box('snug', '300', {
|
|
71
|
+
rate_table: { weight_brackets_g: [6000, 20000], prices_minor: [2400, 3100] },
|
|
72
|
+
});
|
|
73
|
+
const cheapPerGram = box('roomy', '400', {
|
|
74
|
+
rate_table: { weight_brackets_g: [6000, 20000], prices_minor: [900, 1500] },
|
|
75
|
+
});
|
|
76
|
+
const byMoney = { seed: 42, objective: 'lowest_landed_cost', ...weightPricing };
|
|
77
|
+
console.log('lowest_landed_cost ', solve(byMoney, [dearPerGram, cheapPerGram]));
|
|
78
|
+
|
|
79
|
+
// A rate card that stops short of the shipment is a refusal, never a silent clamp to the
|
|
80
|
+
// top bracket -- you would otherwise be quoted a price the carrier never published.
|
|
81
|
+
const tooNarrow = box('roomy', '400', {
|
|
82
|
+
rate_table: { weight_brackets_g: [2000], prices_minor: [900] },
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
solve(byMoney, [tooNarrow]);
|
|
86
|
+
} catch (refusal) {
|
|
87
|
+
console.log('lowest_landed_cost* ', `refused: ${refusal.message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// `open_dimension_height` -- pack into the shortest stack, for a lidless container or a
|
|
91
|
+
// pallet that has to clear a doorway.
|
|
92
|
+
console.log('open_dimension_height',
|
|
93
|
+
solve({ seed: 42, objective: 'open_dimension_height' }, [snug, roomy]));
|
|
94
|
+
|
|
95
|
+
// `maximum_value` -- when not everything fits, leave the *cheap* things behind. It orders
|
|
96
|
+
// by value; it does not solve the knapsack problem to optimality. `quantity: 1` on the
|
|
97
|
+
// container is what makes it a choice at all -- with an unlimited supply the packer
|
|
98
|
+
// simply opens another box.
|
|
99
|
+
const scarce = pack({
|
|
100
|
+
units: { length: 'mm' },
|
|
101
|
+
configuration: { seed: 42, objective: 'maximum_value' },
|
|
102
|
+
items: [
|
|
103
|
+
{ id: 'gold', quantity: 2, dimensions: { length: '100', width: '100', height: '100' }, weight: '500 g', value: 90000 },
|
|
104
|
+
{ id: 'gravel', quantity: 2, dimensions: { length: '100', width: '100', height: '100' }, weight: '500 g', value: 10 },
|
|
105
|
+
],
|
|
106
|
+
containers: [{
|
|
107
|
+
id: 'tiny', quantity: 1,
|
|
108
|
+
inner_dimensions: { length: '200', width: '100', height: '100' },
|
|
109
|
+
max_payload: '20 kg',
|
|
110
|
+
}],
|
|
111
|
+
});
|
|
112
|
+
const kept = scarce.containers.flatMap((c) => c.placements.map((p) => p.item_type)).sort();
|
|
113
|
+
const left = (scarce.unpacked_items ?? []).map((u) => u.item_type ?? u.item_id).sort();
|
|
114
|
+
console.log('maximum_value ', `packed=${JSON.stringify(kept)} left behind=${JSON.stringify(left)}`);
|
package/fallback.js
CHANGED
|
@@ -72,6 +72,9 @@ function chargeMinor(table,grams){
|
|
|
72
72
|
// property of the request -- this depends on how the search happened to fill the box,
|
|
73
73
|
// so it must lose a candidate rather than abort the run.
|
|
74
74
|
const UNPRICEABLE=Number.MAX_SAFE_INTEGER;
|
|
75
|
+
// One wording for the refusal wherever it fires (outermost solve frame, rebalancing),
|
|
76
|
+
// so the four engines stay literally comparable.
|
|
77
|
+
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`);
|
|
75
78
|
const addLanded=(total,template,billedTicks)=>{
|
|
76
79
|
if(total===UNPRICEABLE)return total;
|
|
77
80
|
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
|
|
|
523
526
|
templates.push({...container,d:inner,outerD:outer,max:container.max_payload==null?null:scalar(container.max_payload,'g',WT),
|
|
524
527
|
tare:scalar(container.tare_weight??0,'g',WT),rate:parseRateTable(container.rate_table)})
|
|
525
528
|
}
|
|
526
|
-
// `lowest_landed_cost`
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
529
|
+
// `lowest_landed_cost` never reaches this path: packFallback forces `compact=null`
|
|
530
|
+
// for that objective (see the exclusion beside the policy-rule gate), because this
|
|
531
|
+
// path commits to one container from the billed-weight proxy with no priced
|
|
532
|
+
// alternative to correct it. The branch below is kept only so the key stays whole for
|
|
533
|
+
// `shipping_cost`, whose proxy it is; re-enabling compact for landed cost would
|
|
534
|
+
// resurrect the MAX_SAFE_INTEGER leak, since this return path has no refusal.
|
|
530
535
|
templates.sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost'
|
|
531
536
|
?dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))
|
|
532
537
|
:(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
|
|
|
663
668
|
const inner=volume(template.d);if(inner>0n)scoreUnused+=Number((inner-used)*1000000n/inner);
|
|
664
669
|
if(template.d[2]>0)scoreHeight+=Number(BigInt(layers*best.envelope[2])*1000000n/BigInt(template.d[2]));
|
|
665
670
|
scoreAchievedHeight+=layers*best.envelope[2];
|
|
671
|
+
// `lowest_landed_cost` cannot reach this path (packFallback excludes compact for
|
|
672
|
+
// it); if it ever could again, `addLanded`'s UNPRICEABLE sentinel would flow into
|
|
673
|
+
// `score` unrefused -- this return path never checks the finished answer against
|
|
674
|
+
// the tariff the way the outermost general-path frame does.
|
|
666
675
|
if(objective==='shipping_cost'||objective==='lowest_landed_cost'){
|
|
667
676
|
const billed=Math.max(payload+template.tare,dimensionalWeight(template.outerD));
|
|
668
677
|
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;
|
|
|
774
783
|
// a k-start request consume up to k*time_limit_ms while still reporting one portfolio
|
|
775
784
|
// deadline, which is both a determinism and an observability defect.
|
|
776
785
|
const deadline=sharedDeadline??new Deadline(req.configuration?.time_limit_ms??1000,clock);
|
|
786
|
+
// second review: the lowest_landed_cost refusal fires once, at the single
|
|
787
|
+
// outermost frame, on the packing actually selected for return -- the same choke point
|
|
788
|
+
// Rust, Python and PHP refuse at. A child solver/start run instead hands its result
|
|
789
|
+
// back sentinel and all, so a portfolio sibling with a priceable answer is not aborted
|
|
790
|
+
// by one run's refusal. Idempotent: the quality re-entry finalizes inside its callee.
|
|
791
|
+
const finalizeOutermost=result=>{
|
|
792
|
+
if(solverAlias!==null||startIndex!==null)return result;
|
|
793
|
+
if(result.unpriceableDetail!=null)throw unpriceableRefusal(result.unpriceableDetail);
|
|
794
|
+
// Belt and braces behind the portfolio branch's own filter: a sentinel-scored run
|
|
795
|
+
// must never leave the outermost frame by any route.
|
|
796
|
+
if(result.alternatives?.length)result.alternatives=result.alternatives.filter(a=>!a.unpriceableDetail);
|
|
797
|
+
return result
|
|
798
|
+
};
|
|
777
799
|
if(solverAlias===null&&requestedSolvers.length===0&&(req.configuration?.solver_profile??'balanced')==='quality'){
|
|
778
800
|
const child={...req,configuration:{...(req.configuration??{}),solvers:['homogeneous_blocks','extreme_points','maximal_spaces','layer']}};
|
|
779
801
|
return packFallback(child,clock,null,null,deadline)
|
|
@@ -803,8 +825,9 @@ if(solverAlias===null&&requestedSolvers.length){
|
|
|
803
825
|
winner.termination=aggregateTermination(starts);
|
|
804
826
|
winner.algorithm=withPortfolioEffort(winner,runs);
|
|
805
827
|
const alternativeLimit=Math.max(0,(req.configuration?.alternatives??3)-1);
|
|
806
|
-
|
|
807
|
-
|
|
828
|
+
// The sentinel is a search device, never an answer -- alternatives included ( review).
|
|
829
|
+
winner.alternatives=runs.filter((run,index)=>index!==winnerIndex&&!run.unpriceableDetail).sort((a,b)=>compareScore(a.score,b.score)).slice(0,alternativeLimit);
|
|
830
|
+
return finalizeOutermost(winner);
|
|
808
831
|
}
|
|
809
832
|
// This value used to be accepted and never read: raising it produced no extra
|
|
810
833
|
// 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){
|
|
|
829
852
|
winner.termination=aggregateTermination(starts);
|
|
830
853
|
winner.algorithm=withPortfolioEffort(winner,runs);
|
|
831
854
|
if(winnerIndex>0)winner.algorithm={...winner.algorithm,solver:`${winner.algorithm.solver}:seeded_${winnerIndex}`};
|
|
832
|
-
return winner;
|
|
855
|
+
return finalizeOutermost(winner);
|
|
833
856
|
}
|
|
834
857
|
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);
|
|
835
858
|
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)=>{
|
|
|
1177
1200
|
const weights=future.map(item=>profiles.get(item.id).weight).sort((a,b)=>a-b);
|
|
1178
1201
|
const grossWeight=weights.slice(0,placeable).reduce((sum,value)=>sum+value,work.state.payload+tmpl.tare);
|
|
1179
1202
|
const billable=objective==='shipping_cost'||objective==='lowest_landed_cost'?Math.max(grossWeight,dimensionalWeight(tmpl.outerD)):0;
|
|
1180
|
-
|
|
1203
|
+
// A promotional bracket may be cheaper than a lighter bracket, so pricing the
|
|
1204
|
+
// lightest possible completion is not an admissible lower bound. Tariff charges are
|
|
1205
|
+
// non-negative; zero is the general money floor and only loosens this exact search.
|
|
1206
|
+
const landed=0;
|
|
1181
1207
|
const cost=tmpl.cost_minor??0;
|
|
1182
1208
|
if(objective==='lowest_cost')return [unpackedFloor,cost,1,unused,height];
|
|
1183
1209
|
if(objective==='shipping_cost')return [unpackedFloor,billable,1,unused,height];
|
|
@@ -1315,7 +1341,21 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){
|
|
|
1315
1341
|
const trial=packIntoTemplate(tmpl,remaining);
|
|
1316
1342
|
if(!trial.state.placements.length)continue;
|
|
1317
1343
|
let score,better;
|
|
1318
|
-
|
|
1344
|
+
// `lowest_landed_cost` ranks the round in money: the trial's charge first, then
|
|
1345
|
+
// estimated rounds remaining, then progress -- the key order Rust, Python and PHP
|
|
1346
|
+
// use. `planScore`'s finished vector leads with unpacked count, which is
|
|
1347
|
+
// right for whole plans but inverted for one round: an unpriceable-but-roomier
|
|
1348
|
+
// trial out-ranked a priceable one on progress, refusing or over-paying requests
|
|
1349
|
+
// the other three engines ship. The greedy loop commits the trial verbatim, so
|
|
1350
|
+
// its billed weight is final here and the tariff can be read now; an unpriceable
|
|
1351
|
+
// trial still sorts behind every priceable alternative via `addLanded`'s sentinel.
|
|
1352
|
+
if(objective==='lowest_landed_cost'){
|
|
1353
|
+
const placed=trial.state.placements.length;
|
|
1354
|
+
const billed=Math.max(trial.state.payload+tmpl.tare,dimensionalWeight(tmpl.outerD));
|
|
1355
|
+
score=[addLanded(0,tmpl,billed),Math.ceil(remaining.length/Math.max(placed,1)),-placed];
|
|
1356
|
+
const comparison=winnerScore==null?-1:compareScore(score,winnerScore);
|
|
1357
|
+
better=comparison<0||(comparison===0&&tmpl.id<winner.tmpl.id)
|
|
1358
|
+
}else if(solverAlias==='exact_small'){
|
|
1319
1359
|
score=planScore({packed:[trial.state],remaining:trial.next});
|
|
1320
1360
|
const comparison=winnerScore==null?-1:compareScore(score,winnerScore);
|
|
1321
1361
|
better=comparison<0||(comparison===0&&tmpl.id<winner.tmpl.id)
|
|
@@ -1365,12 +1405,33 @@ for(const c of packed){scoreCost+=c.tmpl.cost_minor??0;
|
|
|
1365
1405
|
if(objective==='shipping_cost'||objective==='lowest_landed_cost'){
|
|
1366
1406
|
const billed=Math.max(c.payload+c.tmpl.tare,dimensionalWeight(c.tmpl.outerD));
|
|
1367
1407
|
if(objective==='shipping_cost')scoreBillable+=billed;else scoreLanded=addLanded(scoreLanded,c.tmpl,billed);}}
|
|
1408
|
+
// The search ranks an unpriceable packing worst so that any priceable alternative beats
|
|
1409
|
+
// it; reaching here means none existed in this run. Returning such a packing would
|
|
1410
|
+
// quote a number the carrier never published -- the one outcome `chargeMinor` refuses
|
|
1411
|
+
// to invent -- so the refusal fires, but once, at the outermost frame, on the packing
|
|
1412
|
+
// actually selected for return: a portfolio sibling with a priceable answer must not be
|
|
1413
|
+
// aborted by this run's refusal. Rust, Python and PHP refuse at the same single choke
|
|
1414
|
+
// point ( second review). The detail rides the result as a non-enumerable property
|
|
1415
|
+
// below, a search device that never serializes.
|
|
1416
|
+
let unpriceableDetail=null;
|
|
1417
|
+
if(objective==='lowest_landed_cost')for(const c of packed){
|
|
1418
|
+
const grams=billedGrams(Math.max(c.payload+c.tmpl.tare,dimensionalWeight(c.tmpl.outerD))),table=c.tmpl.rate;
|
|
1419
|
+
if(table!=null&&chargeMinor(table,grams)!=null)continue;
|
|
1420
|
+
// A missing table is already refused at admission, so `0` here is unreachable rather
|
|
1421
|
+
// than a real bound -- but reading a bracket off `null` would replace the refusal
|
|
1422
|
+
// with a TypeError, which is the one thing a refusal must not do. Rust, Python and PHP
|
|
1423
|
+
// report the same `0` on the same unreachable branch.
|
|
1424
|
+
unpriceableDetail={id:c.tmpl.id,grams,bound:table==null?0:table.brackets[table.brackets.length-1]};
|
|
1425
|
+
break
|
|
1426
|
+
}
|
|
1368
1427
|
const status=unpacked.length?(timeLimitReached?'time_limit':'best_found'):'feasible',complete=!unpacked.length,effortLimitReached=effortExceeded();
|
|
1369
1428
|
const solverName=solverAlias?`${solverAlias}:javascript_fallback`:'javascript_fallback';
|
|
1370
1429
|
const starts=[{id:solverName,started:true,completed:!timeLimitReached&&!effortLimitReached,truncated:timeLimitReached||effortLimitReached,selected:true,global_deadline_reached:timeLimitReached}],termination=aggregateTermination(starts);if(effortLimitReached&&!timeLimitReached)termination.code='effort_limit';
|
|
1371
1430
|
const scoreValueForgone=remaining.reduce((sum,i)=>sum+(i.value??0),0);
|
|
1372
1431
|
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;
|
|
1373
|
-
|
|
1432
|
+
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:[]};
|
|
1433
|
+
if(unpriceableDetail!=null)Object.defineProperty(result,'unpriceableDetail',{value:unpriceableDetail,enumerable:false,writable:false,configurable:true});
|
|
1434
|
+
return finalizeOutermost(result)}
|
|
1374
1435
|
|
|
1375
1436
|
function resultTicks(value,name){
|
|
1376
1437
|
const ticks=value&&typeof value==='object'&&Number.isSafeInteger(value.ticks)?value.ticks:null;
|
|
@@ -1519,8 +1580,39 @@ function publicRebalancedContainers(req,context){
|
|
|
1519
1580
|
*/
|
|
1520
1581
|
export function rebalanceWeight(req,result,{maxMoves=64}={}){
|
|
1521
1582
|
if(!Number.isSafeInteger(maxMoves)||maxMoves<0)throw new RangeError('maxMoves must be a non-negative safe integer');
|
|
1583
|
+
const objective=req.configuration?.objective??'default',dimDivisor=req.configuration?.dimensional_weight_divisor??null;
|
|
1584
|
+
if((objective==='shipping_cost'||objective==='lowest_landed_cost')&&dimDivisor==null)throw new RangeError(`the ${objective} objective requires configuration.dimensional_weight_divisor`);
|
|
1585
|
+
if(objective==='lowest_landed_cost'){
|
|
1586
|
+
const unrated=(req.containers??[]).find(container=>container.rate_table==null);
|
|
1587
|
+
if(unrated!=null)throw new RangeError(`the lowest_landed_cost objective requires a rate_table on every container; ${JSON.stringify(unrated.id)} has none`)
|
|
1588
|
+
}
|
|
1522
1589
|
const context=rebalanceContext(req,result),moves=[];
|
|
1523
1590
|
if(!rebalanceValid(context,result))throw new TypeError('result is not a valid packing of this request');
|
|
1591
|
+
// second review: under `lowest_landed_cost` a move is a re-pricing -- shifting
|
|
1592
|
+
// payload can push a destination past its rate table's last bracket, leaving the
|
|
1593
|
+
// "balanced" packing with no published price. States are priced with the same helpers
|
|
1594
|
+
// the packer bills with: an unpriceable input is refused up front in the standard
|
|
1595
|
+
// words, and a trial that turns any state unpriceable fails exactly like an invalid
|
|
1596
|
+
// one. Gated on the objective and divisor so every other request is byte-identical.
|
|
1597
|
+
let statesPriceable=null;
|
|
1598
|
+
if(objective==='lowest_landed_cost'){
|
|
1599
|
+
const lengthUnit=req.configuration?.dimensional_weight_length_unit??'in',weightUnit=req.configuration?.dimensional_weight_weight_unit??'lb';
|
|
1600
|
+
const dimensionalTicks=d=>Number(volume(d)*BigInt(WT[weightUnit])/(BigInt(LEN[lengthUnit])**3n*BigInt(dimDivisor)));
|
|
1601
|
+
// rebalanceContext keeps the raw rate_table; parse it once per container type with
|
|
1602
|
+
// the packer's own parser so both entry points refuse the same malformed tariffs.
|
|
1603
|
+
const pricing=new Map();
|
|
1604
|
+
const priceEntry=tmpl=>{
|
|
1605
|
+
let entry=pricing.get(tmpl.id);
|
|
1606
|
+
if(entry===undefined){entry={rate:parseRateTable(tmpl.rate_table),dimTicks:dimensionalTicks(tmpl.outerD)};pricing.set(tmpl.id,entry)}
|
|
1607
|
+
return entry};
|
|
1608
|
+
const unpriceableState=state=>{
|
|
1609
|
+
const entry=priceEntry(state.tmpl),payload=state.placements.reduce((total,placement)=>total+placement.item.w,0);
|
|
1610
|
+
const grams=billedGrams(Math.max(payload+state.tmpl.tare,entry.dimTicks));
|
|
1611
|
+
if(entry.rate!=null&&chargeMinor(entry.rate,grams)!=null)return null;
|
|
1612
|
+
return {id:state.tmpl.id,grams,bound:entry.rate==null?0:entry.rate.brackets[entry.rate.brackets.length-1]}};
|
|
1613
|
+
statesPriceable=()=>context.states.every(state=>unpriceableState(state)==null);
|
|
1614
|
+
for(const state of context.states){const detail=unpriceableState(state);if(detail!=null)throw unpriceableRefusal(detail)}
|
|
1615
|
+
}
|
|
1524
1616
|
for(let moveNumber=0;moveNumber<maxMoves;moveNumber++){
|
|
1525
1617
|
if(context.states.length<2)break;
|
|
1526
1618
|
const weights=context.states.map(state=>state.placements.reduce((total,placement)=>total+placement.item.w,0));
|
|
@@ -1540,7 +1632,7 @@ export function rebalanceWeight(req,result,{maxMoves=64}={}){
|
|
|
1540
1632
|
const [relocated]=trial[sourceIndex].placements.splice(placementIndex,1);
|
|
1541
1633
|
relocated.x=x;relocated.y=y;relocated.z=z;trial[destinationIndex].placements.push(relocated);
|
|
1542
1634
|
const originalStates=context.states;context.states=trial;
|
|
1543
|
-
if(rebalanceValid(context,result)){
|
|
1635
|
+
if(rebalanceValid(context,result)&&(statesPriceable==null||statesPriceable())){
|
|
1544
1636
|
committed={item_id:moving.item.id,from_container_id:originalStates[sourceIndex].publicContainer.id,to_container_id:originalStates[destinationIndex].publicContainer.id};
|
|
1545
1637
|
break search
|
|
1546
1638
|
}
|
package/index.d.ts
CHANGED
|
@@ -51,3 +51,15 @@ export function packJson(input:string):string;
|
|
|
51
51
|
export function rebalanceWeight(request:PackingRequest,result:PackingResult,options?:{maxMoves?:number}):RebalanceResult;
|
|
52
52
|
export function backend():"rust"|"javascript";
|
|
53
53
|
export function version():string;
|
|
54
|
+
/** One canonical commerce result document: see docs/COMMERCE-API.md. */
|
|
55
|
+
export type CommerceResult=Record<string,unknown>;
|
|
56
|
+
export class CommerceInputError extends Error{readonly name:'CommerceInputError'}
|
|
57
|
+
export const commerce:{
|
|
58
|
+
backend():"rust"|"javascript";
|
|
59
|
+
readonly API_VERSION:number;
|
|
60
|
+
readonly REJECTION_CODES:readonly string[];
|
|
61
|
+
canonicalJson(result:CommerceResult):string;
|
|
62
|
+
quote(document:unknown,request:unknown):CommerceResult;
|
|
63
|
+
evaluatePolicy(document:unknown,request:unknown):CommerceResult;
|
|
64
|
+
catalogVersionInfo(document:unknown,request:unknown):CommerceResult;
|
|
65
|
+
};
|
package/index.js
CHANGED
|
@@ -14,6 +14,8 @@ export {
|
|
|
14
14
|
explanationForUnpackedItem, explainUnpackedItem,
|
|
15
15
|
} from './fallback.js';
|
|
16
16
|
export { UnsupportedFeatureError };
|
|
17
|
+
import * as commerceFallback from './commerce.js';
|
|
18
|
+
export { CommerceInputError } from './commerce.js';
|
|
17
19
|
const require=createRequire(import.meta.url);
|
|
18
20
|
let native=null;
|
|
19
21
|
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}={}){
|
|
|
27
29
|
}
|
|
28
30
|
return rebalanceFallback(request,result,{maxMoves});
|
|
29
31
|
}
|
|
30
|
-
export const version=()=>native?.version?.()??'0.1.
|
|
32
|
+
export const version=()=>native?.version?.()??'0.1.2-js-fallback';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The exported commercial and control-plane API: a quote, a policy decision and catalog
|
|
36
|
+
* version metadata over one canonical JSON document (docs/COMMERCE-API.md).
|
|
37
|
+
*
|
|
38
|
+
* Native-first with a deterministic JavaScript fallback, the same backend selection the
|
|
39
|
+
* packing entry points use. The two agree on every shared fixture; `backend()` reports
|
|
40
|
+
* which one answered a `pack`, and `commerce.backend()` which one answers these.
|
|
41
|
+
*/
|
|
42
|
+
export const commerce = {
|
|
43
|
+
backend: () => (native?.commerceQuoteJson ? 'rust' : 'javascript'),
|
|
44
|
+
API_VERSION: commerceFallback.API_VERSION,
|
|
45
|
+
REJECTION_CODES: commerceFallback.REJECTION_CODES,
|
|
46
|
+
canonicalJson: commerceFallback.canonicalJson,
|
|
47
|
+
quote: (document, request) =>
|
|
48
|
+
viaNative(native?.commerceQuoteJson, document, request) ?? commerceFallback.quote(document, request),
|
|
49
|
+
evaluatePolicy: (document, request) =>
|
|
50
|
+
viaNative(native?.commerceEvaluatePolicyJson, document, request)
|
|
51
|
+
?? commerceFallback.evaluatePolicy(document, request),
|
|
52
|
+
catalogVersionInfo: (document, request) =>
|
|
53
|
+
viaNative(native?.commerceCatalogVersionInfoJson, document, request)
|
|
54
|
+
?? commerceFallback.catalogVersionInfo(document, request),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Call one native commerce entry point, or report that there is none.
|
|
59
|
+
*
|
|
60
|
+
* A native input error is re-thrown as the same `CommerceInputError` the fallback
|
|
61
|
+
* raises, so a caller never has to know which backend answered to catch the failure.
|
|
62
|
+
*/
|
|
63
|
+
function viaNative(entry, document, request) {
|
|
64
|
+
if (!entry) return null;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(entry(JSON.stringify({ document, request })));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new commerceFallback.CommerceInputError(error.message);
|
|
69
|
+
}
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name":"@packvium/engine",
|
|
3
|
-
"version":"0.1.
|
|
3
|
+
"version":"0.1.2",
|
|
4
4
|
"description":"Native-first 3D cartonization with deterministic JS fallback",
|
|
5
|
+
"keywords":["3d-bin-packing","bin-packing","cartonization","packing","container-loading","logistics","shipping","deterministic"],
|
|
6
|
+
"homepage":"https://github.com/toxakara/packvium-node#readme",
|
|
7
|
+
"repository":{"type":"git","url":"git+https://github.com/toxakara/packvium-node.git"},
|
|
8
|
+
"bugs":{"url":"https://github.com/toxakara/packvium-node/issues"},
|
|
5
9
|
"type":"module",
|
|
6
10
|
"main":"index.js",
|
|
7
11
|
"types":"index.d.ts",
|
|
8
12
|
"exports":{".":{"types":"./index.d.ts","import":"./index.js"}},
|
|
9
|
-
"files":["index.js","fallback.js","contact-graph.js","policy.js","index.d.ts","README.md"],
|
|
13
|
+
"files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","examples","index.d.ts","README.md","SECURITY.md"],
|
|
10
14
|
"engines":{"node":">=16"},
|
|
11
|
-
"optionalDependencies":{"@packvium/native":"0.1.
|
|
15
|
+
"optionalDependencies":{"@packvium/native":"0.1.2"},
|
|
12
16
|
"scripts":{
|
|
13
17
|
"test":"node --test \"test/*.test.mjs\"",
|
|
14
18
|
"test:legacy":"node test/legacy-conformance.mjs"
|