@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.
@@ -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` shares this key with `shipping_cost` deliberately: when a
527
- // container is opened its final billed weight is not yet known, so the tariff has
528
- // nothing to price. Billable weight is the monotone proxy the key already used, and
529
- // the finished answer is priced exactly below.
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
- winner.alternatives=runs.filter((_,index)=>index!==winnerIndex).sort((a,b)=>compareScore(a.score,b.score)).slice(0,alternativeLimit);
807
- return winner;
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
- const landed=objective==='lowest_landed_cost'?addLanded(0,tmpl,billable):0;
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
- if(solverAlias==='exact_small'){
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
- 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:[]}}
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.0-js-fallback';
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.0",
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.0"},
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"