@packvium/engine 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/policy.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Versioned eligibility rules, compiled into the checks the packer already runs.
3
+ *
4
+ * See docs/POLICY-RULES.md for the contract and the reasoning behind its shape. The
5
+ * short version: rules travel in the request as data because an engine is driven over
6
+ * JSON as a subprocess, so a rule registered inside one process has no wire
7
+ * representation and nothing can check that four engines agree about it.
8
+ *
9
+ * Deliberately not a predicate language. `eligible_container_tags`, `incompatible_tags`
10
+ * and `tag_limits` already express the predicates in every engine, so each rule form
11
+ * here compiles to a check the packer already performs. What a rule adds is only what
12
+ * tags cannot carry: identity, effective dating, priority, and the shipment-scoped facts
13
+ * a request had nowhere to put.
14
+ *
15
+ * This module is package-internal: package.json exports only the root entry point.
16
+ */
17
+
18
+ // The shipment-scoped facts a rule may select on. Properties of the shipment rather
19
+ // than of any item or container, which is why the request had nowhere to put them.
20
+ const SHIPMENT_FACTS = ['facility', 'customer', 'carrier', 'service'];
21
+
22
+ // Wire name -> the keys the form requires, in the order they are read. Exactly one may
23
+ // appear on a rule: a rule naming two forms would have no single meaning for a citation.
24
+ const FORMS = {
25
+ separate_tags: ['tag', 'from_tag'],
26
+ require_container_tag: ['item_tag', 'container_tag'],
27
+ limit_tag_per_container: ['tag', 'max'],
28
+ };
29
+
30
+ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
31
+ const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
32
+ // The detail strings are part of the cross-language contract, and every other engine
33
+ // renders a tag the way its own language quotes a short string literal. Single quotes
34
+ // are what those agree on; a tag is a request-supplied string, so it is escaped here
35
+ // rather than interpolated raw.
36
+ const quoted = tag => `'${tag.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
37
+
38
+ /**
39
+ * A rule set this engine cannot honour exactly as written.
40
+ *
41
+ * Structured rather than skipped: a rule silently dropped for being malformed would let
42
+ * a request pack in a way its own policy forbids, which is the failure the whole
43
+ * contract exists to prevent.
44
+ */
45
+ export class PolicyError extends Error {
46
+ constructor(message) {
47
+ super(message);
48
+ this.name = 'PolicyError';
49
+ this.code = 'policy_error';
50
+ }
51
+ }
52
+
53
+ function integer(value, where, minimum) {
54
+ if (!Number.isSafeInteger(value) || value < minimum) {
55
+ throw new PolicyError(`${where} must be an integer >= ${minimum}`);
56
+ }
57
+ return value;
58
+ }
59
+
60
+ /**
61
+ * Declared facts, or the absence of one. A fact nobody declared is not a wildcard: a
62
+ * rule naming it simply never participates, so an unstated facility cannot silently
63
+ * match a rule written for a specific one.
64
+ */
65
+ function shipmentContext(raw, where) {
66
+ const context = {};
67
+ for (const fact of SHIPMENT_FACTS) context[fact] = null;
68
+ if (raw == null) return context;
69
+ if (!isPlainObject(raw)) throw new PolicyError(`${where} must be an object`);
70
+ const unknown = Object.keys(raw).filter(key => !SHIPMENT_FACTS.includes(key)).sort();
71
+ if (unknown.length) throw new PolicyError(`${where} names unknown shipment facts: ${unknown.join(', ')}`);
72
+ for (const [name, value] of Object.entries(raw)) {
73
+ if (typeof value !== 'string' || !value) throw new PolicyError(`${where}.${name} must be a non-empty string`);
74
+ context[name] = value;
75
+ }
76
+ return context;
77
+ }
78
+
79
+ /** Whether every fact this selector names equals the shipment's own. */
80
+ const satisfiedBy = (selector, shipment) =>
81
+ SHIPMENT_FACTS.every(fact => selector[fact] === null || selector[fact] === shipment[fact]);
82
+
83
+ function parseForm(name, raw, where) {
84
+ if (!isPlainObject(raw)) throw new PolicyError(`${where} must be an object`);
85
+ const keys = FORMS[name];
86
+ const unknown = Object.keys(raw).filter(key => !keys.includes(key)).sort();
87
+ if (unknown.length) throw new PolicyError(`${where} has unknown keys: ${unknown.join(', ')}`);
88
+ const missing = keys.filter(key => !hasOwn(raw, key));
89
+ if (missing.length) throw new PolicyError(`${where} is missing ${missing.join(', ')}`);
90
+ const form = {kind: name};
91
+ for (const key of keys) {
92
+ if (key === 'max') { form.max = integer(raw.max, `${where}.max`, 0); continue }
93
+ if (typeof raw[key] !== 'string' || !raw[key]) throw new PolicyError(`${where}.${key} must be a non-empty string`);
94
+ form[key] = raw[key];
95
+ }
96
+ return form;
97
+ }
98
+
99
+ function parseRule(raw, index) {
100
+ const where = `policy.rules[${index}]`;
101
+ if (!isPlainObject(raw)) throw new PolicyError(`${where} must be an object`);
102
+ const named = Object.keys(FORMS).filter(name => hasOwn(raw, name));
103
+ if (named.length !== 1) {
104
+ throw new PolicyError(
105
+ `${where} must name exactly one rule form (${Object.keys(FORMS).sort().join(', ')}), not ${named.length}`);
106
+ }
107
+ if (typeof raw.id !== 'string' || !raw.id) throw new PolicyError(`${where}.id must be a non-empty string`);
108
+ const version = integer(raw.version, `${where}.version`, 1);
109
+ return {
110
+ id: raw.id,
111
+ version,
112
+ citation: `${raw.id}@${version}`,
113
+ effective_at: integer(raw.effective_at, `${where}.effective_at`, 0),
114
+ priority: integer(raw.priority, `${where}.priority`, 0),
115
+ applies_to: shipmentContext(raw.applies_to, `${where}.applies_to`),
116
+ form: parseForm(named[0], raw[named[0]], `${where}.${named[0]}`),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Resolution, fixed by the contract and identical in every engine — or the same request
122
+ * packs differently depending on which one answered it.
123
+ */
124
+ function resolve(rules, asOf, shipment) {
125
+ const participating = rules.filter(
126
+ rule => rule.effective_at <= asOf && satisfiedBy(rule.applies_to, shipment));
127
+ // Append-only per id: among participating versions of one id the highest
128
+ // `effective_at` wins, ties broken by the highest `version`. The same resolution the
129
+ // catalog registry already uses for `as_of` lookups, deliberately, so a reader learns
130
+ // one rule and not two.
131
+ const latest = new Map();
132
+ for (const rule of participating) {
133
+ const current = latest.get(rule.id);
134
+ if (current === undefined || rule.effective_at > current.effective_at
135
+ || (rule.effective_at === current.effective_at && rule.version > current.version)) {
136
+ latest.set(rule.id, rule);
137
+ }
138
+ }
139
+ // Citation order, not evaluation order: the first rule that rejects a candidate is the
140
+ // one cited, so sorting here is what makes the citation deterministic. Ties go to the
141
+ // lexicographically smallest id -- never to insertion order, which would make the
142
+ // citation depend on the order the caller happened to write.
143
+ return [...latest.values()].sort((a, b) => b.priority - a.priority || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
144
+ }
145
+
146
+ /** The rules that participate in one request, already resolved and ordered. */
147
+ export function parsePolicy(raw) {
148
+ if (raw == null) return [];
149
+ if (!isPlainObject(raw)) throw new PolicyError('policy must be an object');
150
+ const unknown = Object.keys(raw).filter(key => !['as_of', 'shipment', 'rules'].includes(key)).sort();
151
+ if (unknown.length) throw new PolicyError(`policy has unknown keys: ${unknown.join(', ')}`);
152
+ const declared = raw.rules ?? [];
153
+ if (!Array.isArray(declared)) throw new PolicyError('policy.rules must be an array');
154
+ if (!declared.length) return [];
155
+ // No default: a guessed instant silently activates or hides a restriction, and reading
156
+ // a clock here would make one request pack differently on different days.
157
+ if (!hasOwn(raw, 'as_of')) throw new PolicyError('policy.as_of is required whenever policy.rules is non-empty');
158
+ const asOf = integer(raw.as_of, 'policy.as_of', 0);
159
+ const shipment = shipmentContext(raw.shipment, 'policy.shipment');
160
+ return resolve(declared.map(parseRule), asOf, shipment);
161
+ }
162
+
163
+ /**
164
+ * Whether every rule permits `itemTags` in a container tagged `containerTags` that
165
+ * already holds `presentTags`, and the citation of the first that does not.
166
+ *
167
+ * `O(m + r)` for `m` placements already in the container and `r` resolved rules: one
168
+ * pass collecting the tags present, then one pass over the rules. The same bound class
169
+ * as the tag-count check it compiles onto, so the published complexity bounds are
170
+ * unchanged. Rules arrive in citation order, so the first rejection is already the one
171
+ * the contract says to cite.
172
+ */
173
+ export function policyRejection(rules, itemTags, containerTags, presentTags) {
174
+ for (const rule of rules) {
175
+ const form = rule.form;
176
+ if (form.kind === 'require_container_tag') {
177
+ if (itemTags.includes(form.item_tag) && !containerTags.includes(form.container_tag)) {
178
+ return `${rule.citation}: requires a container tagged ${quoted(form.container_tag)}`;
179
+ }
180
+ } else if (form.kind === 'separate_tags') {
181
+ if (itemTags.includes(form.tag) && presentTags.get(form.from_tag)) {
182
+ return `${rule.citation}: ${quoted(form.tag)} may not share a container with ${quoted(form.from_tag)}`;
183
+ }
184
+ if (itemTags.includes(form.from_tag) && presentTags.get(form.tag)) {
185
+ return `${rule.citation}: ${quoted(form.from_tag)} may not share a container with ${quoted(form.tag)}`;
186
+ }
187
+ } else if (itemTags.includes(form.tag) && (presentTags.get(form.tag) ?? 0) >= form.max) {
188
+ return `${rule.citation}: at most ${form.max} item(s) tagged ${quoted(form.tag)} per container`;
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * The rule that rules an item out of every offered container, if one does.
196
+ *
197
+ * Only `require_container_tag` can be answered here, and that is not a gap. It is a
198
+ * statement about the request alone -- this item carries the tag, no offered container
199
+ * carries the one it requires -- so it holds however the search goes. Segregation and
200
+ * per-container caps depend on what else was packed, so an item they leave behind was
201
+ * left behind by the search, and reporting that as proven would claim more than the
202
+ * engine knows.
203
+ *
204
+ * `O(r * c)` for `r` rules and `c` container templates, once per unpacked item rather
205
+ * than per candidate.
206
+ */
207
+ export function provesUnplaceable(rules, itemTags, templates) {
208
+ for (const rule of rules) {
209
+ const form = rule.form;
210
+ if (form.kind !== 'require_container_tag' || !itemTags.includes(form.item_tag)) continue;
211
+ if (!templates.some(template => (template.tags ?? []).includes(form.container_tag))) {
212
+ return `${rule.citation}: requires a container tagged ${quoted(form.container_tag)}, `
213
+ + 'which none of the containers offered carries';
214
+ }
215
+ }
216
+ return null;
217
+ }
218
+
219
+ /** Tag occurrence counts across placements, for the two forms that need them. */
220
+ export function tagOccurrences(placements) {
221
+ const counts = new Map();
222
+ for (const placement of placements) {
223
+ for (const tag of placement.item.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
224
+ }
225
+ return counts;
226
+ }