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
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,50 @@ What changed in `@packvium/engine` on npm, release by release. The format follow
[Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.4.0]

Replanning a job that has already started, and container ids that match the other engines.
A request the schema never allowed is now refused instead of packed (see *Fixed*).

### Added

- **`fixed_placements`** in a request. Each entry pins an item type to a container type and
instance, at the origin a result reports, in one orientation. Fixed items keep their place,
count toward weight, support and top load, and come back marked `fixed: true`. A fixed set
that is not a valid packing on its own throws `FixedPlacementError` before any search.
- **`@packvium/engine/revisions.js`** — `rootRevision`, `deriveRevision`, `applyEvents`,
`verifyRevisionChain`, `documentDigest` and `canonicalRevisionJson`, with TypeScript
declarations. A `packvium-plan-revision/v1` chain records what happened on the dock —
`item_missing`, `container_substituted`, `placement_locked`, `placement_verified` — against
the artifact it changed, linked by SHA-256 from `node:crypto`, and carries the request the
next plan solves. Every Packvium engine computes the same bytes from the same inputs.
- **`InvalidRequestError`**, exported from the package root. A malformed request names what is
wrong: `code` is `invalid_request`, `reason` one of a closed set (`missing_field`,
`wrong_type`, `below_minimum`, `above_maximum`, `negative_measure`, `invalid_unit`,
`duplicate_id`, `not_allowed`, `invalid_value`), `field` the JSON Pointer of the bad value, and
the message reads `invalid_request: /items/0/quantity: must be at least 1`. It extends
`RangeError`, so existing handlers still catch it; `FixedPlacementError` extends it. The native
and JavaScript backends raise the same error.
- `examples/revisions.mjs`.

### Changed

- **Container ids number each type from 1.** Searches over more than one container type counted
containers across types, so a crate opened after a box was `crate#2` where the other engines
said `crate#1`. Only answers with more than one container type change, and only their ids.

### Fixed

- **Numbers below their floor are refused.** The JavaScript engine packed requests with
negative lengths, weights, payloads, clearances and obstacle positions, and with quantities,
limits and ratios below their minimum, and raised a `max_candidate_points` below 16 to 16
without saying so. Each now throws a `RangeError` naming what is wrong.
- **A `fixed_placements` that is not the schema's shape throws `FixedPlacementError`.** An
object in place of the list was ignored, so the items it named were repacked elsewhere; a
bad instance or orientation threw a bare `RangeError`.
- **A request key named `__proto__` survives a revision** as an ordinary key, instead of
making the canonical writer refuse the document.

## [1.3.0]

Portable operational artifacts, and a faster `quality` profile. Nothing breaks 1.2.0.
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,31 @@ const quote = commerce.quote(commerceDocument, {
console.log(quote.quote.total_minor);
```

## Errors

A request that no engine may answer throws `InvalidRequestError`, a `RangeError`, before anything
is solved, whichever backend is running. It names the problem instead of describing it:

```js
import { InvalidRequestError, pack } from '@packvium/engine';

try {
const result = pack(request);
} catch (error) {
if (!(error instanceof InvalidRequestError)) throw error;
error.code; // 'invalid_request'
error.reason; // 'below_minimum'
error.field; // '/items/0/quantity' -- a JSON Pointer into your request
error.message; // 'invalid_request: /items/0/quantity: must be at least 1'
}
```

`reason` is one of `missing_field`, `wrong_type`, `below_minimum`, `above_maximum`, `negative_measure`, `invalid_unit`, `duplicate_id`, `not_allowed` or `invalid_value`. `FixedPlacementError` extends it, with code
`invalid_fixed_placement` and reason `malformed` or `cannot_hold`.
The message is the same in every Packvium engine. Branch on `reason` and `field`; show the
message to a person. A request that is valid but does not fit completely is not an error: the
result lists what was left out, and why, in `unpacked_items`.

## Quotes, policy and catalog versions

`commerce` has three functions, all deterministic and all over one document you supply —
Expand Down Expand Up @@ -110,6 +135,7 @@ and execute without a project around it.
| [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. |
| [`execution.mjs`](examples/execution.mjs) | Turn a result into dock instructions — and the clearest place to see what "byte-identical" does and does not promise: the four adapters agree on any given result, while this engine is free to reach a different packing than Python does. |
| [`artifacts.mjs`](examples/artifacts.mjs) | Hand a result to a system with no engine: one document with the plan, geometry and the request that produced it, exported as CSV and a printable HTML work order, and a refusal for a format it does not know. |
| [`revisions.mjs`](examples/revisions.mjs) | Replan a half-loaded job: a missing item and a locked placement recorded against the approved plan, a replan that keeps the locked item in place, and a hash-chained record that notices an edit. |

```bash
node examples/basic.mjs
Expand All @@ -128,6 +154,9 @@ node examples/basic.mjs
- Portable operational artifacts through `buildOperationalArtifact()` from `artifacts.js`, exported
by `artifact-exports.js` as canonical JSON, CSV or a self-contained HTML work order — the same
bytes the Python, PHP and Rust packages write.
- Fixed placements (`fixed_placements`): items already loaded stay where they are and the solve
packs around them; plan revisions from `revisions.js` record exceptions against an approved
plan and derive the next request, hash-chained with the same bytes in all four engines.
- Deterministic carrier quotes, policy evaluation and effective-dated catalog lookup
through `commerce`.

Expand Down
2 changes: 1 addition & 1 deletion artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const FORMAT = 'packvium-operational-artifact/v1';
* The engine's own name is deliberately not recorded: four correct builders naming themselves
* would emit four different documents.
*/
export const SUITE_VERSION = '1.3.0';
export const SUITE_VERSION = '1.4.0';

/** The deterministic part of `result.algorithm`; `duration_ms` is wall-clock time. */
const SOLVER_FIELDS = ['profile', 'solver', 'seed', 'time_limit_reached', 'effort_limit_reached'];
Expand Down
19 changes: 19 additions & 0 deletions canonical-json.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ export function canonicalJson(value) {
return parts.join('');
}

/**
* The integer a JSON value is, judged by value as every engine can judge it: `1.0` is `1`
* (JavaScript cannot tell them apart once the text is parsed); `true`, `"1"` and `1.5` are
* not integers, and neither is anything past 2^53 - 1. Null when it is not one.
*/
export function jsonInteger(value) {
return Number.isSafeInteger(value) ? value : null;
}

/** How a refusal quotes a value: its canonical JSON, the one spelling four engines share. */
export function jsonSpelling(value) {
try {
return canonicalJson(value);
} catch (error) {
if (!(error instanceof CanonicalJsonError)) throw error;
return error.code === 'number_out_of_range' ? 'an out-of-range number' : 'an unspellable value';
}
}

function write(value, parts) {
if (value === null) {
parts.push('null');
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 `1.3.0` freezes the public API. Field names, status codes, the objective
Version `1.4.0` freezes the public API. Field names, status codes, the objective
vector, the numeric policy and the validation rules do not change without a major
version, so any `1.x` is a safe upgrade from any earlier `1.x`. A caret or tilde
constraint on `1.0` is enough; an exact pin is no longer required.
Expand Down
34 changes: 34 additions & 0 deletions docs/PUBLIC-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@
(`c` emitted containers, `r <= 6` rotations), so large identical quantities do not
create per-item JavaScript objects either. Requests outside the regular-lattice
preconditions keep using the general solver and may still return coordinates.
- `fixed_placements`: optional items already in a known place before the solve -- loaded,
or locked there by an operator. Each names an item type, a container type and instance
(from 1; the result emits that container as `<type>#<instance>`), the physical position a
result reports and an orientation, so a result placement can be fixed by quoting it. Fixed
items are real placements: they carry weight, support what is stacked on them, take top
load and count against payload. They take the first instances of their type, their
containers open first and are always kept, and each is reported unmoved with
`"fixed": true`. A fixed set that is not a valid packing on its own is refused before
search with `invalid_fixed_placement`. Python `Packer.pack(items, containers,
fixed_placements)` and PHP `Packer::pack($items, $containers, $fixedPlacements)` take
`FixedPlacement` values; Rust takes them in `PackingRequest.fixed_placements`, and every
JSON entry point reads the field. PLAN-REVISIONS.md is the contract;
all four engines implement it.
- `catalog_versions_used`: optional immutable catalog references resolved before
packing. Each reference has exactly `catalog_id`, positive `version`,
non-negative `published_at_epoch_ms`, and `content_digest`; catalog IDs are unique.
Expand Down Expand Up @@ -553,6 +566,27 @@ names the field that decided it.
`unknown_format`, `invalid_json`). An export or reader handed a format it does not know refuses
it rather than guessing.

## Plan revisions

An append-only, hash-chained record of what differed from an approved plan: a missing item,
a substituted carton, an operator lock or a verified placement. Each revision derives the
request the next plan solves; a replan is an ordinary solve of it, with locks and verified
placements carried as `fixed_placements`. A revision is a pure function of its parent, the
approved operational artifact and its events, and calls no solver or clock. The contract is
PLAN-REVISIONS.md.

| Language | Surface |
| --- | --- |
| Python | `packvium.revisions.root_revision(request)`, `.derive_revision(parent, approved_artifact, events)`, `.verify_revision_chain(revisions, artifacts=None)`, `.document_digest(document)`, `.canonical_revision_json(document)`; `packvium.revision_outcomes.record_revision(ledger, revision, decision_id, recorded_at)` |
| Rust | `packvium_core::revisions::root_revision_json(request)`, `derive_revision_json(parent, artifact, events)`, `apply_events_json(request, events)`, `verify_revision_chain_json(revisions, artifacts)`, `document_digest_json(document)`, `canonical_revision_json(document)`; SHA-256 from the `sha2` crate, so the WASM package hashes too |
| JavaScript | `rootRevision`, `deriveRevision`, `applyEvents`, `verifyRevisionChain`, `documentDigest`, `canonicalRevisionJson` from `@packvium/engine`'s `revisions.js`; SHA-256 from `node:crypto` |
| PHP | `Packvium\Revisions\PlanRevision::root($request)`, `::derive($parent, $approvedArtifact, $events)`, `::verifyChain($revisions, $artifacts)`, `::digest($document)`, `::canonicalJson($document)`, `::parse($json)`. Documents are `stdClass` trees, so `{}` and `[]` keep their digests |

Digests are SHA-256 over the RFC 8785 canonical bytes. The audit reports `revision_number`,
`parent_mismatch`, `sequence_gap`, `request_mismatch` and `artifact_mismatch` without
stopping at the first. Refusals use `invalid_revision`, `invalid_event`, `event_conflict`,
`invalid_artifact` and the canonical-form codes.

## Scenario and recommendation API

Scenario what-if comparison and catalog/rule recommendation publication, exported as
Expand Down
123 changes: 123 additions & 0 deletions examples/revisions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Replan a half-loaded job without losing what was already done, or the record of why.
*
* Run it:
*
* node examples/revisions.mjs
*
* A plan is approved, and the dock starts loading. Then something changes: a slab is missing
* from the shelf, and a cube that is already in the tote must stay exactly where it is. The next
* plan has to keep the cube in place and pack around it, and anyone auditing the job later has
* to see what changed, in what order, and which approved plan each change was recorded against.
*
* A plan revision is that record. It is append-only and hash-chained: each revision names its
* parent and the artifact it replaced by SHA-256, and carries the request the events produce.
* The replan is an ordinary solve of that request. Every Packvium engine computes the same
* revision bytes from the same inputs -- though this engine may reach a different, equally valid
* packing than Python does, and then the artifact it approves describes that packing.
*/

import { pack } from '../index.js';
import { buildOperationalArtifact } from '../artifacts.js';
import { PlanRevisionError, deriveRevision, rootRevision, verifyRevisionChain } from '../revisions.js';

const request = {
units: { length: 'mm' },
configuration: {
// Counted work decides where the search stops, not a clock: a plan the clock stopped
// could not be replayed, and its revision would say `replay: not_guaranteed`. The time
// limit is only a fuse, far above what this needs even on a slow or emulated host.
effort_budget: { max_search_nodes: 20000 },
time_limit_ms: 60000,
},
items: [
{ id: 'cube', quantity: 4, weight: '1 kg', dimensions: { length: '100', width: '100', height: '100' } },
{ id: 'slab', quantity: 2, weight: '2 kg', dimensions: { length: '200', width: '100', height: '50' } },
],
containers: [
{ id: 'tote', quantity: 2, inner_dimensions: { length: '200', width: '200', height: '150' } },
],
};
const rule = '='.repeat(78);

function section(title) {
console.log();
console.log(rule);
console.log(title);
console.log(rule);
}

// Solve a request and wrap the answer as the artifact the dock works from.
function approve(approvedRequest) {
return buildOperationalArtifact(approvedRequest, pack(structuredClone(approvedRequest)));
}

// --------------------------------------------------------------------------------------
section('1. The approved plan, and the revision that records it');

const root = rootRevision(request);
const approved = approve(root.request);
const firstStep = approved.plan.containers[0].steps[0].placement;
console.log(` revision ${root.revision}, parent ${root.parent}`);
console.log(` first step: a ${firstStep.item_type} at x=${firstStep.position_ticks.x} in container 0`);
console.log(`
The root records nothing against the request. It exists so the first change has a
parent to name.`);

// --------------------------------------------------------------------------------------
section('2. What happened on the dock, recorded against that plan');

const loaded = pack(structuredClone(root.request)).containers[0].placements[0];
const events = [
{ sequence: 1, type: 'placement_locked', placement: {
item_type: 'cube', container_type: 'tote', container_instance: 1,
position: { x: loaded.position.x.value, y: loaded.position.y.value, z: loaded.position.z.value },
orientation: loaded.orientation,
} },
{ sequence: 2, type: 'item_missing', item_type: 'slab', quantity: 1 },
];
const revision = deriveRevision(root, approved, events);
console.log(` revision ${revision.revision}, parent ${revision.parent.slice(0, 23)}...`);
console.log(` approved artifact ${revision.approved.artifact.slice(0, 23)}..., replay ${revision.approved.replay.level}`);
console.log(` slabs still to pack: ${revision.request.items[1].quantity}`);
console.log(` fixed placements: ${revision.request.fixed_placements.length}`);
console.log(`
The events are applied to the request, not to the result: one slab fewer, and the
cube becomes a fixed placement. The revision names its parent and the artifact it
replaces by SHA-256 over their RFC 8785 bytes, so every engine computes the same
digest.`);

// --------------------------------------------------------------------------------------
section('3. The replan keeps the cube where it is');

const replanned = pack(structuredClone(revision.request));
const fixed = replanned.containers.flatMap((container) => container.placements
.filter((placement) => placement.fixed)
.map((placement) => `${placement.item_id} in ${container.id}`));
const placed = replanned.containers.reduce((sum, container) => sum + container.placements.length, 0);
console.log(` fixed in the answer: ${fixed.join(', ')}`);
console.log(` items placed: ${placed}`);
console.log(`
An ordinary solve of the derived request, with nothing remembered from the first
one. The fixed cube is marked \`fixed: true\`, and the validator refuses any answer
that moves it.`);

// --------------------------------------------------------------------------------------
section('4. An audit that notices tampering, and a refusal instead of a guess');

const codes = (issues) => issues.map((issue) => issue.code).join(', ') || 'no issues';
console.log(` intact chain: ${codes(verifyRevisionChain([root, revision], [null, approved]))}`);
const edited = structuredClone(revision);
edited.request.items[1].quantity = 2;
console.log(` edited chain: ${codes(verifyRevisionChain([root, edited]))}`);
try {
deriveRevision(revision, approve(revision.request),
[{ sequence: 3, type: 'item_missing', item_type: 'pallet', quantity: 1 }]);
} catch (error) {
if (!(error instanceof PlanRevisionError)) throw error;
console.log(` a pallet that was never requested: refused with ${error.code}`);
}
console.log(`
An edited request no longer equals what its parent's request and its own events
produce, and any later revision would stop naming it as a parent. An event that
contradicts the request is refused by name rather than applied as a best guess.`);
Loading
Loading