From 23fcce02b093c0101c731a7c3233cb234400cc3c Mon Sep 17 00:00:00 2001 From: Anton Kara Date: Fri, 21 Aug 2026 12:43:52 -0400 Subject: [PATCH] chore(release): sync 0.1.2 from workspace --- CHANGELOG.md | 34 ++++++++++++ README.md | 16 ++++++ docs/GUARANTEES.md | 2 +- examples/objectives.mjs | 114 ++++++++++++++++++++++++++++++++++++++++ index.js | 2 +- package.json | 8 ++- test/examples.test.mjs | 48 +++++++++++++++++ 7 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 examples/objectives.mjs create mode 100644 test/examples.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index bb1897f..c04cba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index de19369..6a317da 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index e0a8728..499404a 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -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 diff --git a/examples/objectives.mjs b/examples/objectives.mjs new file mode 100644 index 0000000..4632bc5 --- /dev/null +++ b/examples/objectives.mjs @@ -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)}`); diff --git a/index.js b/index.js index ab2b166..44cef6b 100644 --- a/index.js +++ b/index.js @@ -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 diff --git a/package.json b/package.json index b08f791..5dee38b 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/test/examples.test.mjs b/test/examples.test.mjs new file mode 100644 index 0000000..439de37 --- /dev/null +++ b/test/examples.test.mjs @@ -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') + .replace(/\b(?:native|javascript)\b(?= backend\b)|(?<=^backend: )\S+/gm, '') + .replace(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/g, ''); + +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))); + }); +} +