From 8c23dd5836ab7d912be2f1aacb5c02af3732046c Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 27 Sep 2026 16:41:50 -0400 Subject: [PATCH] chore(release): 1.4.0 --- CHANGELOG.md | 44 +++ README.md | 29 ++ artifacts.js | 2 +- canonical-json.js | 19 ++ docs/GUARANTEES.md | 2 +- docs/PUBLIC-API.md | 34 +++ examples/revisions.mjs | 123 +++++++++ fallback.js | 440 +++++++++++++++++++++++++---- index.d.ts | 10 +- index.js | 19 +- package.json | 6 +- request-errors.js | 278 +++++++++++++++++++ revisions.d.ts | 41 +++ revisions.js | 489 +++++++++++++++++++++++++++++++++ test/fallback.test.mjs | 82 +++++- test/fixed-placements.test.mjs | 332 ++++++++++++++++++++++ test/package-exports.test.mjs | 13 +- test/request-errors.test.mjs | 201 ++++++++++++++ test/revisions.test.mjs | 477 ++++++++++++++++++++++++++++++++ test/validate.mjs | 29 ++ 20 files changed, 2593 insertions(+), 77 deletions(-) create mode 100644 examples/revisions.mjs create mode 100644 request-errors.js create mode 100644 revisions.d.ts create mode 100644 revisions.js create mode 100644 test/fixed-placements.test.mjs create mode 100644 test/request-errors.test.mjs create mode 100644 test/revisions.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 34220ca..de10395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index d65f1bf..c5cc3c3 100644 --- a/README.md +++ b/README.md @@ -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 — @@ -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 @@ -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`. diff --git a/artifacts.js b/artifacts.js index 66a7c98..da44560 100644 --- a/artifacts.js +++ b/artifacts.js @@ -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']; diff --git a/canonical-json.js b/canonical-json.js index 37f17da..34fe252 100644 --- a/canonical-json.js +++ b/canonical-json.js @@ -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'); diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 1e5eb73..11d9696 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 `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. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index d24251f..b3d3799 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -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 `#`), 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. @@ -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 diff --git a/examples/revisions.mjs b/examples/revisions.mjs new file mode 100644 index 0000000..20f87aa --- /dev/null +++ b/examples/revisions.mjs @@ -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.`); diff --git a/fallback.js b/fallback.js index 0ac48d2..f04d60f 100644 --- a/fallback.js +++ b/fallback.js @@ -1,12 +1,18 @@ +import { jsonInteger, jsonSpelling } from './canonical-json.js'; import { appendContactBox, buildContactGraph } from './contact-graph.js'; -import { compareCodePoints } from './commerce-model.js'; +import { CommerceInputError, compareCodePoints } from './commerce-model.js'; import { parsePolicy, policyRejection, provesUnplaceable, tagOccurrences } from './policy.js'; +import { InvalidRequestError, checkRequest, pointer } from './request-errors.js'; -const LEN={mm:16000,cm:160000,m:16000000,in:406400,inch:406400,inches:406400,ft:4876800,tick:1,ticks:1}; +export { InvalidRequestError }; + +const LEN={mm:16000,millimeter:16000,millimeters:16000,cm:160000,m:16000000,in:406400,inch:406400,inches:406400,ft:4876800,tick:1,ticks:1}; const WT={g:8000000,kg:8000000000,mg:8000,lb:3628738960,lbs:3628738960,oz:226796185,tick:1,ticks:1}; const ROT={LWH:[0,1,2],LHW:[0,2,1],WLH:[1,0,2],WHL:[1,2,0],HLW:[2,0,1],HWL:[2,1,0]}; const SUPPORT_SCALE=1000000; +const MIN_CANDIDATE_POINTS=16; const hasOwn=(value,key)=>Object.prototype.hasOwnProperty.call(value,key); +const isObject=value=>value!==null&&typeof value==='object'&&!Array.isArray(value); function cloneValue(value){ if(Array.isArray(value))return value.map(cloneValue); if(value!==null&&typeof value==='object'){ @@ -19,6 +25,8 @@ function cloneValue(value){ const UNSUPPORTED={ // Top-level scope: a block describing the whole request rather than one item or // container, which none of the per-entry loops below would ever see. + // `fixed_placements` left this list in 1.4.0, when this engine gained seeding, admission + // and the fixed-placement validator rule (docs/PLAN-REVISIONS.md). request:[], configuration:[], // `hull_vertices`, `compression_ratio` and `max_compression_pressure_kpa` left this list @@ -100,21 +108,226 @@ const addLanded=(total,template,billedTicks)=>{ export class UnsupportedFeatureError extends Error{ constructor(fields){super(`unsupported_feature: JavaScript fallback does not yet implement ${fields.join(', ')}; the request was rejected instead of silently ignoring public fields`);this.name='UnsupportedFeatureError';this.code='unsupported_feature';this.fields=fields} } +// The entries of a list that can carry a field at all. This guard runs before the request's +// shape is checked, so a list that is not one, or an entry that is not an object, is left for +// `checkRequest` to name rather than crashing here. +const objectsIn=value=>Array.isArray(value)?value.filter(isObject):[]; function rejectUnsupported(req){const fields=[]; for(const key of UNSUPPORTED.request)if(hasOwn(req,key))fields.push(key); - for(const key of UNSUPPORTED.configuration)if(hasOwn(req.configuration??{},key))fields.push(`configuration.${key}`); - for(const raw of req.items??[])for(const key of UNSUPPORTED.item)if(hasOwn(raw,key))fields.push(`item.${key}`); - for(const raw of req.containers??[]){ + for(const key of UNSUPPORTED.configuration)if(isObject(req.configuration)&&hasOwn(req.configuration,key))fields.push(`configuration.${key}`); + for(const raw of objectsIn(req.items))for(const key of UNSUPPORTED.item)if(hasOwn(raw,key))fields.push(`item.${key}`); + for(const raw of objectsIn(req.containers)){ for(const key of UNSUPPORTED.container)if(hasOwn(raw,key))fields.push(`container.${key}`); - for(const obstacle of raw.obstacles??[])for(const key of UNSUPPORTED.obstacle)if(hasOwn(obstacle,key))fields.push(`obstacle.${key}`); + for(const obstacle of objectsIn(raw.obstacles))for(const key of UNSUPPORTED.obstacle)if(hasOwn(obstacle,key))fields.push(`obstacle.${key}`); } - for(const raw of req.items??[]){const shape=raw?.shape_type; + for(const raw of objectsIn(req.items)){const shape=raw.shape_type; if(typeof shape==='string'&&UNSUPPORTED.shapeType.includes(shape))fields.push(`item.shape_type=${shape}`)} if(fields.length)throw new UnsupportedFeatureError([...new Set(fields)].sort()); } +/** + * The request's fixed placements are malformed (`reason` `malformed`, `field` the bad value) or + * cannot all hold (`cannot_hold`, `field` `/fixed_placements`), so no search is attempted + * (docs/PLAN-REVISIONS.md). The message is `packvium.fixed_placements`' word for word, and names + * no field: it predates the field and is pinned across engines. + */ +export class FixedPlacementError extends InvalidRequestError{ + static code='invalid_fixed_placement'; + static describe(field,detail){return `${this.code}: ${detail}`} + constructor(detail,reason='cannot_hold',field='/fixed_placements'){super(reason,field,detail);this.name='FixedPlacementError'} +} +const malformed=(detail,field)=>new FixedPlacementError(detail,'malformed',field); +const FIXED_REQUIRED=['item_type','container_type','orientation'],FIXED_FIELDS=[...FIXED_REQUIRED,'container_instance','position'],AXES=['x','y','z']; +const unknownKeys=(value,allowed)=>Object.keys(value).filter(key=>!allowed.includes(key)).sort(compareCodePoints); +/** + * The request's `fixed_placements` as JSON gave them, or a refusal naming the first entry + * that is not the schema's shape. Nothing is coerced: `"1"` and `true` are not instances, and + * a list is not a position. An absent or null field is no fixed placements. O(f). + */ +function requireFixedPlacementShapes(raw,u){ + if(raw==null)return []; + if(!Array.isArray(raw))throw malformed('fixed_placements is a list','/fixed_placements'); + raw.forEach((entry,index)=>requireFixedEntryShape(entry,`fixed_placements[${index}]`,pointer('fixed_placements',index),u)); + return raw +} +function requireFixedEntryShape(entry,where,field,u){ + if(!isObject(entry))throw malformed(`${where} is an object`,field); + const unknown=unknownKeys(entry,FIXED_FIELDS); + if(unknown.length)throw malformed(`${where} does not carry ${jsonSpelling(unknown)}`,field); + const missing=FIXED_REQUIRED.filter(name=>!hasOwn(entry,name)); + if(missing.length)throw malformed(`${where} needs ${jsonSpelling(missing)}`,field); + for(const name of ['item_type','container_type']) + if(typeof entry[name]!=='string'||!entry[name])throw malformed(`${where}.${name} is a non-empty string`,`${field}/${name}`); + if(!Object.keys(ROT).includes(entry.orientation))throw malformed(`${where}.orientation is one of the six codes`,`${field}/orientation`); + const instance=fixedInstance(entry); + if(instance===null||instance<1)throw malformed(`${where}.container_instance counts from 1`,`${field}/container_instance`); + const position=hasOwn(entry,'position')?entry.position:{}; + requirePointShape(position,`${where}.position`,`${field}/position`,malformed); + for(const axis of AXES)if(hasOwn(position,axis))requireCoordinate(position[axis],`${where}.position.${axis}`,`${field}/position/${axis}`,u); +} +// Parsed here, before admission, so a coordinate the length parser cannot read is refused as +// the entry's own fault, naming the value, rather than surfacing later as a bare parse error. +function requireCoordinate(value,where,field,u){ + try{scalar(value,u,LEN)} + catch(error){throw malformed(`${where} ${String(error?.message).includes('cannot be negative')?'cannot be negative':'is a measure'}`,field)} +} +const fixedInstance=entry=>jsonInteger(hasOwn(entry,'container_instance')?entry.container_instance:1); +/** + * A point is an object of `x`, `y` and `z` measures; what a measure may be is the length + * parser's to say, so only the values it would silently misread are refused here. `error` + * builds the refusal from its message and the JSON Pointer of the bad value. + */ +export function requirePointShape(point,where,field,error){ + if(!isObject(point))throw error(`${where} is a point object`,field); + const unknown=unknownKeys(point,AXES); + if(unknown.length)throw error(`${where} does not carry ${jsonSpelling(unknown)}`,field); + for(const axis of AXES)if(hasOwn(point,axis)&&(point[axis]===null||typeof point[axis]==='boolean'||Array.isArray(point[axis]))) + throw error(`${where}.${axis} is a measure`,`${field}/${axis}`); +} +/** + * Resolve `req.fixed_placements` into the containers the solve opens first, or refuse the + * request. Fixed items take the first instances of their type in listed order, and each + * enters as a real placement, so every rule that reads a container's placement list holds + * for it with no rule of its own. The checks run in `admit_fixed_placements`' order because + * the refusal text is compared across engines. + * + * O(f log f) to resolve f entries, plus one pass of `fixedSetIssue`, O(f^2) in its pairwise + * collision sweep. It runs once per solve frame, before search. + */ +function admitFixedPlacements(req,{items,templates,u,clear}){ + const entries=requireFixedPlacementShapes(req.fixed_placements,u);if(!entries.length)return []; + const rawItems=new Map((req.items??[]).map(raw=>[raw.id,raw])),byId=new Map(templates.map(t=>[t.id,t])); + const instances=new Map(items.map(item=>[item.id,item])),order=new Map((req.containers??[]).map((raw,index)=>[raw.id,index])); + const parsed=entries.map(entry=>{const point=hasOwn(entry,'position')?entry.position:{}; + return {itemType:entry.item_type,containerType:entry.container_type,instance:fixedInstance(entry),orientation:entry.orientation, + position:AXES.map(axis=>scalar(hasOwn(point,axis)?point[axis]:0,u,LEN))}}); + for(const entry of parsed){const raw=rawItems.get(entry.itemType); + if(raw===undefined)throw new FixedPlacementError(`unknown item type ${jsonSpelling(entry.itemType)}`); + if(!byId.has(entry.containerType))throw new FixedPlacementError(`unknown container type ${jsonSpelling(entry.containerType)}`); + if(!(raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT))).includes(entry.orientation)) + throw new FixedPlacementError(`${entry.itemType} may not be placed in orientation ${entry.orientation}`)} + const taken=new Map(),assigned=parsed.map(entry=>{const count=(taken.get(entry.itemType)??0)+1,quantity=rawItems.get(entry.itemType).quantity??1;taken.set(entry.itemType,count); + if(count>quantity)throw new FixedPlacementError(`${count} ${entry.itemType} fixed, ${quantity} requested`); + return instances.get(`${entry.itemType}#${count}`)}); + const grouped=new Map(); + parsed.forEach((entry,index)=>{const item=assigned[index],[px,py,pz]=entry.position; + // The clearance envelope must be inside the container, as for any placement; an item + // flush against a wall has an envelope that starts before it. + if(Math.min(px,py,pz)edge+2*clear),r:entry.orientation,item,fixed:true})}); + const groups=[...grouped.values()].sort((a,b)=>order.get(a.tmpl.id)-order.get(b.tmpl.id)||a.seq-b.seq); + requireContiguousInstances(groups,req.configuration?.max_containers??null); + // Support is measured against every other fixed item in the container, not only the ones + // listed before it: the set is one arrangement, and listing order is not physics. + for(const group of groups)for(const placement of group.placements)placement.fixedSupport=placement.z===0?1 + :directSupporters(placement,group.placements).reduce((total,support)=>total+support.area,0)/(placement.ed[0]*placement.ed[1]); + const issue=fixedSetIssue(groups,req,clear); + if(issue!==null)throw new FixedPlacementError(issue); + return groups +} +function requireContiguousInstances(groups,maxContainers){ + const named=new Map();for(const group of groups){if(!named.has(group.tmpl.id))named.set(group.tmpl.id,[]);named.get(group.tmpl.id).push(group.seq)} + for(const [id,sequences] of named){ + if(sequences.some((sequence,index)=>sequence!==index+1))throw new FixedPlacementError(`${id} instances ${jsonSpelling(sequences)} are not numbered 1..${sequences.length}`); + const quantity=byTemplate(groups,id).quantity; + if(quantity!=null&&sequences.length>quantity)throw new FixedPlacementError(`${sequences.length} ${id} named, ${quantity} available`)} + if(maxContainers!=null&&groups.length>maxContainers)throw new FixedPlacementError(`${groups.length} containers hold fixed items, max_containers is ${maxContainers}`) +} +const byTemplate=(groups,id)=>groups.find(group=>group.tmpl.id===id).tmpl; +/** + * The first issue `IndependentSolutionValidator` reports for the fixed set alone, as + * `code: detail`, or null. Same rules, same order: collisions, then per placement bounds, + * obstacles, floor, eligibility, compatibility, tag counts, support and stacking, then per + * container load, axles, route, payload and item count, then groups. Item accounting is + * left out because the free items have not been placed yet. The support-polygon centroid + * test is absent because this engine does not enforce it in search either + * (docs/VALIDATION-CONTRACT.md). + */ +function fixedSetIssue(groups,req,clear){ + const globalRatio=req.configuration?.minimum_support_ratio??0,globalPpm=Math.round(globalRatio*SUPPORT_SCALE),located=new Map(); + for(const group of groups){const {tmpl,placements}=group,cid=`${tmpl.id}#${group.seq}`,boxOf=p=>({x:p.x,y:p.y,z:p.z,d:p.ed}); + for(let i=0;ip.item.tags.length||p.item.incompatible.length); + const support=globalRatio>0||placements.some(p=>p.item.supportPpm>0||(p.item.groundRule!=null&&p.item.groundRule!=='free')); + const stack=placements.some(p=>!p.item.stackable||p.item.maxTop!=null||p.item.maxStacked!=null||p.item.maxCompressionKpa!=null); + const boxes=placements.map(constraintBox),graph=contactGraph(boxes); + for(let index=0;indexk!==index); + if(p.x+p.ed[0]>tmpl.d[0]||p.y+p.ed[1]>tmpl.d[1]||p.z+p.ed[2]>tmpl.d[2])return `outside_container: ${id}`; + if(tmpl.obs.some(o=>intersects(box,o)&&solidsOverlap(placedHull(p),box,null,o)))return `obstacle_collision: ${id}`; + if(item.raw.must_be_on_floor&&p.z!==0)return `must_be_on_floor: ${id}: `; + if(item.eligibleTags.length&&!item.eligibleTags.some(tag=>(tmpl.tags??[]).includes(tag)))return `container_ineligible: ${id}`; + if(compatibility){const rule=compatibilityIssue(item,others,tmpl);if(rule!==null)return `${rule[0]}: ${id}: ${rule[1]}`} + if(support){const rule=supportIssue(p,others,globalRatio,globalPpm);if(rule!==null)return `${rule[0]}: ${id}: ${rule[1]}`} + if(stack){const lower=graph.supporters[index].find(([k])=>!placements[k].item.stackable); + if(lower!==undefined)return `non_stackable: ${id}: ${placements[lower[0]].item.raw.id}`; + if(!item.stackable&&graph.children[index].length)return `non_stackable: ${id}: ${item.raw.id}`}} + const bearing=loadIssue(boxes,graph,placements,tmpl);if(bearing!==null)return `${bearing[0]}: ${cid}: ${bearing[1]}`; + const reaction=axleReactions(tmpl,placements); + if(reaction!=null){const [front,rear]=tmpl.axleSpec; + if(front.max!=null&&reaction.front>BigInt(front.max)*reaction.denominator)return `axle_overloaded: ${cid}: front`; + if(rear.max!=null&&reaction.rear>BigInt(rear.max)*reaction.denominator)return `axle_overloaded: ${cid}: rear`} + if(placements.some(p=>p.item.stopIndex!=null)){const stuck=routeStuck(placements,tmpl,clear); + if(stuck!==null)return `unloading_order_violation: ${cid}: stop ${stuck.stop} cannot be fully unloaded (${stuck.indices.map(k=>placements[k].item.id).join(', ')} still blocked)`} + if(tmpl.max!=null&&placements.reduce((total,p)=>total+p.item.w,0)>tmpl.max)return `payload_exceeded: ${cid}`; + if(tmpl.max_items!=null&&placements.length>tmpl.max_items)return `max_items_exceeded: ${cid}`; + for(const p of placements)if(p.item.group!=null){if(!located.has(p.item.group))located.set(p.item.group,new Set());located.get(p.item.group).add(cid)}} + for(const group of [...located.keys()].sort(compareCodePoints)){const where=[...located.get(group)].sort(compareCodePoints); + if(where.length>1)return `group_split: ${group}: ${where.join(', ')}`} + return null +} +function compatibilityIssue(item,others,tmpl){ + if(item.tags.length||item.incompatible.length)for(const other of others) + if(item.incompatible.some(tag=>other.item.tags.includes(tag))||other.item.incompatible.some(tag=>item.tags.includes(tag))) + return ['incompatible_items',`${item.raw.id} is incompatible with ${other.item.raw.id}`]; + for(const tag of item.tags.filter(tag=>tmpl.tagLimits[tag]!=null).sort(compareCodePoints)){ + const limit=tmpl.tagLimits[tag],count=others.filter(other=>other.item.tags.includes(tag)).length; + if(count+1>limit)return ['tag_count_exceeded',`${tag}: limit ${limit}, would be ${count+1}`]} + return null +} +function supportIssue(p,others,globalRatio,globalPpm){ + const rule=p.item.groundRule,required=Math.max(globalRatio,p.item.raw.minimum_support_ratio??0); + if(p.z===0||((rule==null||rule==='free')&&required<=0))return null; + const supporters=directSupporters(p,others),count=supporters.length; + if(rule==='covered'){const surfaces=others.filter(o=>o.z+o.ed[2]===p.z),x2=p.x+p.ed[0],y2=p.y+p.ed[1]; + if(![[p.x,p.y],[x2,p.y],[p.x,y2],[x2,y2]].every(([x,y])=>surfaces.some(s=>s.x<=x&&x<=s.x+s.ed[0]&&s.y<=y&&y<=s.y+s.ed[1]))) + return ['ground_contact_violation',`covered: ${count} supporter(s), not all four corners touched`]} + if(rule==='single'&&count!==1)return ['ground_contact_violation',`single: rests on ${count} item(s)`]; + if(rule==='multiple'&&count<2)return ['ground_contact_violation',`multiple: rests on ${count} item(s)`]; + if(required<=0)return null; + const area=supporters.reduce((total,support)=>total+support.area,0),base=p.ed[0]*p.ed[1]; + return areaplacements[index].item.id; + let index=boxes.findIndex((b,i)=>b.maxTop!=null&&loads[i]>BigInt(b.maxTop));if(index>=0)return ['top_load_exceeded',label(index)]; + index=boxes.findIndex((b,i)=>b.maxCompressionKpa!=null&&pressureExceeds(appliedPressure(Number(loads[i]),b.d[0]*b.d[1]),b.maxCompressionKpa)); + if(index>=0)return ['crush_violation',label(index)]; + index=boxes.findIndex((b,root)=>{if(b.maxStacked==null)return false;const seen=new Set(),pending=[...graph.children[root]]; + while(pending.length){const k=pending.pop();if(!seen.has(k)){seen.add(k);pending.push(...graph.children[k])}}return seen.size>b.maxStacked}); + if(index>=0)return ['stacked_item_limit_exceeded',label(index)]; + if(tmpl.maxStackDensity!=null){const squareMetre=16000000n*16000000n; + index=boxes.findIndex((b,i)=>(BigInt(b.w)+loads[i])*squareMetre>BigInt(tmpl.maxStackDensity)*BigInt(b.d[0])*BigInt(b.d[1])); + if(index>=0)return ['stack_density_exceeded',label(index)]} + return null +} +// `safe_route_removal_order` over the physical boxes: the first stop that cannot be emptied, +// with every placement still due there, or null. +function routeStuck(placements,tmpl,clear){ + const boxes=placements.map(p=>({x:p.x+clear,y:p.y+clear,z:p.z+clear,d:{length:p.pd[0],width:p.pd[1],height:p.pd[2]}})); + const container={length:tmpl.d[0],width:tmpl.d[1],height:tmpl.d[2]},dependsOn=unloadingDependencies(boxes),present=new Set(boxes.map((_,k)=>k)); + for(const stop of [...new Set(placements.map(p=>p.item.stopIndex).filter(s=>s!=null))].sort((a,b)=>a-b)){ + let due=placements.map((p,k)=>k).filter(k=>placements[k].item.stopIndex===stop); + while(due.length){const removable=due.filter(k=>!dependsOn[k].some(d=>present.has(d))&&clearDirection(k,boxes,present,container,ALL_DIRECTIONS)!=null); + if(!removable.length)return {stop,indices:due}; + const chosen=Math.min(...removable);present.delete(chosen);due=due.filter(k=>k!==chosen)}} + return null +} function rat(s){s=String(s).trim();if(s.includes(' ')){const [w,f]=s.split(/\s+/,2),[n,d]=f.split('/').map(BigInt),wb=BigInt(w),sg=s.startsWith('-')?-1n:1n,mag=(wb<0n?-wb:wb)*d+n;return [sg*mag,d]}if(s.includes('/')){const[n,d]=s.split('/').map(BigInt);return[n,d]}if(s.includes('.')){const neg=s.startsWith('-'),[a,b]=s.replace(/^[-+]/,'').split('.');const d=10n**BigInt(b.length),n=BigInt(a)*d+BigInt(b);return[neg?-n:n,d]}return[BigInt(s),1n]} function round(n,d){const sg=n<0n?-1n:1n,a=n<0n?-n:n,q=a/d,r=a%d;const z=r*2nd?q+1n:q%2n===0n?q:q+1n;return sg*z} -function scalar(v,def,table){let raw=v,unit=def;if(v&&typeof v==='object'){raw=v.value;unit=v.unit??def}else if(typeof v==='string'){const m=v.trim().match(/^(.+?)\s*(millimeters|millimeter|inches|ticks|inch|lbs|tick|mm|cm|ft|in|mg|kg|oz|lb|g|m)$/i);if(m){raw=m[1];unit=m[2]}}const[n,d]=rat(raw);return Number(round(n*BigInt(table[unit.toLowerCase()]),d))} +function scalar(v,def,table){let raw=v,unit=def;if(v&&typeof v==='object'){raw=v.value;unit=v.unit??def}else if(typeof v==='string'){const m=v.trim().match(/^(.+?)\s*(millimeters|millimeter|inches|ticks|inch|lbs|tick|mm|cm|ft|in|mg|kg|oz|lb|g|m)$/i);if(m){raw=m[1];unit=m[2]}}const[n,d]=rat(raw),ticks=Number(round(n*BigInt(table[unit.toLowerCase()]),d));if(ticks<0)throw new RangeError(`${table===WT?'weight':'length'} cannot be negative`);return ticks} function dims(v,u){return [scalar(v.length,u,LEN),scalar(v.width,u,LEN),scalar(v.height,u,LEN)]} function rotate(d,r){return ROT[r].map(i=>d[i])} function volume(d){return BigInt(d[0])*BigInt(d[1])*BigInt(d[2])} @@ -1262,6 +1475,8 @@ function lessThan(left,right){ * general path's own item-building loop is what let the two disagree. */ function admitItem(raw,u){ + if(!Number.isSafeInteger(raw.quantity??1)||(raw.quantity??1)<1)throw new RangeError('item quantity must be positive'); + if(!isUnitRatio(raw.minimum_support_ratio??0))throw new RangeError('minimum_support_ratio must be between 0 and 1'); const d=dims(raw.dimensions,u),nesting=raw.nesting_height==null?null:scalar(raw.nesting_height,u,LEN); if(nesting!=null&&(nesting<0||nesting>=d[2]))throw new RangeError("nesting_height must be at least zero and strictly less than the item's own height"); if(raw.max_stacked_items!=null&&(!Number.isSafeInteger(raw.max_stacked_items)||raw.max_stacked_items<1))throw new RangeError('max_stacked_items must be a positive safe integer'); @@ -1275,6 +1490,30 @@ function admitItem(raw,u){ if(raw.eligible_container_tags!=null&&(!Array.isArray(raw.eligible_container_tags)||raw.eligible_container_tags.some(tag=>typeof tag!=='string')))throw new TypeError('eligible_container_tags must be an array of strings'); } +const isUnitRatio=value=>value>=0&&value<=1; +const isAtLeast=(value,floor)=>Number.isSafeInteger(value)&&value>=floor; + +/** + * Reject a container the contract does not admit, before either solver path runs -- the + * compact lattice path returns before container templates are built (see `admitItem`). + */ +function admitContainer(raw){ + if(raw.quantity!=null&&!isAtLeast(raw.quantity,1))throw new RangeError('container quantity must be positive'); + if(raw.max_items!=null&&!isAtLeast(raw.max_items,1))throw new RangeError('container max_items must be at least 1'); + if(raw.cost_minor!=null&&!isAtLeast(raw.cost_minor,0))throw new RangeError('container cost cannot be negative'); + if(raw.void_fill_reserve_ratio!=null&&(typeof raw.void_fill_reserve_ratio!=='number'||!Number.isFinite(raw.void_fill_reserve_ratio)))throw new TypeError('void_fill_reserve_ratio must be a finite number'); + if(!isUnitRatio(raw.void_fill_reserve_ratio??0))throw new RangeError('void_fill_reserve_ratio must be between 0 and 1'); +} + +/** Reject configuration limits below the floors the reference engine enforces. */ +function admitConfiguration(cfg){ + for(const name of ['time_limit_ms','alternatives','exact_item_limit']) + if(cfg[name]!=null&&!isAtLeast(cfg[name],1))throw new RangeError('positive configuration values required'); + if(cfg.max_containers!=null&&!isAtLeast(cfg.max_containers,1))throw new RangeError('max_containers must be at least 1'); + if(cfg.max_candidate_points!=null&&!isAtLeast(cfg.max_candidate_points,MIN_CANDIDATE_POINTS))throw new RangeError(`max_candidate_points must be at least ${MIN_CANDIDATE_POINTS}`); + if(!isUnitRatio(cfg.minimum_support_ratio??0))throw new RangeError('minimum_support_ratio must be between 0 and 1'); +} + /** * The `count` placements of one regular lattice, in fill order (x fastest, then y, then * layer). @@ -1305,6 +1544,9 @@ function latticePlacements({best,count,firstIndex,raw,weight,clear,ou,ow}){ return placements } +// Any `fixed_placements` but an empty list stands the closed-form path down; a malformed one +// goes on to admission, which refuses it, rather than being answered as if it were absent. +const hasFixedPlacements=req=>Array.isArray(req.fixed_placements)?req.fixed_placements.length>0:req.fixed_placements!=null; function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortExceeded,effortRemaining,deadline}){ // Keep this path opt-in until its capacity-first container choice is // proven objective-equivalent to the general path. Admission, effort accounting, @@ -1313,7 +1555,9 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver // retained lattice in `latticePlacements` is O(count), while compact output remains // O(c*r) and keeps regression-many-container-types below the scaling budget. const wantCoordinates=req.configuration?.require_placement_coordinates!==false; - if((solverAlias!=null&&solverAlias!=='grid')||req.items?.length!==1)return null; + // A container holding fixed items is not an empty box, and this path places every item of + // the request by formula; it stands down for the whole request, as it does for obstacles. + if((solverAlias!=null&&solverAlias!=='grid')||req.items?.length!==1||hasFixedPlacements(req))return null; const raw=req.items[0],quantity=raw.quantity??1; if(!Number.isSafeInteger(quantity)||quantity<1||raw.group!=null||(raw.tags??[]).length||(raw.incompatible_tags??[]).length ||(raw.eligible_container_tags??[]).length||raw.max_stacked_items!=null||raw.nesting_height!=null @@ -1344,7 +1588,7 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver templates.sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost' ?dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d)) :(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))); - let remaining=quantity,sequence=0,scoreCost=0,scoreUnused=0,scoreHeight=0,scoreBillable=0,scoreLanded=0,scoreAchievedHeight=0; + let remaining=quantity,scoreCost=0,scoreUnused=0,scoreHeight=0,scoreBillable=0,scoreLanded=0,scoreAchievedHeight=0; const containers=[],maxContainers=req.configuration?.max_containers??Infinity; // Per-unit capacity depends only on (item, template), both fixed for the // whole solve, so it is computed once per template rather than recomputed on @@ -1457,10 +1701,10 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver const summary={...best,count,weight,clearance:clear}; const used=volume(best.physical)*BigInt(count),payload=weight*count; const firstIndex=quantity-remaining+1; - sequence++;remaining-=count;metrics.search_nodes_expanded+=count; + remaining-=count;metrics.search_nodes_expanded+=count; metrics.candidate_points_considered+=count;metrics.orientations_considered+=count;metrics.feasible_candidates+=count; containers.push({ - id:`${template.id}#${sequence}`,container_type:template.id,inner_dimensions:outDims(template.d,ou), + id:`${template.id}#${plan.opened}`,container_type:template.id,inner_dimensions:outDims(template.d,ou), outer_dimensions:outDims(template.outerD,ou),payload_weight:outWeight(payload,ow), gross_weight:outWeight(payload+template.tare,ow),used_volume_ticks3:used.toString(), volume_utilization:(Number(used)/Number(volume(template.d))).toFixed(6), @@ -1577,7 +1821,48 @@ function unstartedRecord(solverAlias,index,globalDeadlineReached){return { id:startRecordId(solverAlias,index),started:false,completed:false,truncated:false,selected:false, global_deadline_reached:globalDeadlineReached, }} -export function packFallback(req,clock=Date.now,solverAlias=null,startIndex=null,sharedDeadline=null){rejectUnsupported(req); +// The engine's own unit tables and parser, handed to the preflight so a measure means there +// exactly what it means when the model is built. +const MEASURES=Object.freeze({length:{units:LEN,parse:(value,unit)=>scalar(value,unit,LEN)},weight:{units:WT,parse:(value,unit)=>scalar(value,unit,WT)}}); +/** + * Solve a JSON request. A request no engine may answer throws `InvalidRequestError` (or its + * subclass `FixedPlacementError`) naming the bad value, and nothing is solved. + * + * The rule table runs first, over the raw JSON (`checkRequest`). Whatever it does not name and + * still stops the request being read into the model reaches the caller as the same error with + * reason `invalid_value` -- but only until the search starts (`phase.solving`), so a solver + * defect is never dressed up as the caller's mistake. An error that already carries its own + * `code` (unsupported feature, policy, fixed placement, direction) passes through unchanged. + */ +export function packFallback(req,clock=Date.now,solverAlias=null,startIndex=null,sharedDeadline=null){ + if(!isObject(req))throw new InvalidRequestError('wrong_type','','must be an object'); + rejectUnsupported(req); + checkRequest(req,MEASURES); + return solveChecked(req,clock,solverAlias,startIndex,sharedDeadline); +} +/** + * The rule table alone, for a caller about to hand the request to the native engine: the + * native binding reports errors as text, and a caller should get the same `InvalidRequestError` + * whichever backend answers. Which fields a backend implements is its own business, so the + * unsupported-feature check is not part of it. + */ +export function checkRequestShape(req){ + if(!isObject(req))throw new InvalidRequestError('wrong_type','','must be an object'); + checkRequest(req,MEASURES); +} +function solveChecked(req,clock,solverAlias,startIndex,sharedDeadline){ + const phase={solving:false}; + try{return solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,phase)} + catch(error){throw phase.solving?error:asRequestError(error)} +} +function asRequestError(error){ + if(!(error instanceof Error)||'code' in error||error instanceof CommerceInputError)return error; + return new InvalidRequestError('invalid_value','',error.message) +} +// One frame of the solve: the outermost call, a portfolio member or one start of a multi-start +// run. Every frame reads the request into its model before it searches; `phase` is shared by +// all frames of one call and flips when the first search begins. +function solveFrame(req,clock,solverAlias,startIndex,sharedDeadline,phase){ // Admit the doors here, beside the other request-admission checks, and not // where they are canonicalised. The container template is built after the // uniform-lattice fast path has already returned, so validating there let a request @@ -1587,10 +1872,12 @@ export function packFallback(req,clock=Date.now,solverAlias=null,startIndex=null // divergence was reachable only from a library call, which is exactly how an // embedder reaches this engine. for(const container of req.containers??[])validateDirections(container.access_directions??[]); +admitConfiguration(req.configuration??{}); +for(const container of req.containers??[])admitContainer(container); const requestedSolvers=req.configuration?.solvers??[],knownSolvers=['grid','extreme_points','homogeneous_blocks','layer','maximal_spaces','exact_small']; if(!Array.isArray(requestedSolvers)||requestedSolvers.some(name=>!knownSolvers.includes(name)))throw new RangeError(`unknown solver; expected one of ${knownSolvers.join(', ')}`); const exactItemLimit=req.configuration?.exact_item_limit??7; -const requestedItemCount=(req.items??[]).reduce((total,item)=>total+(item.quantity??1),0); +const requestedItemCount=(req.items??[]).reduce((total,item)=>total+(item.quantity??1),0)-(Array.isArray(req.fixed_placements)?req.fixed_placements.length:0); if(requestedSolvers.includes('exact_small')&&requestedItemCount>exactItemLimit)throw new RangeError('exact-small item limit exceeded'); const effort=req.configuration?.effort_budget??null; for(const [name,value] of Object.entries(effort??{}))if(!Number.isSafeInteger(value)||value<=0)throw new RangeError(`effort_budget.${name} must be a positive safe integer`); @@ -1616,7 +1903,7 @@ const finalizeOutermost=result=>{ }; if(solverAlias===null&&requestedSolvers.length===0&&(req.configuration?.solver_profile??'balanced')==='quality'){ const child={...req,configuration:{...(req.configuration??{}),solvers:['homogeneous_blocks','extreme_points','maximal_spaces','layer']}}; - return packFallback(child,clock,null,null,deadline) + return solveFrame(child,clock,null,null,deadline,phase) } if(solverAlias===null&&requestedSolvers.length){ let remainingStarts=restartLimit; @@ -1630,7 +1917,7 @@ if(solverAlias===null&&requestedSolvers.length){ for(const plan of plans){ if(runs.length&&deadline.expired())break; const childRequest={...req,configuration:{...(req.configuration??{}),solvers:[],multi_start_orders:plan.count}}; - runs.push(packFallback(childRequest,clock,plan.name,null,deadline)); + runs.push(solveFrame(childRequest,clock,plan.name,null,deadline,phase)); } let winnerIndex=0;for(let index=1;index1){ for(let index=0;indexNumber(volume(d)*BigInt(WT[dimWeightUnit])/(BigInt(LEN[dimLengthUnit])**3n*BigInt(dimDivisor??1))); const globalSupportPpm=Math.round((req.configuration?.minimum_support_ratio??0)*SUPPORT_SCALE); -const maxCandidatePoints=Math.max(16,req.configuration?.max_candidate_points??4096); +const maxCandidatePoints=req.configuration?.max_candidate_points??4096; const qualityProfile=(req.configuration?.solver_profile??'balanced')==='quality'; const maxCandidatesPerItem=req.configuration?.max_candidates_per_item??(qualityProfile?16:1); const containerPlanBeamWidth=req.configuration?.container_plan_beam_width??(qualityProfile?16:1); @@ -1780,8 +2067,7 @@ items.sort((a,b)=>{ if(startIndex!==null&&startIndex>0&&(!qualityProfile||startIndex>2))items.splice(0,items.length,...seededOrder(items,req.configuration?.seed??42,startIndex)); const templates=req.containers.map(c=>{const d=dims(c.inner_dimensions,u),axleSpec=c.axles==null?null:c.axles.map(a=>({position:scalar(a.position,u,LEN),max:a.max_load==null?null:scalar(a.max_load,'g',WT)})); if(axleSpec&&(axleSpec.length!==2||axleSpec[0].position>=axleSpec[1].position||axleSpec[0].position<0||axleSpec[1].position>d[0]))throw new RangeError('axles must be [front, rear] inside the container'); - if(c.void_fill_reserve_ratio!=null&&(typeof c.void_fill_reserve_ratio!=='number'||!Number.isFinite(c.void_fill_reserve_ratio)))throw new TypeError('void_fill_reserve_ratio must be a finite number'); - const reservePpm=Math.round((c.void_fill_reserve_ratio??0)*SUPPORT_SCALE);if(reservePpm<0||reservePpm>SUPPORT_SCALE)throw new RangeError('void_fill_reserve_ratio must be between 0 and 1'); + const reservePpm=Math.round((c.void_fill_reserve_ratio??0)*SUPPORT_SCALE); if(c.tag_limits!=null&&(typeof c.tag_limits!=='object'||Array.isArray(c.tag_limits)||Object.values(c.tag_limits).some(limit=>!Number.isSafeInteger(limit)||limit<1)))throw new RangeError('tag_limits must map strings to positive safe integers'); const maxStackDensity=c.max_stack_density==null?null:scalar(c.max_stack_density,'g',WT);if(maxStackDensity!=null&&maxStackDensity<0)throw new RangeError('max_stack_density must be non-negative'); const outerD=c.outer_dimensions?dims(c.outer_dimensions,u):d; @@ -1798,7 +2084,26 @@ const templates=req.containers.map(c=>{const d=dims(c.inner_dimensions,u),axleSp innerVolume:volume(d),reserve:volume(d)*BigInt(reservePpm)/BigInt(SUPPORT_SCALE), rate:parseRateTable(c.rate_table),tagLimits:c.tag_limits??{},maxStackDensity, obs:(c.obstacles??[]).flatMap(o=>[o,...(o.additional_boxes??[])]).map(o=>({x:scalar(o.origin?.x??0,u,LEN),y:scalar(o.origin?.y??0,u,LEN),z:scalar(o.origin?.z??0,u,LEN),d:dims(o.dimensions,u)}))}}).sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost'?(dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))):((a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d)))); -const inventory=new Map(templates.map(c=>[c.id,c.quantity??Infinity])),remaining=[...items],packed=[];let seq=0; +// Admission of fixed placements, before any search: the containers they name, each holding +// only its fixed items, in opening order. Their instances leave the free list. +const fixedGroups=admitFixedPlacements(req,{items,templates,u,clear}); +// The request is read: from here on a failure is the solver's, not the caller's. The compact +// path above reads and answers in one closed-form step, so everything it throws is reading. +phase.solving=true; +const fixedIds=new Set(fixedGroups.flatMap(group=>group.placements.map(p=>p.item.id))); +const inventory=new Map(templates.map(c=>[c.id,c.quantity??Infinity])),remaining=items.filter(item=>!fixedIds.has(item.id)),packed=[];let seq=0; +// A fresh per-container search state: candidate points, spatial index and running volume, +// with the template's fixed items (`tmpl.fixed`, set only on a seeded copy) already placed +// exactly as search would place them, so every solver starts from them unaware they exist. +const seedState=tmpl=>{const node={state:{tmpl,placements:[],payload:0},used:0n, + points:[[0,0,0],...tmpl.obs.flatMap(o=>[[o.x+o.d[0],o.y,o.z],[o.x,o.y+o.d[1],o.z],[o.x,o.y,o.z+o.d[2]]])].sort(comparePoints), + index:makeIndex(tmpl.d)}; + for(const placement of tmpl.fixed??[]){const placements=node.state.placements;node.state.payload+=placement.item.w; + const compressionSensitive=placement.item.shapeType==='compressible'||placements.some(p=>p.item.shapeType==='compressible'); + node.used=compressionSensitive?usedVolume([...placements,placement]):node.used+usedVolumeDelta(placements,placement); + placements.push(placement);indexAdd(node.index,placements.length-1,{x:placement.x,y:placement.y,z:placement.z,d:placement.ed}); + retirePointsForPlacement(node.points,placement);for(const point of pointsFrom(placement))insertPoint(node.points,point)} + return node}; const maxContainers=req.configuration?.max_containers??Infinity; // Trial-packs `itemsRemaining` into one instance of `tmpl`, batching group members // together so they land in one container or none of them do. Returns the resulting @@ -1884,21 +2189,32 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ // Broad phase: visit only the placements sharing a cell with `box`, stamping each // so a placement spanning several cells is narrow-phase-checked once. A generation // counter does that without allocating a set per candidate orientation. - if(!collision){const [ix1,ix2,iy1,iy2,iz1,iz2]=cellRange(index,box),stamp=++index.gen; - scan:for(let ix=ix1;ixplaced.x&&yplaced.y&&zplaced.z - &&!(nests&&validNesting(candidate,placed)) - // The axis-aligned test is the broad phase and stays mandatory. Only when a hull - // is one of the two solids does the exact test get to overrule it, so a request of - // ordinary boxes never reaches the hull path at all. - &&solidsOverlap(candidateShape,box,placedHull(placed),{x:placed.x,y:placed.y,z:placed.z,d:pe})){collision=true;break scan}}}} + if(!collision){ + const ix1=Math.floor(x/index.cx),ix2=ceilDiv(Math.max(x2,x+1),index.cx); + const iy1=Math.floor(y/index.cy),iy2=ceilDiv(Math.max(y2,y+1),index.cy); + const iz1=Math.floor(z/index.cz),iz2=ceilDiv(Math.max(z2,z+1),index.cz); + if(ix2===ix1+1&&iy2===iy1+1&&iz2===iz1+1){ + const bucket=index.cells.get(cellKey(ix1,iy1,iz1)); + if(bucket){ + for(const position of bucket){ + const placed=placements[position],pe=placed.ed;metrics.collision_checks++; + if(xplaced.x&&yplaced.y&&zplaced.z + &&!(nests&&validNesting(candidate,placed)) + &&solidsOverlap(candidateShape,box,placedHull(placed),{x:placed.x,y:placed.y,z:placed.z,d:pe})){collision=true;break} + } + } + }else{ + const stamp=++index.gen; + scan:for(let ix=ix1;ixplaced.x&&yplaced.y&&zplaced.z + &&!(nests&&validNesting(candidate,placed)) + &&solidsOverlap(candidateShape,box,placedHull(placed),{x:placed.x,y:placed.y,z:placed.z,d:pe})){collision=true;break scan}} + } + } + } if(collision)continue; if(axleOverloaded(tmpl,placements,candidate))continue; if(!allowed(candidate,placements,tmpl,globalSupportPpm,metrics,loadBase,accessBase,sweep))continue; @@ -1925,13 +2241,12 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{ found.sort((a,b)=>a.score-b.score); return found }; -const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const state={tmpl,placements:[],payload:0};const next=[]; +const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const seeded=seedState(tmpl),state=seeded.state;const next=[]; // Running `usedVolume` of `state.placements`, maintained incrementally rather than // recomputed per candidate. Kept local to this call, not on `state`, so it // cannot leak into the packed container the caller spreads. - let used=0n; - const points=[[0,0,0],...tmpl.obs.flatMap(o=>[[o.x+o.d[0],o.y,o.z],[o.x,o.y+o.d[1],o.z],[o.x,o.y,o.z+o.d[2]]])].sort(comparePoints); - const index=makeIndex(tmpl.d); + let used=seeded.used; + const points=seeded.points,index=seeded.index; for(const batch of batchesOf(itemsRemaining)){ const snapshotPlacements=state.placements.slice(),snapshotPayload=state.payload,snapshotUsed=used; const snapshotPoints=batch.length>1?points.slice():null,snapshotIndex=batch.length>1?copyIndex(index):null;let ok=true; @@ -1956,9 +2271,7 @@ const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const state={tmpl,placements:[ }; const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ const batches=batchesOf(itemsRemaining); - const fresh=()=>({state:{tmpl,placements:[],payload:0},used:0n, - points:[[0,0,0],...tmpl.obs.flatMap(o=>[[o.x+o.d[0],o.y,o.z],[o.x,o.y+o.d[1],o.z],[o.x,o.y,o.z+o.d[2]]])].sort(comparePoints), - index:makeIndex(tmpl.d),unplaced:[]}); + const fresh=()=>({...seedState(tmpl),unplaced:[]}); const clone=node=>({state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used, points:node.points.slice(),index:copyIndex(node.index),unplaced:node.unplaced.slice()}); const place=(node,candidate)=>{node.state.payload+=candidate.item.w; @@ -2032,9 +2345,9 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{ // the reference engines do. Bounded by `exact_item_limit`, already enforced at admission. const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ const batches=batchesOf(itemsRemaining); - const freshWork=()=>({state:{tmpl,placements:[],payload:0},used:0n, - points:[[0,0,0],...tmpl.obs.flatMap(o=>[[o.x+o.d[0],o.y,o.z],[o.x,o.y+o.d[1],o.z],[o.x,o.y,o.z+o.d[2]]])].sort(comparePoints), - index:makeIndex(tmpl.d)}); + const freshWork=()=>seedState(tmpl); + // Fixed items are in every state and in no item list, so item counts leave them out. + const fixedCount=(tmpl.fixed??[]).length; const cloneWork=w=>({state:{tmpl,placements:w.state.placements.slice(),payload:w.state.payload}, used:w.used,points:w.points.slice(),index:copyIndex(w.index)}); const placeInto=(w,candidate)=>{ @@ -2099,7 +2412,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ // below must then use the smallest volumes for that final count; retaining the // earlier, longer prefix would overstate the necessary height and be inadmissible. smallestVolumeSum=volumes.slice(0,placeable).reduce((sum,value)=>sum+value,0n); - const permanentlySkipped=itemsRemaining.length-work.state.placements.length-future.length; + const permanentlySkipped=itemsRemaining.length-(work.state.placements.length-fixedCount)-future.length; const unpackedFloor=permanentlySkipped+future.length-placeable; if(placeable===0&&work.state.placements.length===0)return [unpackedFloor,0,0,0,0]; const largestVolumeSum=volumes.slice(Math.max(0,volumes.length-placeable)).reduce((sum,value)=>sum+value,0n); @@ -2135,7 +2448,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{ if(deadline.expired()){timeLimitReached=true;return} if(effortExceeded())return; metrics.search_nodes_expanded++; - const unpackedHere=itemsRemaining.length-work.state.placements.length; + const unpackedHere=itemsRemaining.length-(work.state.placements.length-fixedCount); if(unpackedHere<=bestRank[0]){const workRank=rankedWork(work);if(compareScore(workRank,bestRank)<0){best=work;bestRank=workRank}} if(compareScore(bestRank,completeLowerBound)===0)return; if(depth>=batches.length)return; @@ -2206,7 +2519,7 @@ const blockMode=(tmpl,itemsRemaining,volumeFirst)=>{let spaces=[{x:0,y:0,z:0,d:t state.placements.push(placement);state.payload+=placement.item.w;metrics.feasible_candidates++;metrics.orientations_considered++} spaces=subtractBlock(spaces,{x:best.space.x,y:best.space.y,z:best.space.z,d:[best.nx*best.ed[0],best.ny*best.ed[1],best.nz*best.ed[2]]})} const next=[...byType.keys()].sort().flatMap(key=>byType.get(key));return {state,next,reached}} -const packBlocksIntoTemplate=(tmpl,itemsRemaining)=>{if(!homogeneousBlocksSupported())return tryPackIntoTemplate(tmpl,itemsRemaining); +const packBlocksIntoTemplate=(tmpl,itemsRemaining)=>{if(!homogeneousBlocksSupported()||tmpl.fixed)return tryPackIntoTemplate(tmpl,itemsRemaining); let best=null;for(const volumeFirst of [false,true]){if(deadline.expired()){timeLimitReached=true;break}const candidate=blockMode(tmpl,itemsRemaining,volumeFirst);if(candidate.reached&&deadline.expired())timeLimitReached=true; const used=usedVolume(candidate.state.placements),top=candidate.state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0),signature=candidate.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|'),key=[candidate.next.length,-used,top,signature]; if(best==null||compareBlockKey(key,best.key)<0)best={...candidate,key}} @@ -2229,8 +2542,19 @@ const additionalContainerBound=plan=>{const available=templates.filter(t=>plan.i if(!plan.remaining.some(item=>item.nesting!=null)){const capacity=available.reduce((best,t)=>volume(t.d)>best?volume(t.d):best,0n),required=plan.remaining.reduce((sum,item)=>sum+volume(item.d),0n);if(capacity>0n)lower=Math.max(lower,Number((required+capacity-1n)/capacity))} if(available.every(t=>t.max!=null)){const capacity=Math.max(...available.map(t=>t.max)),required=plan.remaining.reduce((sum,item)=>sum+item.w,0);if(capacity>0)lower=Math.max(lower,Math.ceil(required/capacity))}return lower}; const planBound=(plan)=>{const key=planScore(plan,[]),index=objective==='default'?1:2;key[index]+=additionalContainerBound(plan);return key}; +// Containers holding fixed items open first, in request order then instance order, each +// filled by one ordinary single-container search from its seeded state. They are kept +// whatever that search gets -- nothing, or nothing at all once the deadline or effort is +// spent -- because dropping one would drop items the request says are already loaded. +let fixedReached=false; +for(const group of fixedGroups){const seeded={...group.tmpl,fixed:group.placements};let trial=null; + if(deadline.expired())timeLimitReached=true;else if(!fixedReached)trial=packIntoTemplate(seeded,remaining); + if(trial===null||timeLimitReached||effortExceeded())fixedReached=true; + inventory.set(seeded.id,inventory.get(seeded.id)-1);seq++; + packed.push({...(trial===null?seedState(seeded).state:trial.state),seq}); + if(trial!==null)remaining.splice(0,remaining.length,...trial.next)} if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ - const initial={packed:[],remaining:remaining.slice(),inventory:new Map(inventory),seq:0};let beam=[initial],incumbent=initial,planNodes=0; + const initial={packed:packed.slice(),remaining:remaining.slice(),inventory:new Map(inventory),seq};let beam=fixedReached?[]:[initial],incumbent=initial,planNodes=0; while(beam.length&&planNodes=maxContainers){if(compareScore(planScore(plan),planScore(incumbent))<0)incumbent=plan;continue} for(const tmpl of templates){if(planNodes>=containerPlanNodeLimit)break;if(deadline.expired()||effortExceeded()){exhausted=true;break}if(plan.inventory.get(tmpl.id)<=0)continue;planNodes++; @@ -2241,8 +2565,8 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ const dominant=new Map();for(const plan of expansions){const signature=`${plan.remaining.map(i=>i.id).join('|')}::${[...plan.inventory.entries()].sort().map(([k,v])=>`${k}:${v}`).join('|')}`; const previous=dominant.get(signature);if(!previous||compareScore(planScore(plan,[]),planScore(previous,[]))<0)dominant.set(signature,plan)} beam=[...dominant.values()].sort((a,b)=>compareScore(planBound(a),planBound(b))||compareCodePoints(a.remaining.map(i=>i.id).join('|'),b.remaining.map(i=>i.id).join('|'))).slice(0,containerPlanBeamWidth)} - packed.push(...incumbent.packed);remaining.splice(0,remaining.length,...incumbent.remaining);seq=incumbent.seq; -}else while(remaining.length&&packed.lengthinventory.get(c.id)>0&&remaining.some(i=>(!i.eligibleTags.length||i.eligibleTags.some(tag=>(c.tags??[]).includes(tag)))&&i.rots.some(r=>{const d=rotate(i.d,r).map(x=>x+2*clear);return d.every((x,k)=>x<=c.d[k])}))); @@ -2291,9 +2615,13 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){ seq++;packed.push({...winner.state,seq}); remaining.splice(0,remaining.length,...winner.next); } +// Containers are numbered per type in opening order, `T#n`, as in every engine; fixed +// containers open first, so a fixed placement's instance n is the container emitted as `T#n`. +{const counts=new Map(); + for(const c of packed){const n=(counts.get(c.tmpl.id)??0)+1;counts.set(c.tmpl.id,n);c.seq=n}} const containers=packed.map(c=>{const loads=topLoads(c.placements.map(constraintBox)),used=usedVolume(c.placements); const reaction=axleReactions(c.tmpl,c.placements); - return {id:`${c.tmpl.id}#${c.seq}`,container_type:c.tmpl.id,inner_dimensions:outDims(c.tmpl.d,ou),outer_dimensions:outDims(c.tmpl.outer_dimensions?dims(c.tmpl.outer_dimensions,u):c.tmpl.d,ou),payload_weight:outWeight(c.payload,ow),gross_weight:outWeight(c.payload+c.tmpl.tare,ow),used_volume_ticks3:used.toString(),volume_utilization:(Number(used)/Number(volume(c.tmpl.d))).toFixed(6),centre_of_mass_offset_ppm:centreOfMassOffsetPpm(c.tmpl,c.placements,clear),...(reaction==null?{}:{axle_reactions:{basis:'gross',denominator:reaction.denominator.toString(),front_numerator:reaction.front.toString(),rear_numerator:reaction.rear.toString()}}),void_fill_reserve_ticks3:(volume(c.tmpl.d)*BigInt(c.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)).toString(),placements:c.placements.map((p,i)=>({item_id:p.item.id,item_type:p.item.raw.id,position:outPoint({x:p.x+clear,y:p.y+clear,z:p.z+clear},ou),dimensions:outDims(p.pd,ou),orientation:p.r,support_ratio:supportRatioOf(p,c.placements).toFixed(6),top_load:outWeight(loads[i],ow)}))}}); + return {id:`${c.tmpl.id}#${c.seq}`,container_type:c.tmpl.id,inner_dimensions:outDims(c.tmpl.d,ou),outer_dimensions:outDims(c.tmpl.outer_dimensions?dims(c.tmpl.outer_dimensions,u):c.tmpl.d,ou),payload_weight:outWeight(c.payload,ow),gross_weight:outWeight(c.payload+c.tmpl.tare,ow),used_volume_ticks3:used.toString(),volume_utilization:(Number(used)/Number(volume(c.tmpl.d))).toFixed(6),centre_of_mass_offset_ppm:centreOfMassOffsetPpm(c.tmpl,c.placements,clear),...(reaction==null?{}:{axle_reactions:{basis:'gross',denominator:reaction.denominator.toString(),front_numerator:reaction.front.toString(),rear_numerator:reaction.rear.toString()}}),void_fill_reserve_ticks3:(volume(c.tmpl.d)*BigInt(c.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)).toString(),placements:c.placements.map((p,i)=>({item_id:p.item.id,item_type:p.item.raw.id,position:outPoint({x:p.x+clear,y:p.y+clear,z:p.z+clear},ou),dimensions:outDims(p.pd,ou),orientation:p.r,support_ratio:(p.fixed?p.fixedSupport:supportRatioOf(p,c.placements)).toFixed(6),top_load:outWeight(loads[i],ow),...(p.fixed?{fixed:true}:{})}))}}); const fitsWithRotations=(i,rots)=>templates.some(c=>rots.some(r=>{const d=rotate(i.d,r).map(x=>x+2*clear);return d.every((edge,k)=>edge<=c.d[k])})); const unpacked=remaining.map(i=>{ // Same geometric check with every physical orientation allowed, not only the @@ -2542,7 +2870,9 @@ export function rebalanceWeight(req,result,{maxMoves=64}={}){ const destinations=context.states.map((_,index)=>index).filter(index=>index!==sourceIndex).sort((a,b)=>weights[a]-weights[b]); let committed=null; search:for(const placementIndex of placements){ - const moving=context.states[sourceIndex].placements[placementIndex],weight=moving.item.w;if(weight<=0)continue; + const moving=context.states[sourceIndex].placements[placementIndex],weight=moving.item.w; + // A fixed item is already loaded where the request put it; nothing may move it. + if(moving.publicPlacement.fixed===true||weight<=0)continue; for(const destinationIndex of destinations){ const projected=[...weights];projected[sourceIndex]-=weight;projected[destinationIndex]+=weight; if(Math.max(...projected)-Math.min(...projected)>=spread)continue; diff --git a/index.d.ts b/index.d.ts index 9c16ed8..3a38e9d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,7 +5,9 @@ export interface Axle{position:ExactScalar;max_load?:ExactScalar} export interface Container{id:string;inner_dimensions:Dimensions;outer_dimensions?:Dimensions;max_payload?:ExactScalar;tare_weight?:ExactScalar;quantity?:number;cost_minor?:number;max_items?:number;max_stack_density?:ExactScalar;axles?:[Axle,Axle];tags?:string[];tag_limits?:Record;void_fill_reserve_ratio?:number;metadata?:Record;obstacles?:Array<{id:string;origin:{x:ExactScalar;y:ExactScalar;z:ExactScalar};dimensions:Dimensions;additional_boxes?:Array<{origin?:{x:ExactScalar;y:ExactScalar;z:ExactScalar};dimensions:Dimensions}>}>} export interface PackingConfiguration{solver_profile?:'fast'|'balanced'|'quality'|'exact_small';time_limit_ms?:number;alternatives?:number;seed?:number;max_containers?:number;clearance?:ExactScalar;minimum_support_ratio?:number;exact_item_limit?:number;multi_start_orders?:number;max_candidates_per_item?:number;max_candidate_points?:number;require_placement_coordinates?:boolean;solvers?:Array<'grid'|'extreme_points'|'homogeneous_blocks'|'layer'|'maximal_spaces'|'exact_small'>;objective?:'default'|'lowest_cost'|'shipping_cost'|'open_dimension_height'|'maximum_value';dimensional_weight_divisor?:number;dimensional_weight_length_unit?:'mm'|'cm'|'m'|'in'|'ft';dimensional_weight_weight_unit?:'mg'|'g'|'kg'|'oz'|'lb';effort_budget?:{max_candidates_evaluated?:number;max_placement_attempts?:number;max_search_nodes?:number;max_restarts?:number}} export interface CatalogReference{catalog_id:string;version:number;effective_at:number;resolved_at:number} -export interface PackingRequest{units?:{length?:string};configuration?:PackingConfiguration;items:Item[];containers:Container[];catalog_versions_used?:CatalogReference[];output?:{length_unit?:string;weight_unit?:string}} +/** An item already in a known place before the solve (docs/PLAN-REVISIONS.md). A result placement can be copied in unchanged. */ +export interface FixedPlacement{item_type:string;container_type:string;container_instance?:number;position?:{x?:ExactScalar;y?:ExactScalar;z?:ExactScalar};orientation:'LWH'|'LHW'|'WLH'|'WHL'|'HLW'|'HWL'} +export interface PackingRequest{units?:{length?:string};configuration?:PackingConfiguration;items:Item[];containers:Container[];fixed_placements?:FixedPlacement[];catalog_versions_used?:CatalogReference[];output?:{length_unit?:string;weight_unit?:string}} export interface ResultFact{code:string;[key:string]:unknown} export interface StartRecord{id:string;started:boolean;completed:boolean;truncated:boolean;selected:boolean;global_deadline_reached:boolean} export interface TerminationFact extends ResultFact{any_start_truncated:boolean;all_required_starts_completed:boolean;winning_start_truncated:boolean;global_deadline_reached:boolean;starts:StartRecord[]} @@ -18,6 +20,12 @@ export interface PackingResult{status:string;feasibility:ResultFact;termination: export interface WeightMove{item_id:string;from_container_id:string;to_container_id:string} export interface RebalanceResult{containers:unknown[];moves:WeightMove[];improved:boolean} export class UnsupportedFeatureError extends Error{readonly code:'unsupported_feature';readonly fields:string[]} +/** Why a request was refused: a closed set shared by all four engines (docs/ERRORS.md). */ +export type InvalidRequestReason='missing_field'|'wrong_type'|'below_minimum'|'above_maximum'|'negative_measure'|'invalid_unit'|'duplicate_id'|'not_allowed'|'invalid_value'; +/** The request is not one any engine may answer; nothing was solved. `field` is the RFC 6901 JSON Pointer of the offending value, `''` for the request as a whole. */ +export class InvalidRequestError extends RangeError{readonly code:string;readonly reason:string;readonly field:string;readonly detail:string;constructor(reason:InvalidRequestReason,field:string,detail:string)} +/** The request's fixed placements are malformed (`reason` `malformed`, `field` the bad value) or cannot all hold (`cannot_hold`, `field` `/fixed_placements`); refused before any search. */ +export class FixedPlacementError extends InvalidRequestError{readonly code:'invalid_fixed_placement';readonly reason:'malformed'|'cannot_hold';constructor(detail:string,reason?:'malformed'|'cannot_hold',field?:string)} export type MovementDirection='+x'|'-x'|'+y'|'-y'|'+z'|'-z'; export interface SequencePoint{x:number;y:number;z:number} export interface SequenceDimensions{length:number;width:number;height:number} diff --git a/index.js b/index.js index 1229d15..5a1357f 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,9 @@ import { createRequire } from 'node:module'; import { + FixedPlacementError, + InvalidRequestError, UnsupportedFeatureError, + checkRequestShape, packFallback, rebalanceWeight as rebalanceFallback, } from './fallback.js'; @@ -13,7 +16,7 @@ export { verifyLoadingPrefixBusinessRules, REASON_MESSAGES, explainReason, explanationForUnpackedItem, explainUnpackedItem, } from './fallback.js'; -export { UnsupportedFeatureError }; +export { FixedPlacementError, InvalidRequestError, UnsupportedFeatureError }; import * as commerceFallback from './commerce.js'; export { CommerceInputError } from './commerce.js'; const require = createRequire(import.meta.url); @@ -51,7 +54,17 @@ const native = loadNative(); export const backend = () => (native ? 'rust' : 'javascript'); export function packJson(input) { - if (native?.packJson) return native.packJson(input); + return packJsonWith(native, input); +} + +// Separate from `packJson` so the native path can be exercised without a native build. +export function packJsonWith(binding, input) { + if (binding?.packJson) { + // The native binding reports errors as text; checking the rule table here first gives a + // caller the same InvalidRequestError whichever backend answers. + checkRequestShape(JSON.parse(input)); + return binding.packJson(input); + } return JSON.stringify(packFallback(JSON.parse(input))); } @@ -69,7 +82,7 @@ export function rebalanceWeight(request, result, { maxMoves = 64 } = {}) { return rebalanceFallback(request, result, { maxMoves }); } -export const version = () => native?.version?.() ?? '1.3.0-js-fallback'; +export const version = () => native?.version?.() ?? '1.4.0-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 50975eb..083c427 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name":"@packvium/engine", - "version":"1.3.0", + "version":"1.4.0", "description":"Native-first 3D cartonization with deterministic JS fallback", "author":{"name":"Packvium","url":"https://packvium.com"}, "keywords":["3d-bin-packing","bin-packing","cartonization","packing","container-loading","logistics","shipping","deterministic"], @@ -10,8 +10,8 @@ "type":"module", "main":"index.js", "types":"index.d.ts", - "exports":{".":{"types":"./index.d.ts","import":"./index.js"},"./execution.js":{"types":"./execution.d.ts","import":"./execution.js"},"./artifacts.js":{"types":"./artifacts.d.ts","import":"./artifacts.js"},"./artifact-exports.js":{"types":"./artifact-exports.d.ts","import":"./artifact-exports.js"}}, - "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","execution.js","canonical-json.js","artifacts.js","artifact-exports.js","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","README.md","SECURITY.md"], + "exports":{".":{"types":"./index.d.ts","import":"./index.js"},"./execution.js":{"types":"./execution.d.ts","import":"./execution.js"},"./artifacts.js":{"types":"./artifacts.d.ts","import":"./artifacts.js"},"./artifact-exports.js":{"types":"./artifact-exports.d.ts","import":"./artifact-exports.js"},"./revisions.js":{"types":"./revisions.d.ts","import":"./revisions.js"}}, + "files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","execution.js","canonical-json.js","artifacts.js","artifact-exports.js","revisions.js","request-errors.js","examples","index.d.ts","execution.d.ts","artifacts.d.ts","artifact-exports.d.ts","revisions.d.ts","README.md","SECURITY.md"], "engines":{"node":">=16"}, "scripts":{ "test":"node test/run-tests.mjs", diff --git a/request-errors.js b/request-errors.js new file mode 100644 index 0000000..f6e8301 --- /dev/null +++ b/request-errors.js @@ -0,0 +1,278 @@ +/** + * Structured request errors: one type, a closed reason, and the JSON Pointer of the bad value. + * + * Held to `packvium.request_errors`. A caller who sends a malformed request needs to know which + * value is wrong and why in a form a program can branch on, and four engines phrasing the prose + * four ways answers neither. So every request error is an `InvalidRequestError` with: + * + * - `code`: `invalid_request` (a subclass such as `FixedPlacementError` names its own); + * - `reason`: one of a closed set shared by all four engines (`packvium.request_errors.REASONS`); + * - `field`: an RFC 6901 JSON Pointer to the offending value (`/items/0/quantity`), or `""` for + * the request as a whole; + * - `detail`: the fixed text for the reason, so the message is identical in every engine. + * + * `checkRequest` walks the schema's rules over the raw JSON before any model is built, and the + * first violation wins, in the order every engine follows: units, configuration, items, + * containers. A cross-engine suite holds all four engines to the same reason, field and + * message. + * + * This module is package-internal: the error class is re-exported from `fallback.js` and the + * package root. + */ + +import { compareCodePoints } from './commerce-model.js'; +import { jsonSpelling } from './canonical-json.js'; + +export const SOLVER_PROFILES = Object.freeze(['fast', 'balanced', 'quality', 'exact_small']); + +/** The request is not one any engine may answer. Nothing was solved. */ +export class InvalidRequestError extends RangeError { + static code = 'invalid_request'; + + /** The message a subclass may respell; it runs before `this` exists, so it is static. */ + static describe(field, detail) { + return field ? `${this.code}: ${field}: ${detail}` : `${this.code}: ${detail}`; + } + + constructor(reason, field, detail) { + super(new.target.describe(field, detail)); + this.name = 'InvalidRequestError'; + this.code = new.target.code; + this.reason = reason; + this.field = field; + this.detail = detail; + } +} + +/** The RFC 6901 pointer to a value, escaping `~` and `/` in keys. */ +export function pointer(...parts) { + return parts.map((part) => `/${String(part).replace(/~/g, '~0').replace(/\//g, '~1')}`).join(''); +} + +// ------------------------------------------------------------------------------ primitives + +const MAX_EXACT_MAGNITUDE = Number.MAX_SAFE_INTEGER; +const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); +const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); + +function requireInteger(value, field, minimum) { + if (Number.isSafeInteger(value)) { + if (value < minimum) throw new InvalidRequestError('below_minimum', field, `must be at least ${minimum}`); + return; + } + // A whole number past 2^53 - 1 is out of range, not mistyped: say which way. + if (typeof value === 'number' && Number.isInteger(value)) { + if (value < minimum) throw new InvalidRequestError('below_minimum', field, `must be at least ${minimum}`); + throw new InvalidRequestError('above_maximum', field, `must be at most ${MAX_EXACT_MAGNITUDE}`); + } + throw new InvalidRequestError('wrong_type', field, 'must be an integer'); +} + +function requireRatio(value, field, maximum = 1) { + if (typeof value !== 'number') throw new InvalidRequestError('wrong_type', field, 'must be a number'); + if (value < 0) throw new InvalidRequestError('below_minimum', field, 'must be at least 0'); + if (maximum !== null && value > maximum) { + throw new InvalidRequestError('above_maximum', field, `must be at most ${maximum}`); + } +} + +const requireUnbounded = (value, field) => requireRatio(value, field, null); + +function requireOneOf(value, field, allowed) { + if (typeof value !== 'string' || !allowed.includes(value)) { + throw new InvalidRequestError('not_allowed', field, `must be one of ${jsonSpelling([...allowed])}`); + } +} + +function requireObject(value, field) { + if (!isObject(value)) throw new InvalidRequestError('wrong_type', field, 'must be an object'); + return value; +} + +function requireList(value, field) { + if (!Array.isArray(value)) throw new InvalidRequestError('wrong_type', field, 'must be a list'); + return value; +} + +/** + * A measure is an integer, a string or `{value, unit}`, and never negative. `kind` is the + * engine's own parser for one quantity (`{units, parse}`), so what a measure may spell is the + * parser's to say, exactly as it is when the model is built. + */ +function requireMeasure(value, field, kind, unit) { + const whole = typeof value === 'number' && Number.isInteger(value); + if (!whole && typeof value !== 'string' && !isObject(value)) { + throw new InvalidRequestError('wrong_type', field, 'must be a measure'); + } + if (isObject(value)) requireMeasureObject(value, field, kind, unit); + try { + kind.parse(value, unit); + } catch (error) { + if (String(error?.message).includes('cannot be negative')) { + throw new InvalidRequestError('negative_measure', field, 'cannot be negative'); + } + throw new InvalidRequestError('wrong_type', field, 'must be a measure'); + } +} + +function requireMeasureObject(value, field, kind, unit) { + if (!hasOwn(value, 'value')) throw new InvalidRequestError('wrong_type', field, 'must be a measure'); + const stated = hasOwn(value, 'unit') ? value.unit : unit; + if (!knownUnit(stated, kind)) { + throw new InvalidRequestError('invalid_unit', field, `has an unknown unit ${jsonSpelling(stated)}`); + } +} + +const knownUnit = (unit, kind) => typeof unit === 'string' && hasOwn(kind.units, unit.trim().toLowerCase()); + +// ------------------------------------------------------------------------------ the rules + +const CONFIGURATION_INTEGERS = [['time_limit_ms', 1], ['alternatives', 1], ['max_containers', 1], + ['exact_item_limit', 1], ['multi_start_orders', 1], ['max_candidates_per_item', 1], + ['max_candidate_points', 16], ['container_plan_beam_width', 1], ['container_plan_node_limit', 1], + ['dimensional_weight_divisor', 1]]; +const EFFORT_LIMITS = ['max_candidates_evaluated', 'max_placement_attempts', 'max_search_nodes', 'max_restarts']; +const SIDES = ['length', 'width', 'height']; +const AXES = ['x', 'y', 'z']; +const atLeast = (minimum) => (value, field) => requireInteger(value, field, minimum); + +/** + * Refuse the request with its first violation, or return. O(size of the request). + * + * `measures` is `{length, weight}`, each `{units, parse(value, unit)}`: the engine's own unit + * table and parser, passed in so this module stays below the solver it guards. + */ +export function checkRequest(data, measures) { + const request = requireObject(data, ''); + const unit = checkUnits(request.units, measures.length); + const length = (value, field) => requireMeasure(value, field, measures.length, unit); + const weight = (value, field) => requireMeasure(value, field, measures.weight, 'g'); + checkConfiguration(request.configuration, length); + const items = requiredList(request, 'items', ''); + items.forEach((raw, index) => checkItem(raw, pointer('items', index), length, weight)); + requireUniqueIds(items, 'items'); + const containers = requiredList(request, 'containers', ''); + containers.forEach((raw, index) => checkContainer(raw, pointer('containers', index), length, weight)); + requireUniqueIds(containers, 'containers'); +} + +function checkUnits(raw, lengths) { + if (raw == null) return 'mm'; + const length = requireObject(raw, '/units').length; + if (length == null) return 'mm'; + if (!knownUnit(length, lengths)) { + throw new InvalidRequestError('invalid_unit', '/units/length', `has an unknown unit ${jsonSpelling(length)}`); + } + return length; +} + +function checkConfiguration(raw, length) { + if (raw == null) return; + const configuration = requireObject(raw, '/configuration'); + optional(configuration, 'solver_profile', '/configuration', (v, f) => requireOneOf(v, f, SOLVER_PROFILES)); + for (const [name, minimum] of CONFIGURATION_INTEGERS) optional(configuration, name, '/configuration', atLeast(minimum)); + optional(configuration, 'minimum_support_ratio', '/configuration', requireRatio); + optional(configuration, 'clearance', '/configuration', length); + if (configuration.effort_budget == null) return; + const budget = requireObject(configuration.effort_budget, '/configuration/effort_budget'); + for (const name of EFFORT_LIMITS) optional(budget, name, '/configuration/effort_budget', atLeast(1)); +} + +function checkItem(raw, where, length, weight) { + const item = requireObject(raw, where); + requiredString(item, 'id', where); + optional(item, 'quantity', where, atLeast(1)); + dimensions(required(item, 'dimensions', where), `${where}/dimensions`, length); + optional(item, 'weight', where, weight); + optional(item, 'max_top_load', where, weight); + optional(item, 'nesting_height', where, length); + optional(item, 'max_stacked_items', where, atLeast(1)); + optional(item, 'stop_index', where, atLeast(0)); + optional(item, 'value', where, atLeast(0)); + optional(item, 'max_compression_pressure_kpa', where, atLeast(0)); + optional(item, 'minimum_support_ratio', where, requireRatio); + optional(item, 'compression_ratio', where, requireUnbounded); +} + +function checkContainer(raw, where, length, weight) { + const container = requireObject(raw, where); + requiredString(container, 'id', where); + optional(container, 'quantity', where, atLeast(1)); + dimensions(required(container, 'inner_dimensions', where), `${where}/inner_dimensions`, length); + optional(container, 'outer_dimensions', where, (v, f) => dimensions(v, f, length)); + for (const name of ['tare_weight', 'max_payload', 'max_stack_density']) optional(container, name, where, weight); + optional(container, 'max_items', where, atLeast(1)); + optional(container, 'cost_minor', where, atLeast(0)); + optional(container, 'void_fill_reserve_ratio', where, requireRatio); + optional(container, 'tag_limits', where, tagLimits); + optional(container, 'rate_table', where, rateTable); + optional(container, 'obstacles', where, (v, f) => obstacles(v, f, length)); +} + +function dimensions(raw, where, length) { + const sides = requireObject(raw, where); + for (const side of SIDES) length(required(sides, side, where), where + pointer(side)); +} + +// Tags in code-point order, as every engine walks them: a JavaScript object lists integer-like +// keys first, so its own order would name a different bad tag than the others do. +function tagLimits(raw, where) { + const limits = requireObject(raw, where); + for (const tag of Object.keys(limits).sort(compareCodePoints)) requireInteger(limits[tag], where + pointer(tag), 1); +} + +function rateTable(raw, where) { + const table = requireObject(raw, where); + for (const [name, minimum] of [['weight_brackets_g', 1], ['prices_minor', 0]]) { + if (table[name] == null) continue; + requireList(table[name], where + pointer(name)) + .forEach((value, index) => requireInteger(value, where + pointer(name, index), minimum)); + } + optional(table, 'minimum_charge_minor', where, atLeast(0)); + optional(table, 'fuel_surcharge_permille', where, atLeast(0)); +} + +function obstacles(raw, where, length) { + requireList(raw, where).forEach((entry, index) => { + const at = where + pointer(index); + const obstacle = requireObject(entry, at); + if (obstacle.origin != null) { + const point = requireObject(obstacle.origin, `${at}/origin`); + for (const axis of AXES) optional(point, axis, `${at}/origin`, length); + } + dimensions(required(obstacle, 'dimensions', at), `${at}/dimensions`, length); + }); +} + +// ------------------------------------------------------------------------------ plumbing + +/** An optional field: absent and null are the default; anything else must pass. */ +function optional(container, name, where, check) { + const value = hasOwn(container, name) ? container[name] : null; + if (value != null) check(value, where + pointer(name)); +} + +function required(container, name, where) { + const value = hasOwn(container, name) ? container[name] : null; + if (value == null) throw new InvalidRequestError('missing_field', where + pointer(name), 'is required'); + return value; +} + +const requiredList = (container, name, where) => requireList(required(container, name, where), where + pointer(name)); + +function requiredString(container, name, where) { + if (typeof required(container, name, where) !== 'string') { + throw new InvalidRequestError('wrong_type', where + pointer(name), 'must be a string'); + } +} + +/** Checked once every entry is well formed, so the later of two equal ids is named. */ +function requireUniqueIds(entries, key) { + const seen = new Set(); + entries.forEach((entry, index) => { + if (seen.has(entry.id)) { + throw new InvalidRequestError('duplicate_id', pointer(key, index, 'id'), `repeats the id ${jsonSpelling(entry.id)}`); + } + seen.add(entry.id); + }); +} diff --git a/revisions.d.ts b/revisions.d.ts new file mode 100644 index 0000000..1ba241e --- /dev/null +++ b/revisions.d.ts @@ -0,0 +1,41 @@ +// Types for `@packvium/engine/revisions.js` — plan revisions. +// +// `docs/PLAN-REVISIONS.md` is the contract. A revision records the exceptions observed against +// an approved plan and derives the next request from its parent's. It calls no solver, validator +// or clock, and its RFC 8785 spelling is byte-identical across engines. +import type { FixedPlacement, PackingRequest } from './index.js'; +import type { ArtifactReplay, OperationalArtifact } from './artifacts.js'; + +export const FORMAT: 'packvium-plan-revision/v1'; +export const EVENT_TYPES: readonly ['item_missing', 'container_substituted', 'placement_locked', 'placement_verified']; + +/** The closed set of refusal codes shared by all four engines. */ +export type PlanRevisionErrorCode = + | 'invalid_revision' | 'invalid_event' | 'event_conflict' | 'invalid_artifact' + | 'invalid_json' | 'number_out_of_range' | 'invalid_string' | 'invalid_value'; + +export class PlanRevisionError extends Error{constructor(code:PlanRevisionErrorCode,message:string);readonly code:PlanRevisionErrorCode} + +/** `sequence` continues across the whole chain from 1; order is the sequence, never a clock. */ +export type RevisionEvent = + | {sequence:number;type:'item_missing';item_type:string;quantity:number} + | {sequence:number;type:'container_substituted';container_type:string;replacement:Record&{id:string}} + | {sequence:number;type:'placement_locked'|'placement_verified';placement:FixedPlacement}; + +export interface PlanRevision{format:'packvium-plan-revision/v1';suite_version:string;revision:number;parent:string|null;approved:{artifact:string;replay:ArtifactReplay}|null;events:RevisionEvent[];request:PackingRequest} + +/** One way a chain fails its audit, anchored on the revision where it shows. */ +export interface RevisionIssue{code:'invalid_revision'|'revision_number'|'parent_mismatch'|'sequence_gap'|'request_mismatch'|'artifact_mismatch';revision:number;detail:string} + +/** Revision 0: the request as first approved, with nothing recorded against it. */ +export function rootRevision(request:PackingRequest):PlanRevision; +/** The next revision: `events`, observed against `approvedArtifact`, applied to `parent`. Throws `PlanRevisionError`. */ +export function deriveRevision(parent:PlanRevision,approvedArtifact:OperationalArtifact,events:RevisionEvent[]):PlanRevision; +/** The request these events derive. Refuses a contradiction (`event_conflict`), never physics. */ +export function applyEvents(request:PackingRequest,events:RevisionEvent[]):PackingRequest; +/** Every way the chain fails to be what its root and events derive, without stopping at the first. */ +export function verifyRevisionChain(revisions:PlanRevision[],artifacts?:(OperationalArtifact|null)[]|null):RevisionIssue[]; +/** `sha256:` and the lowercase hex SHA-256 of a document's canonical bytes. */ +export function documentDigest(document:unknown):string; +/** The RFC 8785 canonical form. Throws `PlanRevisionError` for a value with no spelling. */ +export function canonicalRevisionJson(document:unknown):string; diff --git a/revisions.js b/revisions.js new file mode 100644 index 0000000..776f3c8 --- /dev/null +++ b/revisions.js @@ -0,0 +1,489 @@ +/** + * Plan revisions: an append-only chain of exceptions against approved plans. + * + * `docs/PLAN-REVISIONS.md` is the contract and `packvium.revisions` the reference. A revision + * records what differed from an approved plan -- a missing item, a substituted carton, a lock, + * a verified placement -- and derives the request the next plan solves. It is a pure function + * of its parent, the approved artifact and its events: it calls no solver, validator or clock, + * so four builders emit the same bytes. + * + * Where an input breaks two rules, the checks run in Python's order, and a message quotes a + * value by its canonical JSON (`"x"`, `null`, `["a","b"]`), because the code and text are + * compared across engines. + */ + +import { createHash } from 'node:crypto'; + +import { FORMAT as ARTIFACT_FORMAT, SUITE_VERSION } from './artifacts.js'; +import { CanonicalJsonError, canonicalJson, jsonInteger, jsonSpelling } from './canonical-json.js'; +import { compareCodePoints } from './commerce-model.js'; +import { requirePointShape } from './fallback.js'; + +export const FORMAT = 'packvium-plan-revision/v1'; + +export const EVENT_TYPES = Object.freeze(['item_missing', 'container_substituted', 'placement_locked', 'placement_verified']); + +const ORIENTATIONS = ['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']; + +const EVENT_FIELDS = new Map([ + ['item_missing', ['item_type', 'quantity']], + ['container_substituted', ['container_type', 'replacement']], + ['placement_locked', ['placement']], + ['placement_verified', ['placement']], +]); + +const PLACEMENT_FIELDS = ['item_type', 'container_type', 'container_instance', 'position', 'orientation']; + +/** + * A revision could not be built. `code` is one of a closed set shared by four engines: + * `invalid_revision`, `invalid_event`, `event_conflict`, `invalid_artifact`, and the + * canonical-form codes `number_out_of_range`, `invalid_string`, `invalid_value`. + */ +export class PlanRevisionError extends Error { + constructor(code, message) { + super(message); + this.name = 'PlanRevisionError'; + this.code = code; + } +} + +/** The RFC 8785 canonical form, the bytes a digest and four engines are compared on. */ +export function canonicalRevisionJson(document) { + try { + return canonicalJson(document); + } catch (error) { + if (error instanceof CanonicalJsonError) throw new PlanRevisionError(error.code, error.message); + throw error; + } +} + +/** `sha256:` and the hex SHA-256 of a document's canonical bytes. */ +export function documentDigest(document) { + return `sha256:${createHash('sha256').update(canonicalRevisionJson(document), 'utf8').digest('hex')}`; +} + +/** Revision 0: the request as first approved, with nothing recorded against it. */ +export function rootRevision(request) { + if (!isMapping(request)) throw new PlanRevisionError('invalid_revision', 'a request is a JSON object'); + const document = revisionDocument(0, null, null, [], clone(request)); + canonicalRevisionJson(document); + return document; +} + +/** + * The next revision: `events`, observed against `approvedArtifact`, applied to `parent`. + * + * Copies and hashes the request/artifact in their serialized size. Placement-only replay + * indexes canonical keys; missing-item and carton events still scan their request lists. + */ +export function deriveRevision(parent, approvedArtifact, events) { + requireRevision(parent); + requireArtifact(approvedArtifact, parent.request); + if (!Array.isArray(events) || !events.length) { + throw new PlanRevisionError('invalid_event', 'a revision records at least one event'); + } + const first = lastSequence(parent) + 1; + const recorded = events.map((event, offset) => recordedEvent(event, first + offset)); + const request = applyEvents(parent.request, recorded); + const approved = { + artifact: documentDigest(approvedArtifact), + replay: clone(approvedArtifact.provenance.replay), + }; + const document = revisionDocument(jsonInteger(parent.revision) + 1, documentDigest(parent), approved, recorded, request); + canonicalRevisionJson(document); + return document; +} + +/** + * The request these events derive from `request`. Refuses a contradiction, never physics: + * whether the new fixed set can hold is the engine's admission check when it is solved. + */ +export function applyEvents(request, events) { + if (!isMapping(request)) throw new PlanRevisionError('invalid_revision', 'a request is a JSON object'); + if (!Array.isArray(events)) throw new PlanRevisionError('invalid_event', 'events are a JSON array'); + const derived = clone(request); + const placementSession = { fixed: null, keys: null, indexable: true }; + for (const event of events) { + requireShape(event); + const given = get(event, 'sequence'); + if (jsonInteger(given) === null) { + throw new PlanRevisionError('invalid_event', `event sequence ${jsonSpelling(given)} is not an integer`); + } + APPLY[event.type](derived, event, placementSession); + } + return derived; +} + +/** + * Every way the chain fails to be what its root and events derive, without stopping at the + * first. `artifacts[k]`, when given, is the artifact revision `k` names as approved. + */ +export function verifyRevisionChain(revisions, artifacts = null) { + if (!Array.isArray(revisions)) throw new PlanRevisionError('invalid_revision', 'a chain is a JSON array'); + if (artifacts != null && !Array.isArray(artifacts)) { + throw new PlanRevisionError('invalid_artifact', 'artifacts is a JSON array'); + } + const issues = []; + let last = 0; + for (let position = 0; position < revisions.length; position += 1) { + const revision = revisions[position]; + try { + requireRevision(revision); + } catch (error) { + if (!(error instanceof PlanRevisionError)) throw error; + issues.push(issue('invalid_revision', position, error.message)); + return issues; + } + const number = jsonInteger(revision.revision); + if (number !== position) { + issues.push(issue('revision_number', position, `revision ${number} at position ${position}`)); + } + const parent = position ? revisions[position - 1] : null; + const expectedParent = parent === null ? null : documentDigest(parent); + const recordedParent = get(revision, 'parent'); + if (recordedParent !== expectedParent) { + issues.push(issue('parent_mismatch', position, `parent ${plain(recordedParent)}, expected ${plain(expectedParent)}`)); + } + last = checkSequences(revision, position, last, issues); + if (parent !== null) checkRequest(revision, parent, position, issues); + const artifact = artifacts == null || position >= artifacts.length ? null : artifacts[position]; + if (artifact != null && parent !== null) checkArtifact(revision, parent, artifact, position, issues); + } + return issues; +} + +// ---------------------------------------------------------------------------- building + +function revisionDocument(number, parent, approved, events, request) { + return { + format: FORMAT, + suite_version: SUITE_VERSION, + revision: number, + parent, + approved, + events, + request, + }; +} + +function requireRevision(document) { + if (!isMapping(document) || get(document, 'format') !== FORMAT) { + throw new PlanRevisionError('invalid_revision', `not a ${FORMAT} document`); + } + const number = jsonInteger(get(document, 'revision')); + if (number === null || number < 0) { + throw new PlanRevisionError('invalid_revision', 'revision is a non-negative integer'); + } + if (!isMapping(get(document, 'request'))) { + throw new PlanRevisionError('invalid_revision', 'a revision carries its request'); + } + const events = get(document, 'events'); + if (!Array.isArray(events)) { + throw new PlanRevisionError('invalid_revision', 'a revision carries its events'); + } + if (!events.every((event) => isMapping(event) && jsonInteger(get(event, 'sequence')) !== null)) { + throw new PlanRevisionError('invalid_revision', "a revision's events each carry an integer sequence"); + } +} + +function requireArtifact(artifact, request) { + if (!isMapping(artifact) || get(artifact, 'format') !== ARTIFACT_FORMAT) { + throw new PlanRevisionError('invalid_artifact', `not a ${ARTIFACT_FORMAT} document`); + } + const provenance = get(artifact, 'provenance'); + if (!isMapping(provenance) || !isMapping(get(provenance, 'replay'))) { + throw new PlanRevisionError('invalid_artifact', 'the artifact carries no provenance.replay'); + } + if (!same(get(provenance, 'request'), request)) { + throw new PlanRevisionError('invalid_artifact', "the artifact was built from a different request than the parent's"); + } +} + +function lastSequence(revision) { + const { events } = revision; + if (events.length) return jsonInteger(get(events[events.length - 1], 'sequence')); + if (jsonInteger(revision.revision) !== 0) { + throw new PlanRevisionError('invalid_revision', 'only the root revision records no events'); + } + return 0; +} + +function recordedEvent(raw, sequence) { + requireShape(raw); + const given = get(raw, 'sequence'); + if (jsonInteger(given) !== sequence) { + throw new PlanRevisionError('invalid_event', `event sequence ${jsonSpelling(given)} does not continue the chain at ${sequence}`); + } + return clone(raw); +} + +function requireShape(raw) { + if (!isMapping(raw)) throw new PlanRevisionError('invalid_event', 'an event is a JSON object'); + const kind = get(raw, 'type'); + const fields = typeof kind === 'string' ? EVENT_FIELDS.get(kind) : undefined; + if (fields === undefined) throw new PlanRevisionError('invalid_event', `unknown event type ${jsonSpelling(kind)}`); + const allowed = new Set(['sequence', 'type', ...fields]); + const unknown = Object.keys(raw).filter((key) => !allowed.has(key)).sort(compareCodePoints); + if (unknown.length) throw new PlanRevisionError('invalid_event', `${kind} does not carry ${jsonSpelling(unknown)}`); + const missing = fields.filter((field) => !hasOwn(raw, field)); + if (missing.length) throw new PlanRevisionError('invalid_event', `${kind} needs ${jsonSpelling(missing)}`); + VALIDATE[kind](raw); +} + +function validateItemMissing(event) { + requireName(event.item_type, 'item_type'); + const quantity = jsonInteger(event.quantity); + if (quantity === null || quantity < 1) { + throw new PlanRevisionError('invalid_event', 'item_missing.quantity is a positive integer'); + } +} + +function validateContainerSubstituted(event) { + requireName(event.container_type, 'container_type'); + if (!isMapping(event.replacement)) throw new PlanRevisionError('invalid_event', 'a replacement is a container object'); + requireName(get(event.replacement, 'id'), 'replacement.id'); +} + +function validatePlacement(event) { + const { placement } = event; + if (!isMapping(placement)) throw new PlanRevisionError('invalid_event', 'a placement is a fixed-placement object'); + const unknown = Object.keys(placement).filter((key) => !PLACEMENT_FIELDS.includes(key)).sort(compareCodePoints); + if (unknown.length) throw new PlanRevisionError('invalid_event', `a placement does not carry ${jsonSpelling(unknown)}`); + requireName(get(placement, 'item_type'), 'placement.item_type'); + requireName(get(placement, 'container_type'), 'placement.container_type'); + if (!ORIENTATIONS.includes(get(placement, 'orientation'))) { + throw new PlanRevisionError('invalid_event', 'placement.orientation is one of the six codes'); + } + const instance = jsonInteger(hasOwn(placement, 'container_instance') ? placement.container_instance : 1); + if (instance === null || instance < 1) { + throw new PlanRevisionError('invalid_event', 'placement.container_instance counts from 1'); + } + requirePointShape(hasOwn(placement, 'position') ? placement.position : {}, 'placement.position', '', invalidEvent); +} + +function invalidEvent(message) { + return new PlanRevisionError('invalid_event', message); +} + +function requireName(value, field) { + if (typeof value !== 'string' || !value) throw new PlanRevisionError('invalid_event', `${field} is a non-empty string`); +} + +const VALIDATE = { + item_missing: validateItemMissing, + container_substituted: validateContainerSubstituted, + placement_locked: validatePlacement, + placement_verified: validatePlacement, +}; + +// ---------------------------------------------------------------------------- applying + +function applyItemMissing(request, event) { + const items = entries(request, 'items'); + const index = indexOf(items, event.item_type, 'item'); + const quantity = jsonInteger(hasOwn(items[index], 'quantity') ? items[index].quantity : 1); + if (quantity === null) { + throw new PlanRevisionError('invalid_revision', `request.items[${index}].quantity is an integer`); + } + const remaining = quantity - jsonInteger(event.quantity); + const fixed = fixedPlacements(request).filter((entry) => get(entry, 'item_type') === event.item_type).length; + if (remaining < fixed) { + throw new PlanRevisionError('event_conflict', + `event ${sequenceOf(event)}: ${fixed} ${event.item_type} are fixed, ${Math.max(remaining, 0)} would remain`); + } + if (remaining > 0) { + items[index].quantity = remaining; + return; + } + if (items.length === 1) { + throw new PlanRevisionError('event_conflict', `event ${sequenceOf(event)}: no item would remain to pack`); + } + items.splice(index, 1); +} + +function applyContainerSubstituted(request, event) { + const containers = entries(request, 'containers'); + const index = indexOf(containers, event.container_type, 'container'); + if (fixedPlacements(request).some((entry) => get(entry, 'container_type') === event.container_type)) { + throw new PlanRevisionError('event_conflict', + `event ${sequenceOf(event)}: fixed placements are in ${event.container_type}`); + } + const replacementId = event.replacement.id; + if (containers.some((container, position) => position !== index && get(container, 'id') === replacementId)) { + throw new PlanRevisionError('event_conflict', + `event ${sequenceOf(event)}: another container is already ${replacementId}`); + } + containers[index] = clone(event.replacement); +} + +function applyPlacement(request, event, session) { + const placement = withDefaultInstance(event.placement); + if (session.fixed === null) { + session.fixed = fixedPlacements(request); + request.fixed_placements = session.fixed; + try { + session.keys = new Set(session.fixed.map((entry) => canonicalRevisionJson(withDefaultInstance(entry)))); + } catch (error) { + if (!(error instanceof PlanRevisionError)) throw error; + session.indexable = false; + } + } + const fixed = session.fixed; + // One box cannot be two fixed items: a lock that is later verified in place is recorded + // twice in the chain and once in the request. + if (session.indexable) { + try { + const key = canonicalRevisionJson(placement); + if (session.keys.has(key)) return; + fixed.push(clone(event.placement)); + session.keys.add(key); + return; + } catch (error) { + if (!(error instanceof PlanRevisionError)) throw error; + session.indexable = false; + } + } + // Direct callers may supply values outside canonical JSON. Keep the former first-match + // refusal order rather than making construction of the index an earlier refusal. + if (fixed.some((entry) => same(withDefaultInstance(entry), placement))) return; + fixed.push(clone(event.placement)); +} + +function withDefaultInstance(placement) { + return { container_instance: 1, ...clone(placement) }; +} + +const APPLY = { + item_missing: applyItemMissing, + container_substituted: applyContainerSubstituted, + placement_locked: applyPlacement, + placement_verified: applyPlacement, +}; + +/** + * The request's fixed placements; absent or null is none, anything else not a list of objects + * is a request no event can safely edit. + */ +function fixedPlacements(request) { + return get(request, 'fixed_placements') === null ? [] : entries(request, 'fixed_placements'); +} + +function entries(request, key) { + const list = get(request, key); + if (!Array.isArray(list) || !list.every(isMapping)) { + throw new PlanRevisionError('invalid_revision', `request.${key} is a list of objects`); + } + return list; +} + +function indexOf(list, identifier, kind) { + const index = list.findIndex((entry) => get(entry, 'id') === identifier); + if (index < 0) throw new PlanRevisionError('event_conflict', `the request has no ${kind} ${jsonSpelling(identifier)}`); + return index; +} + +// ------------------------------------------------------------------------------ audit + +function checkSequences(revision, position, last, issues) { + const { events } = revision; + if (position === 0 && events.length) issues.push(issue('sequence_gap', 0, 'the root revision records no events')); + if (position > 0 && !events.length) issues.push(issue('sequence_gap', position, 'a revision records at least one event')); + let next = last; + for (const event of events) { + const sequence = sequenceOf(event); + if (sequence !== next + 1) { + issues.push(issue('sequence_gap', position, `event ${sequence} where ${next + 1} was next`)); + } + next = sequence; + } + return next; +} + +function checkRequest(revision, parent, position, issues) { + let derived; + try { + derived = applyEvents(parent.request, revision.events); + } catch (error) { + if (!(error instanceof PlanRevisionError)) throw error; + issues.push(issue('request_mismatch', position, `the events do not apply: ${error.message}`)); + return; + } + if (!same(derived, revision.request)) { + issues.push(issue('request_mismatch', position, "the request is not what the parent's request and these events derive")); + } +} + +function checkArtifact(revision, parent, artifact, position, issues) { + const approved = get(revision, 'approved'); + const digest = digestOrNull(artifact); + if (digest === null || !isMapping(approved) || get(approved, 'artifact') !== digest) { + issues.push(issue('artifact_mismatch', position, "the artifact's digest is not the one this revision approved")); + return; + } + const provenance = get(artifact, 'provenance'); + if (!isMapping(provenance) || !same(get(provenance, 'request'), parent.request)) { + issues.push(issue('artifact_mismatch', position, "the artifact was built from a different request than the parent's")); + } else if (!same(get(provenance, 'replay'), get(approved, 'replay'))) { + issues.push(issue('artifact_mismatch', position, "approved.replay is not the artifact's provenance.replay")); + } +} + +function issue(code, revision, detail) { + return { code, revision, detail }; +} + +// ------------------------------------------------------------------------------ values + +/** Equality as the canonical form sees it, so `1.0` and `1` are one value, as in every engine. */ +function same(left, right) { + return canonicalRevisionJson(left) === canonicalRevisionJson(right); +} + +/** + * A deep copy of a JSON tree, so a caller's document is never mutated. Keys are defined, not + * assigned: assigning `__proto__` would swap the copy's prototype instead of keeping the key. + */ +function clone(value) { + if (Array.isArray(value)) return value.map(clone); + if (isMapping(value) && Object.getPrototypeOf(value) === Object.prototype) { + const copy = {}; + for (const [key, entry] of Object.entries(value)) { + Object.defineProperty(copy, key, { value: clone(entry), enumerable: true, writable: true, configurable: true }); + } + return copy; + } + return value; +} + +/** Python's `Mapping.get`: an absent key reads as null, and never from the prototype. */ +function get(mapping, name) { + return isMapping(mapping) && hasOwn(mapping, name) ? (mapping[name] ?? null) : null; +} + +function isMapping(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasOwn(mapping, name) { + return Object.prototype.hasOwnProperty.call(mapping, name); +} + +/** A validated event's sequence; `requireRevision` or `applyEvents` has vouched for it. */ +function sequenceOf(event) { + return jsonInteger(get(event, 'sequence')); +} + +/** An audited artifact's digest; one with no canonical form matches no approval. */ +function digestOrNull(document) { + try { + return documentDigest(document); + } catch (error) { + if (!(error instanceof PlanRevisionError)) throw error; + return null; + } +} + +/** A digest as itself, and anything a tampered chain puts in its place as JSON. */ +function plain(value) { + return typeof value === 'string' ? value : jsonSpelling(value); +} diff --git a/test/fallback.test.mjs b/test/fallback.test.mjs index e9642bb..f89f72d 100644 --- a/test/fallback.test.mjs +++ b/test/fallback.test.mjs @@ -6,7 +6,7 @@ import { Deadline, SequenceReplayError, UNSUPPORTED_FIELDS, UnsupportedFeatureError, BoundOverflowError, __spatialIndexForTests, __inspectHullShapeForTests, __objectiveBoundsForTests, __occupiesLessThanItsBoxForTests, __candidatePrefixForTests, __groupBatchesForTests, aggregateTermination, - explainUnpackedItem, explanationForUnpackedItem, packFallback, rebalanceWeight, + InvalidRequestError, explainUnpackedItem, explanationForUnpackedItem, packFallback, rebalanceWeight, verifyLoadingPrefixBusinessRules, } from '../fallback.js'; import { objective as recomputeObjective, validate } from './validate.mjs'; @@ -382,11 +382,61 @@ test('nesting height accepts zero and rejects negative or full-height depths', ( [cube('a', 10, { nesting_height })], [box('c', 10)], )), - /nesting_height/, + /nesting_height|length cannot be negative/, ); } }); +test('every number below its schema floor is refused before either solver path', () => { + const base = () => request([cube('a', 10, { quantity: 2, weight: '1000' })], [box('c', 20, 10, 20, { quantity: 2 })], + { configuration: { solver_profile: 'fast' } }); + const item = (fields) => (req) => Object.assign(req.items[0], fields); + const container = (fields) => (req) => Object.assign(req.containers[0], fields); + const configuration = (fields) => (req) => Object.assign(req.configuration, fields); + const obstacle = (fields) => container({ obstacles: [{ id: 'p', origin: { x: '15' }, dimensions: mm(1, 1, 1), ...fields }] }); + assert.doesNotThrow(() => packFallback(base())); + const negative = 'negative_measure'; + const cases = [ + [item({ quantity: 0 }), 'below_minimum', '/items/0/quantity: must be at least 1'], + [item({ quantity: -1 }), 'below_minimum', '/items/0/quantity: must be at least 1'], + [item({ minimum_support_ratio: -0.5 }), 'below_minimum', '/items/0/minimum_support_ratio: must be at least 0'], + [item({ dimensions: mm(-1, 10, 10) }), negative, '/items/0/dimensions/length: cannot be negative'], + [item({ weight: '-1' }), negative, '/items/0/weight: cannot be negative'], + [item({ weight: -1 }), negative, '/items/0/weight: cannot be negative'], + [item({ max_top_load: '-1' }), negative, '/items/0/max_top_load: cannot be negative'], + [container({ quantity: 0 }), 'below_minimum', '/containers/0/quantity: must be at least 1'], + [container({ quantity: -1 }), 'below_minimum', '/containers/0/quantity: must be at least 1'], + [container({ max_items: 0 }), 'below_minimum', '/containers/0/max_items: must be at least 1'], + [container({ cost_minor: -1 }), 'below_minimum', '/containers/0/cost_minor: must be at least 0'], + [container({ void_fill_reserve_ratio: -0.5 }), 'below_minimum', '/containers/0/void_fill_reserve_ratio: must be at least 0'], + [container({ void_fill_reserve_ratio: 'half' }), 'wrong_type', '/containers/0/void_fill_reserve_ratio: must be a number'], + [container({ inner_dimensions: mm(20, -1, 20) }), negative, '/containers/0/inner_dimensions/width: cannot be negative'], + [container({ outer_dimensions: { length: '-1', width: '11', height: '21' } }), negative, + '/containers/0/outer_dimensions/length: cannot be negative'], + [container({ tare_weight: '-1' }), negative, '/containers/0/tare_weight: cannot be negative'], + [container({ max_payload: { value: '-1', unit: 'kg' } }), negative, '/containers/0/max_payload: cannot be negative'], + [obstacle({ origin: { x: '-1' } }), negative, '/containers/0/obstacles/0/origin/x: cannot be negative'], + [obstacle({ dimensions: mm(-1, 1, 1) }), negative, '/containers/0/obstacles/0/dimensions/length: cannot be negative'], + [configuration({ time_limit_ms: 0 }), 'below_minimum', '/configuration/time_limit_ms: must be at least 1'], + [configuration({ alternatives: -1 }), 'below_minimum', '/configuration/alternatives: must be at least 1'], + [configuration({ exact_item_limit: 0 }), 'below_minimum', '/configuration/exact_item_limit: must be at least 1'], + [configuration({ max_containers: 0 }), 'below_minimum', '/configuration/max_containers: must be at least 1'], + [configuration({ max_candidate_points: 15 }), 'below_minimum', '/configuration/max_candidate_points: must be at least 16'], + [configuration({ minimum_support_ratio: -0.5 }), 'below_minimum', '/configuration/minimum_support_ratio: must be at least 0'], + [configuration({ clearance: '-1' }), negative, '/configuration/clearance: cannot be negative'], + ]; + for (const [edit, reason, message] of cases) { + const req = base(); + edit(req); + assert.throws(() => packFallback(req), (error) => error instanceof InvalidRequestError + && error.reason === reason && error.message === `invalid_request: ${message}`, JSON.stringify(req)); + } +}); + +test('a negative measure that rounds to zero ticks is admitted as zero', () => { + assert.doesNotThrow(() => packFallback(request([cube('a', 10, { weight: '-0.0000000001' })], [box('c', 10)]))); +}); + test('every box of a compound obstacle is enforced', () => { const result = packFallback(request( [{ id: 'a', dimensions: mm(10, 20, 20), allowed_rotations: ['LWH'] }], @@ -992,7 +1042,7 @@ test('compact lattice rejects the same malformed item as the general path', () = [box('c')], { configuration: { require_placement_coordinates: false } }, ); - assert.throws(() => packFallback(payload), /value must be a non-negative safe integer/); + assert.throws(() => packFallback(payload), { message: 'invalid_request: /items/0/value: must be at least 0' }); }); test('compact lattice stops at the exact effort boundary', () => { @@ -1601,7 +1651,7 @@ test('a rebalance move that would leave the destination unpriceable is vetoed', assert.deepEqual(vetoed.containers, original.containers); const balanced = rebalanceWeight(req, original, { maxMoves: 8 }); assert.deepEqual(balanced.moves, [ - { item_id: 'brick#1', from_container_id: 'alpha_hold#1', to_container_id: 'gamma_tight#2' }, + { item_id: 'brick#1', from_container_id: 'alpha_hold#1', to_container_id: 'gamma_tight#1' }, ]); assert.deepEqual( balanced.containers.map((container) => container.payload_weight.ticks), @@ -1935,15 +1985,23 @@ test('a policy rule set is honoured rather than refused', () => { test('a malformed tariff is rejected rather than silently mispricing', () => { const cases = [ - [{ weight_brackets_g: [1500, 500], prices_minor: [100, 200] }, /strictly ascending/], - [{ weight_brackets_g: [500, 1500], prices_minor: [100] }, /same length/], - [{ weight_brackets_g: [], prices_minor: [] }, /non-empty/], - [{ weight_brackets_g: [0], prices_minor: [100] }, /positive safe integers/], - [{ weight_brackets_g: [500], prices_minor: [-1] }, /non-negative safe integers/], - [{ weight_brackets_g: [500], prices_minor: [100], fuel_surcharge_permille: -1 }, /fuel_surcharge_permille/], + // What the rule table cannot see -- order, pairing, emptiness -- still reaches the caller + // as a request error, with the engine's own words and no field. + [{ weight_brackets_g: [1500, 500], prices_minor: [100, 200] }, + 'invalid_request: rate_table weight_brackets_g must be strictly ascending'], + [{ weight_brackets_g: [500, 1500], prices_minor: [100] }, + 'invalid_request: rate_table weight_brackets_g and prices_minor must have the same length'], + [{ weight_brackets_g: [], prices_minor: [] }, + 'invalid_request: rate_table requires non-empty weight_brackets_g and prices_minor'], + [{ weight_brackets_g: [0], prices_minor: [100] }, + 'invalid_request: /containers/0/rate_table/weight_brackets_g/0: must be at least 1'], + [{ weight_brackets_g: [500], prices_minor: [-1] }, + 'invalid_request: /containers/0/rate_table/prices_minor/0: must be at least 0'], + [{ weight_brackets_g: [500], prices_minor: [100], fuel_surcharge_permille: -1 }, + 'invalid_request: /containers/0/rate_table/fuel_surcharge_permille: must be at least 0'], ]; for (const [table, expected] of cases) { - assert.throws(() => packFallback(landed(table, '200 g')), expected); + assert.throws(() => packFallback(landed(table, '200 g')), { name: 'InvalidRequestError', message: expected }); } }); @@ -1961,7 +2019,7 @@ test('a request that omits value reproduces the default result byte-for-byte', ( test('a negative value fails admission instead of being ignored', () => { assert.throws( () => packFallback(request([cube('a', 10, { value: -1 })], [box('c')])), - /value must be a non-negative safe integer/, + { message: 'invalid_request: /items/0/value: must be at least 0' }, ); }); diff --git a/test/fixed-placements.test.mjs b/test/fixed-placements.test.mjs new file mode 100644 index 0000000..82c0a10 --- /dev/null +++ b/test/fixed-placements.test.mjs @@ -0,0 +1,332 @@ +/** + * Fixed placements: items already in a known place before the solve (docs/PLAN-REVISIONS.md). + * + * Mirrors `packvium-python/tests/test_fixed_placements.py` and `FixedPlacementTest.php`. The + * refusal texts are asserted whole, not by fragment, because they are compared across engines. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { FixedPlacementError, InvalidRequestError, UNSUPPORTED_FIELDS, packFallback, rebalanceWeight } from '../fallback.js'; +import { validate } from './validate.mjs'; + +const FALLBACK_WARNING = 'JavaScript fallback is active; build the Rust addon for the native portfolio'; + +const cube = (quantity, weight = '1000', extra = {}) => ({ + id: 'cube', quantity, weight, dimensions: { length: '100', width: '100', height: '100' }, ...extra, +}); +const box = (quantity = 3, extra = {}) => ({ + id: 'box', quantity, inner_dimensions: { length: '200', width: '100', height: '200' }, ...extra, +}); +const fixed = (x = '0', y = '0', z = '0', instance = 1, extra = {}) => ({ + item_type: 'cube', container_type: 'box', container_instance: instance, + position: { x, y, z }, orientation: 'LWH', ...extra, +}); + +function request({ items, containers, placements, configuration } = {}) { + const data = { items: items ?? [cube(5)], containers: containers ?? [box()], + fixed_placements: placements ?? [fixed('100')] }; + if (configuration !== undefined) data.configuration = configuration; + return data; +} + +const fixedRows = (result) => result.containers.flatMap((container) => container.placements + .filter((placement) => placement.fixed) + .map((placement) => [container.id, placement.item_id, placement.position.x.value, + placement.position.y.value, placement.position.z.value])); +const placedCount = (result) => result.containers.reduce((total, container) => total + container.placements.length, 0); + +// The refusal's message; its reason and field are asserted here, since a caller branches on them. +function refusal(data, reason = 'cannot_hold', field = '/fixed_placements') { + try { + packFallback(data); + } catch (error) { + assert.ok(error instanceof FixedPlacementError, `${error.name}: ${error.message}`); + assert.ok(error instanceof InvalidRequestError, 'a fixed-placement refusal is a request error'); + assert.equal(error.code, 'invalid_fixed_placement'); + assert.deepEqual([error.reason, error.field], [reason, field]); + return error.message; + } + assert.fail('the request was accepted'); +} + +test('fixed_placements is no longer refused as unsupported', () => { + assert.deepEqual(UNSUPPORTED_FIELDS.request, []); +}); + +for (const profile of ['fast', 'balanced', 'quality', 'exact_small']) { + test(`a fixed item is reported where the request put it (${profile})`, () => { + const data = request({ configuration: { solver_profile: profile } }); + const result = packFallback(data); + assert.ok(['feasible', 'optimal'].includes(result.status), result.status); + assert.deepEqual(fixedRows(result), [['box#1', 'cube#1', '100', '0', '0']]); + assert.equal(placedCount(result), 5); + assert.deepEqual(validate(data, result), []); + }); +} + +test('only fixed placements carry the flag, and the key is absent otherwise', () => { + const result = packFallback(request()); + const all = result.containers.flatMap((container) => container.placements); + assert.equal(all.filter((placement) => placement.fixed === true).length, 1); + assert.equal(all.filter((placement) => !('fixed' in placement)).length, 4); +}); + +test('a request without fixed placements answers byte for byte as before', () => { + const data = request(); + delete data.fixed_placements; + const empty = { ...data, fixed_placements: [] }; + assert.equal(JSON.stringify(packFallback(empty)), JSON.stringify(packFallback(data))); + assert.ok(!JSON.stringify(packFallback(data)).includes('"fixed"')); +}); + +test('fixed items take the first instances of their type in listed order', () => { + const result = packFallback(request({ placements: [fixed('100'), fixed('0')] })); + assert.deepEqual(fixedRows(result), [['box#1', 'cube#1', '100', '0', '0'], ['box#1', 'cube#2', '0', '0', '0']]); +}); + +test('fixed containers open first and free ones are numbered after them', () => { + const result = packFallback(request({ items: [cube(9)], placements: [fixed(), fixed('0', '0', '0', 2)] })); + const ids = result.containers.map((container) => container.id); + assert.deepEqual(ids.slice(0, 2), ['box#1', 'box#2']); + assert.deepEqual(ids.slice(2), ['box#3']); +}); + +test('containers are numbered per type, fixed ones first', () => { + const slab = { id: 'slab', quantity: 2, weight: '500', dimensions: { length: '200', width: '100', height: '50' } }; + const big = { id: 'big', quantity: 2, inner_dimensions: { length: '300', width: '200', height: '200' } }; + const data = request({ items: [cube(5), slab], containers: [box(), big], + placements: [fixed('100'), fixed('0', '0', '0', 2), { ...fixed(), item_type: 'slab', container_type: 'big' }] }); + const result = packFallback(data); + assert.deepEqual(result.containers.map((container) => container.id), ['box#1', 'box#2', 'big#1']); + assert.deepEqual(validate(data, result), []); +}); + +test('a fixed container is kept when no free item fits in it', () => { + const tray = { id: 'tray', quantity: 1, inner_dimensions: { length: '100', width: '100', height: '100' } }; + const result = packFallback(request({ items: [cube(3)], containers: [tray, box()], + placements: [{ ...fixed(), container_type: 'tray' }] })); + assert.equal(result.containers[0].id, 'tray#1'); + assert.deepEqual(result.containers[0].placements.map((placement) => placement.item_id), ['cube#1']); +}); + +test('a fixed container survives a search that runs out of effort', () => { + const result = packFallback(request({ items: [cube(40)], containers: [box(20)], + placements: [fixed(), fixed('0', '0', '0', 2)], configuration: { effort_budget: { max_search_nodes: 1 } } })); + assert.deepEqual(new Set(fixedRows(result).map((row) => row[0])), new Set(['box#1', 'box#2'])); + assert.deepEqual(result.warnings, [FALLBACK_WARNING]); +}); + +test('a fixed container survives an already expired deadline with its fixed items alone', () => { + let now = 0; + const clock = () => { now += 5000; return now; }; + const result = packFallback(request({ items: [cube(6)], placements: [fixed(), fixed('0', '0', '0', 2)] }), clock); + assert.deepEqual(result.containers.map((container) => container.placements.length), [1, 1]); + assert.deepEqual(fixedRows(result).map((row) => row[0]), ['box#1', 'box#2']); + assert.equal(result.algorithm.time_limit_reached, true); +}); + +test('a free item resting on a fixed one loads it', () => { + const data = request({ items: [cube(2)], containers: [box(1)], placements: [fixed()], + configuration: { solvers: ['extreme_points'] } }); + data.containers[0].inner_dimensions.length = '100'; + const [container] = packFallback(data).containers; + const byId = Object.fromEntries(container.placements.map((placement) => [placement.item_id, placement])); + assert.equal(byId['cube#2'].position.z.value, '100'); + assert.equal(byId['cube#1'].top_load.value, '1000'); +}); + +test('a fixed item reports the support its fellow fixed items give it', () => { + const data = request({ items: [cube(3)], containers: [box(1)], placements: [fixed('0', '0', '100')] }); + const [placement] = packFallback(data).containers[0].placements.filter((entry) => entry.fixed); + assert.equal(placement.support_ratio, '0.000000'); +}); + +test('fixed weight counts against payload', () => { + const result = packFallback(request({ items: [cube(3)], containers: [box(2, { max_payload: '1000' })], + placements: [fixed()] })); + assert.deepEqual(result.containers.map((container) => container.placements.length), [1, 1]); + assert.ok(['cube#2', 'cube#3'].includes(result.unpacked_items.map((item) => item.item_id).join())); +}); + +for (const solver of ['grid', 'homogeneous_blocks', 'maximal_spaces', 'layer']) { + test(`every solver packs around fixed items (${solver})`, () => { + const data = request({ configuration: { solvers: [solver] } }); + const result = packFallback(data); + assert.deepEqual(fixedRows(result), [['box#1', 'cube#1', '100', '0', '0']]); + assert.deepEqual(result.warnings, [FALLBACK_WARNING]); + assert.equal(placedCount(result), 5); + assert.deepEqual(validate(data, result), []); + }); +} + +test('the compact lattice stands down for a request with fixed items, as it does for obstacles', () => { + // Python and PHP keep the lattice on the containers without fixed items; this engine's + // lattice is a whole-request path, so every container is searched instead. + const configuration = { solver_profile: 'fast', require_placement_coordinates: false }; + const data = request({ items: [cube(12)], containers: [box(4)], configuration }); + const result = packFallback(data); + assert.equal(result.containers[0].placements[0].fixed, true); + assert.ok(result.containers.every((container) => !('lattice_summary' in container))); + assert.equal(result.summary.packed_item_count, 12); + assert.deepEqual(validate(data, result), []); + delete data.fixed_placements; + assert.ok(packFallback(data).containers.some((container) => 'lattice_summary' in container)); +}); + +test('every start of a multi-start portfolio keeps the fixed items', () => { + const single = packFallback(request({ items: [cube(6)] })); + const multi = packFallback(request({ items: [cube(6)], configuration: { multi_start_orders: 3 } })); + assert.deepEqual(fixedRows(multi), fixedRows(single)); +}); + +const REFUSALS = [ + ['collision', (d) => d.fixed_placements.push(fixed('100')), 'collision: cube#1 with cube#2'], + ['unknown item', (d) => { d.fixed_placements[0].item_type = 'crate'; }, 'unknown item type "crate"'], + ['unknown container', (d) => { d.fixed_placements[0].container_type = 'crate'; }, 'unknown container type "crate"'], + ['instance gap', (d) => { d.fixed_placements[0].container_instance = 2; }, 'box instances [2] are not numbered 1..1'], + ['instance gaps', (d) => { d.fixed_placements[0].container_instance = 2; d.fixed_placements.push(fixed('0', '0', '0', 3)); }, + 'box instances [2,3] are not numbered 1..2'], + ['outside', (d) => { d.fixed_placements[0].position.x = '150'; }, 'outside_container: cube#1'], + ['too many', (d) => { d.items[0].quantity = 1; d.fixed_placements.push(fixed()); }, '2 cube fixed, 1 requested'], + ['rotation', (d) => { d.items[0].keep_upright = true; d.fixed_placements[0].orientation = 'HWL'; }, + 'cube may not be placed in orientation HWL'], + ['obstacle', (d) => { d.containers[0].obstacles = [{ id: 'pillar', origin: { x: '150' }, + dimensions: { length: '10', width: '10', height: '10' } }]; }, 'obstacle_collision: cube#1'], + ['payload', (d) => { d.containers[0].max_payload = '500'; }, 'payload_exceeded: box#1'], + ['clearance', (d) => { d.configuration = { clearance: '1' }; }, 'outside_container: cube#1'], + ['support', (d) => { d.configuration = { minimum_support_ratio: 1 }; d.fixed_placements[0].position.z = '50'; }, + 'insufficient_support: cube#1: 0/2560000000000 < 1.000000'], + ['container quantity', (d) => { d.containers[0].quantity = 1; d.fixed_placements.push(fixed('0', '0', '0', 2)); }, + '2 box named, 1 available'], + ['max_containers', (d) => { d.configuration = { max_containers: 1 }; d.fixed_placements.push(fixed('0', '0', '0', 2)); }, + '2 containers hold fixed items, max_containers is 1'], +]; + +for (const [name, mutate, detail] of REFUSALS) { + test(`a fixed set that cannot hold is refused before search: ${name}`, () => { + const data = request(); + mutate(data); + assert.equal(refusal(data), `invalid_fixed_placement: ${detail}`); + }); +} + +// The schema's shape, checked before anything is parsed: nothing is coerced, defaulted to the +// origin or ignored. The request has one item type, so a field the checks let through would be +// answered by the closed-form lattice path as if no item were fixed. +const entry = () => ({ item_type: 'cube', container_type: 'box', position: { x: '100' }, orientation: 'LWH' }); +const SHAPES = [ + ['not a list', 'x', 'fixed_placements is a list', '/fixed_placements'], + ['an object', { 0: entry() }, 'fixed_placements is a list', '/fixed_placements'], + ['an entry that is not an object', [5], 'fixed_placements[0] is an object', '/fixed_placements/0'], + ['an unknown key', [{ ...entry(), note: 'strapped', ab: 1 }], 'fixed_placements[0] does not carry ["ab","note"]', '/fixed_placements/0'], + ['a missing key', [{ item_type: 'cube' }], 'fixed_placements[0] needs ["container_type","orientation"]', '/fixed_placements/0'], + ['an empty item type', [{ ...entry(), item_type: '' }], 'fixed_placements[0].item_type is a non-empty string', '/fixed_placements/0/item_type'], + ['a numeric container type', [{ ...entry(), container_type: 5 }], 'fixed_placements[0].container_type is a non-empty string', '/fixed_placements/0/container_type'], + ['an unknown orientation', [{ ...entry(), orientation: 'XYZ' }], 'fixed_placements[0].orientation is one of the six codes', '/fixed_placements/0/orientation'], + ['an orientation in a list', [{ ...entry(), orientation: ['LWH'] }], 'fixed_placements[0].orientation is one of the six codes', '/fixed_placements/0/orientation'], + ['a text instance', [{ ...entry(), container_instance: '1' }], 'fixed_placements[0].container_instance counts from 1', '/fixed_placements/0/container_instance'], + ['a boolean instance', [{ ...entry(), container_instance: true }], 'fixed_placements[0].container_instance counts from 1', '/fixed_placements/0/container_instance'], + ['a fractional instance', [{ ...entry(), container_instance: 1.5 }], 'fixed_placements[0].container_instance counts from 1', '/fixed_placements/0/container_instance'], + ['a null instance', [{ ...entry(), container_instance: null }], 'fixed_placements[0].container_instance counts from 1', '/fixed_placements/0/container_instance'], + ['an instance beyond 2^53 - 1', [{ ...entry(), container_instance: 2 ** 53 }], 'fixed_placements[0].container_instance counts from 1', '/fixed_placements/0/container_instance'], + ['a position list', [{ ...entry(), position: ['100', '0', '0'] }], 'fixed_placements[0].position is a point object', '/fixed_placements/0/position'], + ['a null position', [{ ...entry(), position: null }], 'fixed_placements[0].position is a point object', '/fixed_placements/0/position'], + ['an extra axis', [{ ...entry(), position: { x: '100', w: '5' } }], 'fixed_placements[0].position does not carry ["w"]', '/fixed_placements/0/position'], + ['a boolean axis', [{ ...entry(), position: { x: true } }], 'fixed_placements[0].position.x is a measure', '/fixed_placements/0/position/x'], + ['a null axis', [{ ...entry(), position: { y: null } }], 'fixed_placements[0].position.y is a measure', '/fixed_placements/0/position/y'], + ['a list axis', [{ ...entry(), position: { z: [0] } }], 'fixed_placements[0].position.z is a measure', '/fixed_placements/0/position/z'], + ['an unparsable coordinate', [{ ...entry(), position: { x: 'ten' } }], 'fixed_placements[0].position.x is a measure', + '/fixed_placements/0/position/x'], + ['an unknown coordinate unit', [{ ...entry(), position: { y: { value: '1', unit: 'furlong' } } }], + 'fixed_placements[0].position.y is a measure', '/fixed_placements/0/position/y'], + ['a negative coordinate', [{ ...entry(), position: { x: '100', z: '-1' } }], 'fixed_placements[0].position.z cannot be negative', + '/fixed_placements/0/position/z'], + ['a later entry', [entry(), { ...entry(), orientation: 'lwh' }], 'fixed_placements[1].orientation is one of the six codes', '/fixed_placements/1/orientation'], +]; + +for (const [name, placements, detail, field] of SHAPES) { + test(`fixed placements that are not the schema's shape are refused: ${name}`, () => { + const data = request({ items: [cube(3)], containers: [box(2)] }); + data.fixed_placements = placements; + assert.equal(refusal(data, 'malformed', field), `invalid_fixed_placement: ${detail}`); + }); +} + +test('a null or empty fixed_placements is no fixed placements', () => { + for (const placements of [null, []]) { + const data = request({ items: [cube(3)], containers: [box(2)] }); + data.fixed_placements = placements; + const result = packFallback(data); + assert.equal(placedCount(result), 3); + assert.deepEqual(fixedRows(result), []); + } +}); + +test('an absent instance and position are the first container and the origin', () => { + const data = request({ items: [cube(3)], containers: [box(2)] }); + data.fixed_placements = [{ item_type: 'cube', container_type: 'box', orientation: 'LWH' }]; + assert.deepEqual(fixedRows(packFallback(data)), [['box#1', 'cube#1', '0', '0', '0']]); +}); + +// Every other rule of the ordinary validator, with the text Python reports for it. +const item = (id, extra = {}) => ({ id, quantity: 3, weight: '1000', + dimensions: { length: '100', width: '100', height: '100' }, ...extra }); +const wide = { length: '200', width: '100', height: '100' }; +const at = (type, x = '0', z = '0', instance = 1) => ({ ...fixed(x, '0', z, instance), item_type: type }); +const room = (extra = {}) => ({ id: 'box', quantity: 3, inner_dimensions: { length: '300', width: '100', height: '300' }, ...extra }); +const RULES = [ + ['floor', [item('a'), item('b', { must_be_on_floor: true })], room(), [at('a'), at('b', '0', '100')], 'must_be_on_floor: b#1: '], + ['eligibility', [item('a', { eligible_container_tags: ['cold'] })], room({ tags: ['dry'] }), [at('a')], 'container_ineligible: a#1'], + ['compatibility', [item('a', { tags: ['food'] }), item('b', { incompatible_tags: ['food'] })], room(), + [at('a'), at('b', '100')], 'incompatible_items: a#1: a is incompatible with b'], + ['tag limit', [item('a', { tags: ['hazmat'] })], room({ tag_limits: { hazmat: 1 } }), [at('a'), at('a', '100')], + 'tag_count_exceeded: a#1: hazmat: limit 1, would be 2'], + ['non-stackable', [item('a', { stackable: false }), item('b')], room(), [at('a'), at('b', '0', '100')], 'non_stackable: a#1: a'], + ['top load', [item('a', { max_top_load: '500' }), item('b')], room(), [at('a'), at('b', '0', '100')], 'top_load_exceeded: box#1: a#1'], + ['stacked limit', [item('a', { max_stacked_items: 1 })], room(), [at('a'), at('a', '0', '100'), at('a', '0', '200')], + 'stacked_item_limit_exceeded: box#1: a#1'], + ['single contact', [item('a'), item('b', { ground_contact_rule: 'single', dimensions: wide })], room(), + [at('a'), at('a', '100'), at('b', '0', '100')], 'ground_contact_violation: b#1: single: rests on 2 item(s)'], + ['covered contact', [item('a'), item('b', { ground_contact_rule: 'covered', dimensions: wide })], room(), + [at('a'), at('b', '0', '100')], 'ground_contact_violation: b#1: covered: 1 supporter(s), not all four corners touched'], + ['route', [item('a', { stop_index: 1 }), item('b', { stop_index: 2 })], + { id: 'box', quantity: 3, inner_dimensions: { length: '100', width: '100', height: '200' } }, + [at('a'), at('b', '0', '100')], 'unloading_order_violation: box#1: stop 1 cannot be fully unloaded (a#1 still blocked)'], + ['max items', [item('a')], room({ max_items: 1 }), [at('a'), at('a', '100')], 'max_items_exceeded: box#1'], + ['group', [item('a', { group: 'g' })], room(), [at('a'), at('a', '0', '0', 2)], 'group_split: g: box#1, box#2'], + ['axle', [item('a', { weight: '5000' })], room({ axles: [{ position: '0', max_load: '1000' }, { position: '300', max_load: '100000' }] }), + [at('a')], 'axle_overloaded: box#1: front'], +]; + +for (const [name, items, container, placements, detail] of RULES) { + test(`the fixed set is held to the ordinary rules: ${name}`, () => { + assert.equal(refusal({ items, containers: [container], fixed_placements: placements }), + `invalid_fixed_placement: ${detail}`); + }); +} + +test('the validator catches a moved fixed item', () => { + const packed = packFallback(request()); + const moved = request({ placements: [fixed('0')] }); + assert.deepEqual(new Set(validate(moved, packed)), new Set(['fixed_placement_moved', 'unexpected_fixed_placement'])); +}); + +test('the validator catches a missing fixed container', () => { + const data = request(); + delete data.fixed_placements; + const packed = packFallback(data); + assert.ok(validate(request({ placements: [fixed('0', '0', '0', 3)] }), packed).includes('fixed_container_missing')); +}); + +test('rebalancing never moves a fixed item', () => { + const data = request({ items: [cube(5, '5000')], containers: [box(2)], + placements: [fixed(), fixed('100'), fixed('0', '0', '100'), fixed('0', '0', '0', 2)] }); + const packed = packFallback(data); + assert.equal(packed.containers.length, 2); + const rebalanced = rebalanceWeight(data, packed); + const fixedIds = new Set(['cube#1', 'cube#2', 'cube#3', 'cube#4']); + assert.ok(rebalanced.moves.every((move) => !fixedIds.has(move.item_id))); + assert.deepEqual(validate(data, { ...packed, containers: rebalanced.containers }), []); +}); diff --git a/test/package-exports.test.mjs b/test/package-exports.test.mjs index c063121..7651b43 100644 --- a/test/package-exports.test.mjs +++ b/test/package-exports.test.mjs @@ -48,11 +48,22 @@ test('the operational artifact and its exports resolve by package name', async ( assert.equal(exports.CSV_COLUMNS[0], 'record'); }); +test('plan revisions resolve by package name', async () => { + const revisions = await import(`${manifest.name}/revisions.js`); + for (const name of ['rootRevision', 'deriveRevision', 'applyEvents', 'verifyRevisionChain', + 'documentDigest', 'canonicalRevisionJson', 'PlanRevisionError']) { + assert.equal(typeof revisions[name], 'function', name); + } + assert.equal(revisions.FORMAT, 'packvium-plan-revision/v1'); +}); + test('a module an export imports is published even when it is not exported itself', () => { // `canonical-json.js` is internal: absent from `exports` on purpose, but `artifacts.js` and // `execution.js` import it, so a tarball without it would break both at load time. const published = new Set(manifest.files); - for (const name of ['canonical-json.js', 'artifacts.js', 'artifact-exports.js', 'artifacts.d.ts', 'artifact-exports.d.ts']) { + // `revisions.js` imports `commerce-model.js` and `artifacts.js` the same way. + for (const name of ['canonical-json.js', 'commerce-model.js', 'artifacts.js', 'artifact-exports.js', 'revisions.js', + 'artifacts.d.ts', 'artifact-exports.d.ts', 'revisions.d.ts']) { assert.ok(published.has(name), `"files" does not publish ${name}`); } assert.ok(!('./canonical-json.js' in manifest.exports), 'canonical-json.js is not public API'); diff --git a/test/request-errors.test.mjs b/test/request-errors.test.mjs new file mode 100644 index 0000000..da68c12 --- /dev/null +++ b/test/request-errors.test.mjs @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + FixedPlacementError, InvalidRequestError, UnsupportedFeatureError, packFallback, +} from '../fallback.js'; +import * as root from '../index.js'; + +/** + * Structured request errors (`packvium.request_errors`): one type, a closed reason and the JSON + * Pointer of the bad value. `conformance/request_errors/run.py` holds every case of the shared + * table to Python's answer; these tests hold the type itself and one case per reason and scope. + */ + +const base = () => ({ + items: [{ id: 'cube', quantity: 2, weight: '1000', dimensions: { length: '100', width: '100', height: '100' } }], + containers: [{ id: 'box', quantity: 2, inner_dimensions: { length: '200', width: '100', height: '200' } }], + configuration: { solver_profile: 'fast' }, +}); + +function refusalOf(edit) { + const request = base(); + edit(request); + try { + packFallback(request); + } catch (error) { + return error; + } + return assert.fail('the request was accepted'); +} + +function assertRefused(edit, reason, field, detail) { + const error = refusalOf(edit); + assert.ok(error instanceof InvalidRequestError, `${error.name}: ${error.message}`); + assert.deepEqual( + { code: error.code, reason: error.reason, field: error.field, detail: error.detail, message: error.message }, + { code: 'invalid_request', reason, field, detail, message: `invalid_request: ${field}: ${detail}` }, + ); +} + +test('the error is a RangeError with a code, reason, field and detail, exported from the root', () => { + const error = new InvalidRequestError('below_minimum', '/items/0/quantity', 'must be at least 1'); + assert.ok(error instanceof RangeError); + assert.equal(error.name, 'InvalidRequestError'); + assert.equal(error.message, 'invalid_request: /items/0/quantity: must be at least 1'); + assert.equal(new InvalidRequestError('wrong_type', '', 'must be an object').message, 'invalid_request: must be an object'); + assert.equal(root.InvalidRequestError, InvalidRequestError); + assert.equal(root.FixedPlacementError, FixedPlacementError); +}); + +test('a fixed-placement refusal is a request error that keeps its own code and message', () => { + const error = new FixedPlacementError('fixed_placements is a list', 'malformed', '/fixed_placements'); + assert.ok(error instanceof InvalidRequestError); + assert.deepEqual([error.name, error.code, error.reason, error.field, error.message], + ['FixedPlacementError', 'invalid_fixed_placement', 'malformed', '/fixed_placements', + 'invalid_fixed_placement: fixed_placements is a list']); + const holding = new FixedPlacementError('outside_container: cube#1'); + assert.deepEqual([holding.reason, holding.field], ['cannot_hold', '/fixed_placements']); +}); + +test('a request that is not an object is refused as a whole', () => { + for (const request of [[base()], null, 'request']) { + assert.throws(() => packFallback(request), { name: 'InvalidRequestError', reason: 'wrong_type', field: '', + message: 'invalid_request: must be an object' }); + } +}); + +const CASES = [ + ['a missing list', (r) => { delete r.items; }, 'missing_field', '/items', 'is required'], + ['a list that is not one', (r) => { r.containers = { box: r.containers[0] }; }, 'wrong_type', '/containers', 'must be a list'], + ['an entry that is not an object', (r) => { r.items.push('slab'); }, 'wrong_type', '/items/1', 'must be an object'], + ['a missing id', (r) => { r.items[0].id = null; }, 'missing_field', '/items/0/id', 'is required'], + ['a numeric id', (r) => { r.containers[0].id = 7; }, 'wrong_type', '/containers/0/id', 'must be a string'], + ['a later repeated id', (r) => { r.items.push({ ...r.items[0] }); }, 'duplicate_id', '/items/1/id', 'repeats the id "cube"'], + ['a repeated container id', (r) => { r.containers.push({ ...r.containers[0] }); }, 'duplicate_id', '/containers/1/id', + 'repeats the id "box"'], + ['a text quantity', (r) => { r.items[0].quantity = '2'; }, 'wrong_type', '/items/0/quantity', 'must be an integer'], + ['a fractional quantity', (r) => { r.items[0].quantity = 1.5; }, 'wrong_type', '/items/0/quantity', 'must be an integer'], + ['a whole number past 2^53 - 1', (r) => { r.items[0].stop_index = 2 ** 53; }, 'above_maximum', '/items/0/stop_index', + 'must be at most 9007199254740991'], + ['a whole number far below the floor', (r) => { r.items[0].quantity = -(2 ** 60); }, 'below_minimum', '/items/0/quantity', + 'must be at least 1'], + ['a ratio above one', (r) => { r.items[0].minimum_support_ratio = 1.5; }, 'above_maximum', '/items/0/minimum_support_ratio', + 'must be at most 1'], + ['a boolean ratio', (r) => { r.configuration.minimum_support_ratio = true; }, 'wrong_type', + '/configuration/minimum_support_ratio', 'must be a number'], + ['a negative unbounded ratio', (r) => { r.items[0].compression_ratio = -0.5; }, 'below_minimum', '/items/0/compression_ratio', + 'must be at least 0'], + ['a missing side', (r) => { delete r.items[0].dimensions.width; }, 'missing_field', '/items/0/dimensions/width', 'is required'], + ['dimensions that are not an object', (r) => { r.items[0].dimensions = 'big'; }, 'wrong_type', '/items/0/dimensions', + 'must be an object'], + ['a boolean measure', (r) => { r.items[0].dimensions.length = true; }, 'wrong_type', '/items/0/dimensions/length', + 'must be a measure'], + ['a fractional number measure', (r) => { r.items[0].weight = 10.5; }, 'wrong_type', '/items/0/weight', 'must be a measure'], + ['an unparsable measure', (r) => { r.items[0].nesting_height = 'ten'; }, 'wrong_type', '/items/0/nesting_height', + 'must be a measure'], + ['a measure object without a value', (r) => { r.items[0].max_top_load = { unit: 'kg' }; }, 'wrong_type', + '/items/0/max_top_load', 'must be a measure'], + ['an unknown measure unit', (r) => { r.containers[0].max_payload = { value: '1', unit: 'stone' }; }, 'invalid_unit', + '/containers/0/max_payload', 'has an unknown unit "stone"'], + ['a null measure unit', (r) => { r.containers[0].tare_weight = { value: '1', unit: null }; }, 'invalid_unit', + '/containers/0/tare_weight', 'has an unknown unit null'], + ['a negative measure', (r) => { r.containers[0].outer_dimensions = { length: '210', width: '-1', height: '210' }; }, + 'negative_measure', '/containers/0/outer_dimensions/width', 'cannot be negative'], + ['an unknown request unit', (r) => { r.units = { length: 'furlong' }; }, 'invalid_unit', '/units/length', + 'has an unknown unit "furlong"'], + ['units that are not an object', (r) => { r.units = 'mm'; }, 'wrong_type', '/units', 'must be an object'], + ['an unknown profile', (r) => { r.configuration.solver_profile = 'fastest'; }, 'not_allowed', '/configuration/solver_profile', + 'must be one of ["fast","balanced","quality","exact_small"]'], + ['a configuration that is not an object', (r) => { r.configuration = [1]; }, 'wrong_type', '/configuration', + 'must be an object'], + ['an effort budget that is not an object', (r) => { r.configuration.effort_budget = 5; }, 'wrong_type', + '/configuration/effort_budget', 'must be an object'], + ['an effort limit of zero', (r) => { r.configuration.effort_budget = { max_restarts: 0 }; }, 'below_minimum', + '/configuration/effort_budget/max_restarts', 'must be at least 1'], + ['an escaped tag limit', (r) => { r.containers[0].tag_limits = { 'a/b~c': 0 }; }, 'below_minimum', + '/containers/0/tag_limits/a~1b~0c', 'must be at least 1'], + ['a bracket list that is not one', (r) => { r.containers[0].rate_table = { weight_brackets_g: 1000, prices_minor: [5] }; }, + 'wrong_type', '/containers/0/rate_table/weight_brackets_g', 'must be a list'], + ['a negative minimum charge', (r) => { r.containers[0].rate_table = { minimum_charge_minor: -1 }; }, 'below_minimum', + '/containers/0/rate_table/minimum_charge_minor', 'must be at least 0'], + ['obstacles that are not a list', (r) => { r.containers[0].obstacles = {}; }, 'wrong_type', '/containers/0/obstacles', + 'must be a list'], + ['an obstacle origin that is not an object', (r) => { + r.containers[0].obstacles = [{ id: 'p', origin: 5, dimensions: { length: '1', width: '1', height: '1' } }]; + }, 'wrong_type', '/containers/0/obstacles/0/origin', 'must be an object'], + ['an obstacle without dimensions', (r) => { r.containers[0].obstacles = [{ id: 'p' }]; }, 'missing_field', + '/containers/0/obstacles/0/dimensions', 'is required'], +]; + +for (const [name, edit, reason, field, detail] of CASES) { + test(`a malformed request is refused with its rule and pointer: ${name}`, () => assertRefused(edit, reason, field, detail)); +} + +test('the first violation wins: configuration before items, items before containers', () => { + assertRefused((r) => { r.items[0].quantity = 0; r.configuration.max_containers = 0; }, 'below_minimum', + '/configuration/max_containers', 'must be at least 1'); + assertRefused((r) => { r.containers[0].quantity = 0; r.items[0].quantity = 0; }, 'below_minimum', + '/items/0/quantity', 'must be at least 1'); +}); + +test('null optional fields are absent, and a valid request is admitted', () => { + const request = base(); + Object.assign(request, { units: { length: null } }); + Object.assign(request.items[0], { weight: null, stop_index: null, max_top_load: null }); + Object.assign(request.containers[0], { + obstacles: [{ id: 'p', origin: null, dimensions: { length: '1', width: '1', height: '1' } }], + rate_table: null, tag_limits: null, + }); + request.configuration.effort_budget = null; + assert.equal(packFallback(request).status, 'feasible'); + assert.equal(packFallback({ ...base(), units: null }).status, 'feasible'); +}); + +test('an error the rule table does not name still reaches the caller as a request error', () => { + assertRefusedWith((r) => { r.configuration.objective = 'cheapest'; }, 'invalid_request: unknown objective "cheapest"'); + assertRefusedWith((r) => { r.items[0].nesting_height = '100'; }, + "invalid_request: nesting_height must be at least zero and strictly less than the item's own height"); +}); + +function assertRefusedWith(edit, message) { + const error = refusalOf(edit); + assert.ok(error instanceof InvalidRequestError, `${error.name}: ${error.message}`); + assert.deepEqual([error.reason, error.field, error.message], ['invalid_value', '', message]); +} + +test('an error that carries its own code is not relabelled', () => { + const direction = refusalOf((r) => { r.containers[0].access_directions = ['+w']; }); + assert.equal(direction.code, 'invalid_direction'); + assert.ok(!(direction instanceof InvalidRequestError)); + const unsupported = refusalOf((r) => { r.containers[0].pallet_overhang_limit = '10'; }); + assert.ok(unsupported instanceof UnsupportedFeatureError); +}); + +test('an unsupported field is refused ahead of a malformed shape, without crashing on it', () => { + const error = refusalOf((r) => { r.items = { cube: r.items[0] }; r.containers.push(null, { id: 'b', pallet_overhang_limit: 1 }); }); + assert.ok(error instanceof UnsupportedFeatureError, `${error.name}: ${error.message}`); + assertRefused((r) => { r.containers[0].obstacles = [null]; }, 'wrong_type', '/containers/0/obstacles/0', 'must be an object'); +}); + +test('a refusal from the solve itself is not dressed up as the caller\'s mistake', () => { + const request = base(); + request.items[0].weight = '6000'; + request.containers[0].rate_table = { weight_brackets_g: [1000], prices_minor: [500] }; + Object.assign(request.configuration, { objective: 'lowest_landed_cost', dimensional_weight_divisor: 5000 }); + const error = (() => { try { packFallback(request); } catch (caught) { return caught; } return null; })(); + assert.ok(error instanceof RangeError && /no published price/.test(error.message), String(error)); + assert.ok(!(error instanceof InvalidRequestError)); +}); + +test('the native backend gets the same InvalidRequestError, and only well-formed requests reach it', () => { + const calls = []; + const binding = { packJson: (input) => { calls.push(input); return '{"native":true}'; } }; + const bad = base(); + bad.items[0].quantity = 0; + assert.throws(() => root.packJsonWith(binding, JSON.stringify(bad)), (error) => + error instanceof InvalidRequestError && error.field === '/items/0/quantity'); + assert.equal(calls.length, 0); + assert.equal(root.packJsonWith(binding, JSON.stringify(base())), '{"native":true}'); + assert.equal(calls.length, 1); +}); diff --git a/test/revisions.test.mjs b/test/revisions.test.mjs new file mode 100644 index 0000000..cfc0ec4 --- /dev/null +++ b/test/revisions.test.mjs @@ -0,0 +1,477 @@ +/** + * Plan revisions: an append-only, hash-chained record of exceptions (docs/PLAN-REVISIONS.md). + * + * Mirrors `packvium-python/tests/test_revisions.py` and `PlanRevisionTest.php`. The module is + * imported by package name, so a missing `exports` entry fails here and not only in a + * consumer's project. Refusal messages are asserted whole: they are compared across engines. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { SUITE_VERSION, buildOperationalArtifact } from '@packvium/engine/artifacts.js'; +import { + FORMAT, PlanRevisionError, applyEvents, canonicalRevisionJson, deriveRevision, documentDigest, + rootRevision, verifyRevisionChain, +} from '@packvium/engine/revisions.js'; +import { packFallback } from '../fallback.js'; + +function cubeRequest() { + return { + items: [ + { id: 'cube', quantity: 4, weight: '1000', dimensions: { length: '100', width: '100', height: '100' } }, + { id: 'slab', quantity: 1, dimensions: { length: '200', width: '100', height: '50' } }, + ], + containers: [ + { id: 'box', quantity: 3, inner_dimensions: { length: '200', width: '100', height: '200' } }, + { id: 'crate', inner_dimensions: { length: '300', width: '200', height: '200' } }, + ], + configuration: { solver_profile: 'balanced', minimum_support_ratio: 1.0 }, + }; +} + +const artifactFor = (request) => buildOperationalArtifact(request, packFallback(request)); + +const locked = ({ x = '0', z = '0', kind = 'placement_locked', sequence = 1 } = {}) => ({ + sequence, type: kind, placement: { item_type: 'cube', container_type: 'box', position: { x, z }, orientation: 'LWH' }, +}); + +function chain() { + const root = rootRevision(cubeRequest()); + const firstArtifact = artifactFor(root.request); + const first = deriveRevision(root, firstArtifact, [ + locked(), { sequence: 2, type: 'item_missing', item_type: 'cube', quantity: 1 }, + ]); + const secondArtifact = artifactFor(first.request); + const second = deriveRevision(first, secondArtifact, [locked({ kind: 'placement_verified', sequence: 3 })]); + return [[root, first, second], [null, firstArtifact, secondArtifact]]; +} + +const copy = (value) => JSON.parse(JSON.stringify(value)); +const codes = (revisions, artifacts = null) => new Set(verifyRevisionChain(revisions, artifacts).map((entry) => entry.code)); + +function refused(action, code, message) { + assert.throws(action, (error) => { + assert.ok(error instanceof PlanRevisionError, `${error.name}: ${error.message}`); + assert.equal(error.code, code); + if (message !== undefined) assert.equal(error.message, message); + return true; + }); +} + +test('the root records nothing against the request', () => { + assert.deepEqual(rootRevision(cubeRequest()), { + format: FORMAT, suite_version: SUITE_VERSION, revision: 0, parent: null, approved: null, events: [], + request: cubeRequest(), + }); +}); + +test('a revision links its parent and approved artifact by digest', () => { + const [[root, first], artifacts] = chain(); + assert.equal(first.revision, 1); + assert.equal(first.parent, documentDigest(root)); + assert.equal(first.approved.artifact, documentDigest(artifacts[1])); + assert.deepEqual(first.approved.replay, artifacts[1].provenance.replay); +}); + +test('a digest is sha256 over the canonical bytes', () => { + const root = rootRevision(cubeRequest()); + const expected = createHash('sha256').update(Buffer.from(canonicalRevisionJson(root), 'utf8')).digest('hex'); + assert.equal(documentDigest(root), `sha256:${expected}`); + assert.match(documentDigest(root), /^sha256:[0-9a-f]{64}$/); +}); + +test('transport spelling never changes a digest', () => { + const root = rootRevision(cubeRequest()); + const reparsed = JSON.parse(JSON.stringify(root, null, 4)); + reparsed.request.configuration.minimum_support_ratio = 1; + assert.equal(documentDigest(reparsed), documentDigest(root)); +}); + +test('the same inputs derive the same bytes', () => { + const [first] = chain(); + const [second] = chain(); + assert.deepEqual(first.map(canonicalRevisionJson), second.map(canonicalRevisionJson)); +}); + +test('building never mutates the caller\'s documents', () => { + const request = cubeRequest(); + const root = rootRevision(request); + const artifact = artifactFor(root.request); + const before = [JSON.stringify(root), JSON.stringify(artifact)]; + const events = [locked()]; + deriveRevision(root, artifact, events); + assert.deepEqual([JSON.stringify(root), JSON.stringify(artifact)], before); + assert.deepEqual(events, [locked()]); + assert.deepEqual(request, cubeRequest()); +}); + +test('a missing item lowers its quantity and a last one removes the type', () => { + const request = cubeRequest(); + const lowered = applyEvents(request, [{ sequence: 1, type: 'item_missing', item_type: 'cube', quantity: 3 }]); + assert.equal(lowered.items[0].quantity, 1); + const removed = applyEvents(request, [{ sequence: 1, type: 'item_missing', item_type: 'slab', quantity: 1 }]); + assert.deepEqual(removed.items.map((item) => item.id), ['cube']); + assert.deepEqual(request, cubeRequest()); +}); + +test('a substituted container is replaced where it stood', () => { + const replacement = { id: 'box-b', inner_dimensions: { length: '210', width: '110', height: '210' } }; + const derived = applyEvents(cubeRequest(), [{ sequence: 1, type: 'container_substituted', container_type: 'box', replacement }]); + assert.deepEqual(derived.containers.map((container) => container.id), ['box-b', 'crate']); +}); + +test('a lock then a verification of the same box fixes it once', () => { + const derived = applyEvents(cubeRequest(), [locked(), locked({ kind: 'placement_verified', sequence: 2 })]); + assert.deepEqual(derived.fixed_placements, [locked().placement]); +}); + +test('a fixed set with no canonical form still deduplicates by first match', () => { + const unholdable = locked({ x: '5' }).placement; + unholdable.position.x = 2 ** 60; + const request = { ...cubeRequest(), fixed_placements: [locked().placement, unholdable] }; + const derived = applyEvents(request, [locked()]); + assert.deepEqual(derived.fixed_placements, [locked().placement, unholdable]); +}); + +test('a lock with no canonical form is recorded for admission to judge', () => { + const event = locked(); + event.placement.position.x = 2 ** 60; + const derived = applyEvents(cubeRequest(), [event]); + assert.deepEqual(derived.fixed_placements, [event.placement]); +}); + +test('a lock without a position is a lock at the origin', () => { + const event = locked(); + delete event.placement.position; + const derived = applyEvents(cubeRequest(), [event]); + assert.deepEqual(derived.fixed_placements, [event.placement]); +}); + +test('an explicit first instance and the default one are the same box', () => { + const explicit = locked({ sequence: 2 }); + explicit.placement.container_instance = 1; + const derived = applyEvents(cubeRequest(), [locked(), explicit]); + assert.deepEqual(derived.fixed_placements, [locked().placement]); +}); + +test('a derived request solves with its fixed items in place', () => { + const [revisions] = chain(); + const result = packFallback(revisions.at(-1).request); + const fixed = result.containers.flatMap((container) => container.placements + .filter((placement) => placement.fixed).map((placement) => [container.id, placement.item_id])); + assert.deepEqual(fixed, [['box#1', 'cube#1']]); + assert.equal(result.containers.reduce((total, container) => total + container.placements.length, 0), 4); +}); + +for (const [name, event, message] of [ + ['an unknown item', { sequence: 1, type: 'item_missing', item_type: 'pallet', quantity: 1 }, 'the request has no item "pallet"'], + ['a replacement id that exists', { sequence: 1, type: 'container_substituted', container_type: 'box', + replacement: { id: 'crate', inner_dimensions: { length: 1, width: 1, height: 1 } } }, 'event 1: another container is already crate'], + ['a substitution of an unknown container', { sequence: 1, type: 'container_substituted', container_type: 'pallet', + replacement: { id: 'p2' } }, 'the request has no container "pallet"'], +]) { + test(`an event that contradicts the request is refused: ${name}`, () => { + refused(() => applyEvents(cubeRequest(), [event]), 'event_conflict', message); + }); +} + +test('fixed items cannot go missing or lose their container', () => { + const fixed = applyEvents(cubeRequest(), [locked()]); + refused(() => applyEvents(fixed, [{ sequence: 2, type: 'item_missing', item_type: 'cube', quantity: 4 }]), + 'event_conflict', 'event 2: 1 cube are fixed, 0 would remain'); + refused(() => applyEvents(fixed, [{ sequence: 2, type: 'container_substituted', container_type: 'box', + replacement: { id: 'box-b', inner_dimensions: { length: 1, width: 1, height: 1 } } }]), + 'event_conflict', 'event 2: fixed placements are in box'); +}); + +test('the last item type cannot go missing', () => { + const request = cubeRequest(); + request.items = request.items.slice(0, 1); + refused(() => applyEvents(request, [{ sequence: 1, type: 'item_missing', item_type: 'cube', quantity: 4 }]), + 'event_conflict', 'event 1: no item would remain to pack'); +}); + +const MALFORMED_EVENTS = [ + [[], 'a revision records at least one event'], + [[locked({ sequence: 2 })], 'event sequence 2 does not continue the chain at 1'], + [[{ ...locked(), type: 'placement_moved' }], 'unknown event type "placement_moved"'], + [[{ ...locked(), note: 'strapped' }], 'placement_locked does not carry ["note"]'], + [[{ sequence: 1, type: 'item_missing', item_type: 'cube' }], 'item_missing needs ["quantity"]'], + [[{ sequence: 1, type: 'item_missing', item_type: 'cube', quantity: 0 }], 'item_missing.quantity is a positive integer'], + [[{ sequence: true, type: 'item_missing', item_type: 'cube', quantity: 1 }], 'event sequence true does not continue the chain at 1'], + [[{ sequence: 1, type: 'placement_locked', placement: { ...locked().placement, orientation: 'XYZ' } }], + 'placement.orientation is one of the six codes'], + [[{ sequence: 1, type: 'placement_locked', placement: { ...locked().placement, container_instance: 0 } }], + 'placement.container_instance counts from 1'], + [['item_missing'], 'an event is a JSON object'], + [[{ sequence: 1, type: 'container_substituted', container_type: '', replacement: { id: 'b' } }], 'container_type is a non-empty string'], + [[{ sequence: 1, type: 'container_substituted', container_type: 'box', replacement: 'b' }], 'a replacement is a container object'], + [[{ sequence: 1, type: 'placement_locked', placement: 'box#1' }], 'a placement is a fixed-placement object'], + [[{ sequence: 1, type: 'placement_locked', placement: { ...locked().placement, item_id: 'cube#1' } }], + 'a placement does not carry ["item_id"]'], + [[{ sequence: 1, type: 'placement_locked', placement: { ...locked().placement, position: [0, 0, 0] } }], + 'placement.position is a point object'], +]; + +MALFORMED_EVENTS.forEach(([events, message], index) => { + test(`a malformed event is refused (${index}): ${message}`, () => { + const root = rootRevision(cubeRequest()); + refused(() => deriveRevision(root, artifactFor(root.request), events), 'invalid_event', message); + }); +}); + +test('an artifact built from another request is refused', () => { + const root = rootRevision(cubeRequest()); + const other = cubeRequest(); + other.items[0].quantity = 3; + refused(() => deriveRevision(root, artifactFor(other), [locked()]), 'invalid_artifact', + "the artifact was built from a different request than the parent's"); +}); + +test('a parent that is not a revision is refused', () => { + refused(() => deriveRevision(cubeRequest(), artifactFor(cubeRequest()), [locked()]), 'invalid_revision', + 'not a packvium-plan-revision/v1 document'); +}); + +for (const [parent, message] of [ + [{ format: FORMAT, revision: -1, request: {}, events: [] }, 'revision is a non-negative integer'], + [{ format: FORMAT, revision: 0, request: [], events: [] }, 'a revision carries its request'], + [{ format: FORMAT, revision: 0, request: {}, events: {} }, 'a revision carries its events'], + [{ format: FORMAT, revision: 2, request: cubeRequest(), events: [] }, 'only the root revision records no events'], +]) { + test(`a malformed parent is refused: ${message}`, () => { + refused(() => deriveRevision(parent, artifactFor(cubeRequest()), [locked()]), 'invalid_revision', message); + }); +} + +for (const [artifact, message] of [ + [{ format: 'packvium-execution-plan/v1' }, 'not a packvium-operational-artifact/v1 document'], + [{ format: 'packvium-operational-artifact/v1', provenance: { request: {} } }, 'the artifact carries no provenance.replay'], +]) { + test(`a malformed artifact is refused: ${message}`, () => { + refused(() => deriveRevision(rootRevision(cubeRequest()), artifact, [locked()]), 'invalid_artifact', message); + }); +} + +test('a root needs a request object with a canonical spelling', () => { + refused(() => rootRevision([]), 'invalid_revision', 'a request is a JSON object'); + refused(() => rootRevision({ items: [], containers: [], configuration: { seed: 2 ** 60 } }), 'number_out_of_range'); +}); + +test('a plan that stopped on time is marked in the chain', () => { + const root = rootRevision(cubeRequest()); + const artifact = artifactFor(root.request); + artifact.provenance.solver.time_limit_reached = true; + artifact.provenance.replay = { level: 'not_guaranteed', because: 'provenance.solver.time_limit_reached' }; + assert.equal(deriveRevision(root, artifact, [locked()]).approved.replay.level, 'not_guaranteed'); +}); + +test('an intact chain verifies clean', () => { + const [revisions, artifacts] = chain(); + assert.deepEqual(verifyRevisionChain(revisions, artifacts), []); +}); + +test('reordered events are caught', () => { + const [revisions] = chain(); + const { events } = revisions[1]; + [events[0], events[1]] = [events[1], events[0]]; + assert.ok(codes(revisions).has('sequence_gap')); +}); + +test('a missing event is caught', () => { + const [revisions] = chain(); + revisions[1].events.splice(1, 1); + const found = codes(revisions); + assert.ok(found.has('sequence_gap') && found.has('request_mismatch')); +}); + +test('an altered event is caught', () => { + const [revisions] = chain(); + revisions[1].events[1].quantity = 2; + assert.deepEqual(verifyRevisionChain(revisions)[0], { code: 'request_mismatch', revision: 1, + detail: "the request is not what the parent's request and these events derive" }); +}); + +test('an altered request breaks the link to the next revision', () => { + const [revisions] = chain(); + revisions[1].request.items[0].quantity = 9; + const found = codes(revisions); + assert.ok(found.has('request_mismatch') && found.has('parent_mismatch')); +}); + +test('a dropped revision is caught', () => { + const [revisions] = chain(); + revisions.splice(1, 1); + const issues = verifyRevisionChain(revisions); + assert.deepEqual(issues.map((entry) => entry.code).slice(0, 2), ['revision_number', 'parent_mismatch']); + assert.equal(issues[0].detail, 'revision 2 at position 1'); + assert.ok(codes(revisions).has('sequence_gap')); +}); + +test('a substituted artifact is caught', () => { + const [revisions, artifacts] = chain(); + const swapped = copy(artifacts); + swapped[2] = artifacts[1]; + assert.ok(codes(revisions, swapped).has('artifact_mismatch')); +}); + +test('a chain that is not made of revisions stops the audit', () => { + const [revisions] = chain(); + revisions[1] = { format: 'something-else' }; + assert.deepEqual(verifyRevisionChain(revisions), + [{ code: 'invalid_revision', revision: 1, detail: 'not a packvium-plan-revision/v1 document' }]); +}); + +test('an item without a quantity counts as one', () => { + const request = cubeRequest(); + delete request.items[1].quantity; + const derived = applyEvents(request, [{ sequence: 1, type: 'item_missing', item_type: 'slab', quantity: 1 }]); + assert.deepEqual(derived.items.map((item) => item.id), ['cube']); +}); + +test('a request whose entries are not objects is refused', () => { + const request = cubeRequest(); + request.items = ['cube']; + refused(() => applyEvents(request, [{ sequence: 1, type: 'item_missing', item_type: 'cube', quantity: 1 }]), + 'invalid_revision', 'request.items is a list of objects'); +}); + +test('a root with events and a revision without them are caught', () => { + const [revisions] = chain(); + revisions[0].events = [locked()]; + revisions[2].events = []; + const gaps = verifyRevisionChain(revisions).filter((entry) => entry.code === 'sequence_gap') + .map((entry) => [entry.code, entry.revision, entry.detail]); + assert.deepEqual(gaps.slice(0, 2), [['sequence_gap', 0, 'the root revision records no events'], + ['sequence_gap', 1, 'event 1 where 2 was next']]); + assert.ok(gaps.some(([, revision, detail]) => revision === 2 && detail === 'a revision records at least one event')); +}); + +test('events that no longer apply are a request mismatch', () => { + const [revisions] = chain(); + revisions[1].events[1].item_type = 'pallet'; + assert.deepEqual(verifyRevisionChain(revisions).find((entry) => entry.code === 'request_mismatch'), + { code: 'request_mismatch', revision: 1, detail: 'the events do not apply: the request has no item "pallet"' }); +}); + +test('an artifact for another request or with another replay is caught', () => { + const [revisions, artifacts] = chain(); + const tampered = copy(artifacts); + tampered[1].provenance.replay = { level: 'not_guaranteed', because: 'provenance.solver' }; + revisions[1].approved.artifact = documentDigest(tampered[1]); + assert.deepEqual(verifyRevisionChain(revisions.slice(0, 2), tampered.slice(0, 2)), + [{ code: 'artifact_mismatch', revision: 1, detail: "approved.replay is not the artifact's provenance.replay" }]); + tampered[1].provenance.request = { items: [] }; + revisions[1].approved.artifact = documentDigest(tampered[1]); + assert.deepEqual(verifyRevisionChain(revisions.slice(0, 2), tampered.slice(0, 2)), + [{ code: 'artifact_mismatch', revision: 1, detail: "the artifact was built from a different request than the parent's" }]); +}); + +// ------------------------------------------------------------- malformed input, never a crash + +test('a request key named __proto__ stays an ordinary key', () => { + const request = JSON.parse('{"metadata":{"__proto__":{"x":1}},"items":[],"containers":[]}'); + const root = rootRevision(request); + assert.equal(Object.getPrototypeOf(root.request.metadata), Object.prototype); + assert.match(canonicalRevisionJson(root), /"metadata":\{"__proto__":\{"x":1\}\}/); +}); + +for (const [name, events, message] of [ + ['a type that is not a name', [{ ...locked(), type: ['placement_locked'] }], 'unknown event type ["placement_locked"]'], + ['a type with no spelling', [{ ...locked(), type: '\uD800' }], 'unknown event type an unspellable value'], + ['a missing type', [{ sequence: 1 }], 'unknown event type null'], + ['a sequence beyond 2^53 - 1', [locked({ sequence: 2 ** 53 })], 'event sequence an out-of-range number does not continue the chain at 1'], + ['a text sequence', [locked({ sequence: '1' })], 'event sequence "1" does not continue the chain at 1'], + ['an extra axis', [{ ...locked(), placement: { ...locked().placement, position: { w: '5' } } }], + 'placement.position does not carry ["w"]'], + ['a boolean axis', [{ ...locked(), placement: { ...locked().placement, position: { x: true } } }], + 'placement.position.x is a measure'], + ['a null instance', [{ ...locked(), placement: { ...locked().placement, container_instance: null } }], + 'placement.container_instance counts from 1'], + ['unknown keys in code-point order', [{ ...locked(), '\u{1F600}': 1, '。': 2 }], + 'placement_locked does not carry ["。","\u{1F600}"]'], +]) { + test(`a malformed event is refused: ${name}`, () => { + const root = rootRevision(cubeRequest()); + refused(() => deriveRevision(root, artifactFor(root.request), events), 'invalid_event', message); + }); +} + +test('a parent whose events do not each carry an integer sequence is refused', () => { + const [[, first]] = chain(); + for (const edit of [(e) => { e[0].sequence = '1'; }, (e) => { e[0].sequence = true; }, + (e) => { delete e[0].sequence; }, (e) => { e[0] = 5; }]) { + const broken = copy(first); + edit(broken.events); + refused(() => deriveRevision(broken, artifactFor(first.request), [locked({ sequence: 3 })]), 'invalid_revision', + "a revision's events each carry an integer sequence"); + } +}); + +test('a request item quantity must be an integer when an item goes missing', () => { + const missing = { sequence: 1, type: 'item_missing', item_type: 'slab', quantity: 1 }; + for (const quantity of ['3', 2.5, null, true]) { + const request = cubeRequest(); + request.items[1].quantity = quantity; + refused(() => applyEvents(request, [missing]), 'invalid_revision', 'request.items[1].quantity is an integer'); + } +}); + +test('a null fixed_placements is none, and anything else not a list of objects is refused', () => { + const lock = locked(); + const missing = { sequence: 1, type: 'item_missing', item_type: 'cube', quantity: 1 }; + const request = { ...cubeRequest(), fixed_placements: null }; + assert.deepEqual(applyEvents(request, [lock]).fixed_placements, [lock.placement]); + assert.equal(applyEvents(request, [missing]).fixed_placements, null); + for (const value of [{ k: 1 }, [5], 'x']) { + for (const event of [lock, missing]) { + refused(() => applyEvents({ ...cubeRequest(), fixed_placements: value }, [event]), 'invalid_revision', + 'request.fixed_placements is a list of objects'); + } + } +}); + +test('apply refuses a request or event list that is not JSON of the right kind', () => { + refused(() => applyEvents([], [locked()]), 'invalid_revision', 'a request is a JSON object'); + refused(() => applyEvents(cubeRequest(), locked()), 'invalid_event', 'events are a JSON array'); +}); + +test('apply refuses an event whose sequence is not an integer', () => { + const missing = { type: 'item_missing', item_type: 'cube', quantity: 1 }; + for (const [sequence, spelled] of [[undefined, 'null'], ['1', '"1"'], [true, 'true'], [1.5, '1.5']]) { + const event = sequence === undefined ? missing : { ...missing, sequence }; + refused(() => applyEvents(cubeRequest(), [event]), 'invalid_event', `event sequence ${spelled} is not an integer`); + } +}); + +test('an audit refuses a chain or an artifact list that is not an array', () => { + const [revisions, artifacts] = chain(); + refused(() => verifyRevisionChain({ 0: revisions[0] }), 'invalid_revision', 'a chain is a JSON array'); + refused(() => verifyRevisionChain(revisions, { 1: artifacts[1] }), 'invalid_artifact', 'artifacts is a JSON array'); +}); + +test('an artifact that is not an object, or has no canonical form, is a mismatch', () => { + const [revisions, artifacts] = chain(); + const unspellable = copy(artifacts[1]); + unspellable.provenance.solver = { big: 2 ** 60 }; + for (const artifact of ['x', 5, unspellable]) { + assert.deepEqual(verifyRevisionChain(revisions, [null, artifact, artifacts[2]]), + [{ code: 'artifact_mismatch', revision: 1, detail: "the artifact's digest is not the one this revision approved" }]); + } +}); + +test('an audited event without an integer sequence stops the audit there', () => { + const [revisions] = chain(); + revisions[1].events[0].sequence = true; + assert.deepEqual(verifyRevisionChain(revisions), + [{ code: 'invalid_revision', revision: 1, detail: "a revision's events each carry an integer sequence" }]); +}); + +test('a parent that is not a digest is quoted as JSON', () => { + const [revisions] = chain(); + revisions[0].parent = 5; + assert.deepEqual(verifyRevisionChain(revisions.slice(0, 1)), + [{ code: 'parent_mismatch', revision: 0, detail: 'parent 5, expected null' }]); +}); diff --git a/test/validate.mjs b/test/validate.mjs index c464e29..2ba1663 100644 --- a/test/validate.mjs +++ b/test/validate.mjs @@ -225,6 +225,33 @@ function occupiedVolumeOf(box, item, lengthUnit, loadTicks) { return footprint * (height > 1n ? height : 1n); } +/** + * A fixed placement is an ordinary placement, so every physical rule above already covers it; + * what is new is whether it moved (docs/PLAN-REVISIONS.md). Keyed by container id `T#n`, item + * type, position ticks and orientation. + */ +function fixedPlacementIssues(request, result, lengthUnit) { + const key = (containerId, type, at, orientation) => JSON.stringify([containerId, type, ...at, orientation]); + const requested = new Set((request.fixed_placements ?? []).map((entry) => key( + `${entry.container_type}#${entry.container_instance ?? 1}`, entry.item_type, + ['x', 'y', 'z'].map((axis) => requestLengthTicks(entry.position?.[axis] ?? 0, lengthUnit)), entry.orientation))); + const reported = new Set(); + for (const container of result.containers) { + for (const placement of container.placements) { + if (placement.fixed === true) { + reported.add(key(container.id, placement.item_type, corner(placement.position), placement.orientation)); + } + } + } + const present = new Set(result.containers.map((container) => container.id)); + const issues = []; + for (const entry of requested) { + if (!reported.has(entry)) issues.push(present.has(JSON.parse(entry)[0]) ? 'fixed_placement_moved' : 'fixed_container_missing'); + } + for (const entry of reported) if (!requested.has(entry)) issues.push('unexpected_fixed_placement'); + return issues; +} + export function validate(request, result) { const issues = []; const itemsById = new Map(request.items.map((item) => [item.id, item])); @@ -315,6 +342,8 @@ export function validate(request, result) { if (homes.size > 1) issues.push('group_split'); } + issues.push(...fixedPlacementIssues(request, result, lengthUnit)); + const expected = new Set(); for (const item of request.items) { for (let sequence = 1; sequence <= (item.quantity ?? 1); sequence++) expected.add(`${item.id}#${sequence}`);