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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,40 @@ The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/) and thi
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the caveat
that the public API is not frozen until `1.0.0`. Pin an exact version.

## [0.1.2]

An additive documentation and integration release. No packing request/result field or
solver algorithm changed.

### Added

- **Runnable examples and guided capability maps.** Python, PHP and Node now ship worked
examples covering every objective and the major constraint, units, serialization,
nested-packing and commerce paths. Their printed answers are retained and checked on
every release, and each package executes its own examples in its test suite.
- **A versioned carrier-connector contract and reference implementation in the public
workspace.** Connectors prepare ordinary rate-table data before a deterministic solve;
no network call or carrier module enters a packing engine. The contract, offline replay
harness, registry and synthetic carrier are application components rather than new
packing-package API fields.

### Changed

- Every package README now links the whole Packvium family — Python, PHP, Rust, Node,
browser, PHP FFI bridge and Python native selector — and the PyPI, npm, Packagist and
crates.io manifests carry repository, homepage and keyword metadata. The PyPI page shows
the same README as GitHub and states the real Python floor, 3.9.

### Fixed

- Connector responses are revalidated at runtime and bound to the registered carrier and
requested service. Incomplete brackets, cross-currency price comparison and mutable
replay/value-object state are refused instead of silently producing a wrong price.
- Linux x86_64 and ARM64 evidence follows the declared suite version, preventing a
current gate from rewriting a previous release's receipt.
- PHP 7.4 artifact generation safely completes the locked downgrade tool's partial-write
case and still fails closed if its single retry does not finish.

## [0.1.1]

A patch over `0.1.0`. Every package is released together at the new version, including
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,22 @@ node examples/basic.mjs
The native addon is optional. `npm install` works on unsupported platforms too; call
`backend()` if your application needs to know which implementation handled a request.

## The Packvium family

One request and result contract, implemented independently in four engines (Rust,
Python, PHP, JavaScript) and held to identical placements on a shared fixture set.
Pick the package for your stack; mixing them in one system is safe.

| Package | Install | Source |
| --- | --- | --- |
| Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) |
| PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) |
| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "0.1"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
| Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) |
| Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) |
| 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) |
| Python native selector — `packvium-native` | from source until the native wheels ship | [packvium-python-adapter](https://github.com/toxakara/packvium-python-adapter) |

## API and support

TypeScript declarations are included. See the package's `index.d.ts` for the complete
Expand Down
2 changes: 1 addition & 1 deletion docs/GUARANTEES.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ silently — if you need them, they belong in your own layer above this library.

## Status of this release

Version `0.1.1` is an early release. The public API is not yet frozen: field names,
Version `0.1.2` is an early release. The public API is not yet frozen: field names,
status codes and the objective vector may change before `1.0.0`. Pin an exact version.

The algorithm complexities documented in `ALGORITHMS-AND-COMPLEXITY.md` are design
Expand Down
114 changes: 114 additions & 0 deletions examples/objectives.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Objectives: six ways to be "best", and the scenes where they disagree.
*
* Run it:
*
* node examples/objectives.mjs
*
* Every solve returns the arrangement that scores best -- but "best" is a choice, and it
* is the one setting most likely to make the library look wrong when it is merely
* answering a different question than you meant to ask. This example builds scenes where
* two objectives genuinely pick different containers, so the difference is visible
* rather than asserted.
*
* The score is always a lexicographic array of exact integers, never a float, and its
* first key is always the unpacked count: no objective will ever leave an item behind to
* save money. The same request handed to the Python, PHP or Rust engine prints the same
* vector.
*/

import { pack } from '../index.js';

const widgets = [{
id: 'widget', quantity: 8,
dimensions: { length: '100', width: '100', height: '100' },
weight: '500 g',
}];

const solve = (configuration, containers) => {
const result = pack({ units: { length: 'mm' }, configuration, items: widgets, containers });
const chosen = result.containers.length > 0 ? result.containers[0].container_type : 'none';
return `${chosen.padEnd(6)} score=${JSON.stringify(result.score)}`;
};

const box = (id, side, extra = {}) => ({
id,
inner_dimensions: { length: side, width: side, height: side },
max_payload: '20 kg',
...extra,
});

const weightPricing = {
dimensional_weight_divisor: 5000,
dimensional_weight_length_unit: 'cm',
dimensional_weight_weight_unit: 'kg',
};

const snug = box('snug', '300', { cost_minor: 500 });
const roomy = box('roomy', '400', { cost_minor: 150 });

// `default` -- fewest containers, then tightest fit. What you want when the boxes are
// interchangeable and you are simply trying not to open another one.
console.log('default ', solve({ seed: 42 }, [snug, roomy]));

// `lowest_cost` -- the cheapest *packaging*. `cost_minor` is what the box costs you, so
// this is the objective for a warehouse buying cartons, not a shipper paying a carrier.
console.log('lowest_cost ', solve({ seed: 42, objective: 'lowest_cost' }, [snug, roomy]));

// `shipping_cost` -- carrier-billable *weight*: the greater of actual gross weight and
// dimensional weight. A big light box can bill more than a small heavy one, which is why
// this is not the same objective as `lowest_cost`. It needs a divisor and refuses rather
// than guessing one, because a wrong divisor silently misprices every shipment.
console.log('shipping_cost ',
solve({ seed: 42, objective: 'shipping_cost', ...weightPricing }, [snug, roomy]));

// `lowest_landed_cost` -- carrier-billable *money*, with the rate card arriving as
// request data. Weight and money do not always agree: a bracket step, or a minimum
// charge, can make the cheaper shipment the heavier one. Below the roomy box bills
// heavier (12,800 g of dimensional weight against the snug box's 5,400) and still costs
// less, because the snug box's carrier charges a steep first bracket.
const dearPerGram = box('snug', '300', {
rate_table: { weight_brackets_g: [6000, 20000], prices_minor: [2400, 3100] },
});
const cheapPerGram = box('roomy', '400', {
rate_table: { weight_brackets_g: [6000, 20000], prices_minor: [900, 1500] },
});
const byMoney = { seed: 42, objective: 'lowest_landed_cost', ...weightPricing };
console.log('lowest_landed_cost ', solve(byMoney, [dearPerGram, cheapPerGram]));

// A rate card that stops short of the shipment is a refusal, never a silent clamp to the
// top bracket -- you would otherwise be quoted a price the carrier never published.
const tooNarrow = box('roomy', '400', {
rate_table: { weight_brackets_g: [2000], prices_minor: [900] },
});
try {
solve(byMoney, [tooNarrow]);
} catch (refusal) {
console.log('lowest_landed_cost* ', `refused: ${refusal.message}`);
}

// `open_dimension_height` -- pack into the shortest stack, for a lidless container or a
// pallet that has to clear a doorway.
console.log('open_dimension_height',
solve({ seed: 42, objective: 'open_dimension_height' }, [snug, roomy]));

// `maximum_value` -- when not everything fits, leave the *cheap* things behind. It orders
// by value; it does not solve the knapsack problem to optimality. `quantity: 1` on the
// container is what makes it a choice at all -- with an unlimited supply the packer
// simply opens another box.
const scarce = pack({
units: { length: 'mm' },
configuration: { seed: 42, objective: 'maximum_value' },
items: [
{ id: 'gold', quantity: 2, dimensions: { length: '100', width: '100', height: '100' }, weight: '500 g', value: 90000 },
{ id: 'gravel', quantity: 2, dimensions: { length: '100', width: '100', height: '100' }, weight: '500 g', value: 10 },
],
containers: [{
id: 'tiny', quantity: 1,
inner_dimensions: { length: '200', width: '100', height: '100' },
max_payload: '20 kg',
}],
});
const kept = scarce.containers.flatMap((c) => c.placements.map((p) => p.item_type)).sort();
const left = (scarce.unpacked_items ?? []).map((u) => u.item_type ?? u.item_id).sort();
console.log('maximum_value ', `packed=${JSON.stringify(kept)} left behind=${JSON.stringify(left)}`);
2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function rebalanceWeight(request,result,{maxMoves=64}={}){
}
return rebalanceFallback(request,result,{maxMoves});
}
export const version=()=>native?.version?.()??'0.1.1-js-fallback';
export const version=()=>native?.version?.()??'0.1.2-js-fallback';

/**
* The exported commercial and control-plane API: a quote, a policy decision and catalog
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
{
"name":"@packvium/engine",
"version":"0.1.1",
"version":"0.1.2",
"description":"Native-first 3D cartonization with deterministic JS fallback",
"keywords":["3d-bin-packing","bin-packing","cartonization","packing","container-loading","logistics","shipping","deterministic"],
"homepage":"https://github.com/toxakara/packvium-node#readme",
"repository":{"type":"git","url":"git+https://github.com/toxakara/packvium-node.git"},
"bugs":{"url":"https://github.com/toxakara/packvium-node/issues"},
"type":"module",
"main":"index.js",
"types":"index.d.ts",
"exports":{".":{"types":"./index.d.ts","import":"./index.js"}},
"files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","examples","index.d.ts","README.md","SECURITY.md"],
"engines":{"node":">=16"},
"optionalDependencies":{"@packvium/native":"0.1.1"},
"optionalDependencies":{"@packvium/native":"0.1.2"},
"scripts":{
"test":"node --test \"test/*.test.mjs\"",
"test:legacy":"node test/legacy-conformance.mjs"
Expand Down
48 changes: 48 additions & 0 deletions test/examples.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Every shipped Node example is executable documentation.
*
* The workspace-level examples gate pins stdout. This package-level suite adds the same
* ownership checks the Python and PHP ports already have, so publishing only the Node
* tree cannot silently leave its examples untested.
*/

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';

const PACKAGE = dirname(dirname(fileURLToPath(import.meta.url)));
const EXAMPLES = join(PACKAGE, 'examples');
const files = readdirSync(EXAMPLES).filter((name) => name.endsWith('.mjs')).sort();

const mask = (output) => output
.replace(/("(?:duration_ms|elapsed_ms)":\s*)\d+/g, '$1<masked>')
.replace(/\b(?:native|javascript)\b(?= backend\b)|(?<=^backend: )\S+/gm, '<backend>')
.replace(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/g, '<version>');

const run = (name) => execFileSync(process.execPath, [join('examples', name)], {
cwd: PACKAGE,
encoding: 'utf8',
env: { ...process.env },
timeout: 300_000,
});

test('the examples directory is not empty', () => {
assert.notEqual(files.length, 0);
});

for (const name of files) {
test(`${name} runs, documents its command, and is deterministic`, () => {
const source = readFileSync(join(EXAMPLES, name), 'utf8');
assert.match(source, /Run it:/);
assert.ok(source.includes(`examples/${name}`));
assert.match(source, /from ['"]\.\.\/index\.js['"]/);

const first = run(name);
assert.notEqual(first.trim(), '');
assert.equal(mask(first), mask(run(name)));
});
}

Loading