@cavelang/solver 0.35.0 → 0.36.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.
Files changed (119) hide show
  1. package/README.md +2155 -3
  2. package/dist/src/adapter.d.ts +6 -0
  3. package/dist/src/adapter.d.ts.map +1 -1
  4. package/dist/src/adapter.js +3 -0
  5. package/dist/src/adapter.js.map +1 -1
  6. package/dist/src/balanced-reduce.d.ts +3 -0
  7. package/dist/src/balanced-reduce.d.ts.map +1 -0
  8. package/dist/src/balanced-reduce.js +10 -0
  9. package/dist/src/balanced-reduce.js.map +1 -0
  10. package/dist/src/canonical-owned.d.ts +5 -0
  11. package/dist/src/canonical-owned.d.ts.map +1 -0
  12. package/dist/src/canonical-owned.js +206 -0
  13. package/dist/src/canonical-owned.js.map +1 -0
  14. package/dist/src/canonical.d.ts +3 -2
  15. package/dist/src/canonical.d.ts.map +1 -1
  16. package/dist/src/canonical.js +12 -85
  17. package/dist/src/canonical.js.map +1 -1
  18. package/dist/src/capability.d.ts.map +1 -1
  19. package/dist/src/capability.js +43 -33
  20. package/dist/src/capability.js.map +1 -1
  21. package/dist/src/clone.d.ts +3 -0
  22. package/dist/src/clone.d.ts.map +1 -0
  23. package/dist/src/clone.js +54 -0
  24. package/dist/src/clone.js.map +1 -0
  25. package/dist/src/constant-sign.d.ts +5 -0
  26. package/dist/src/constant-sign.d.ts.map +1 -0
  27. package/dist/src/constant-sign.js +54 -0
  28. package/dist/src/constant-sign.js.map +1 -0
  29. package/dist/src/context.d.ts +6 -0
  30. package/dist/src/context.d.ts.map +1 -0
  31. package/dist/src/context.js +151 -0
  32. package/dist/src/context.js.map +1 -0
  33. package/dist/src/diagnostic-text.d.ts +3 -0
  34. package/dist/src/diagnostic-text.d.ts.map +1 -0
  35. package/dist/src/diagnostic-text.js +5 -0
  36. package/dist/src/diagnostic-text.js.map +1 -0
  37. package/dist/src/evaluate-expression.d.ts +4 -0
  38. package/dist/src/evaluate-expression.d.ts.map +1 -0
  39. package/dist/src/evaluate-expression.js +24 -0
  40. package/dist/src/evaluate-expression.js.map +1 -0
  41. package/dist/src/exact.d.ts +2 -0
  42. package/dist/src/exact.d.ts.map +1 -1
  43. package/dist/src/exact.js +125 -28
  44. package/dist/src/exact.js.map +1 -1
  45. package/dist/src/explain.d.ts +4 -0
  46. package/dist/src/explain.d.ts.map +1 -1
  47. package/dist/src/explain.js +330 -91
  48. package/dist/src/explain.js.map +1 -1
  49. package/dist/src/explanation-budget.d.ts +14 -0
  50. package/dist/src/explanation-budget.d.ts.map +1 -0
  51. package/dist/src/explanation-budget.js +46 -0
  52. package/dist/src/explanation-budget.js.map +1 -0
  53. package/dist/src/expression-key.d.ts +8 -0
  54. package/dist/src/expression-key.d.ts.map +1 -0
  55. package/dist/src/expression-key.js +54 -0
  56. package/dist/src/expression-key.js.map +1 -0
  57. package/dist/src/fraction-product.d.ts +3 -0
  58. package/dist/src/fraction-product.d.ts.map +1 -0
  59. package/dist/src/fraction-product.js +13 -0
  60. package/dist/src/fraction-product.js.map +1 -0
  61. package/dist/src/fraction-sum.d.ts +3 -0
  62. package/dist/src/fraction-sum.d.ts.map +1 -0
  63. package/dist/src/fraction-sum.js +23 -0
  64. package/dist/src/fraction-sum.js.map +1 -0
  65. package/dist/src/integer-gcd.d.ts +3 -0
  66. package/dist/src/integer-gcd.d.ts.map +1 -0
  67. package/dist/src/integer-gcd.js +50 -0
  68. package/dist/src/integer-gcd.js.map +1 -0
  69. package/dist/src/linear.d.ts +2 -1
  70. package/dist/src/linear.d.ts.map +1 -1
  71. package/dist/src/linear.js +85 -17
  72. package/dist/src/linear.js.map +1 -1
  73. package/dist/src/numeric-size.d.ts +2 -0
  74. package/dist/src/numeric-size.d.ts.map +1 -0
  75. package/dist/src/numeric-size.js +33 -0
  76. package/dist/src/numeric-size.js.map +1 -0
  77. package/dist/src/options.d.ts +4 -0
  78. package/dist/src/options.d.ts.map +1 -0
  79. package/dist/src/options.js +18 -0
  80. package/dist/src/options.js.map +1 -0
  81. package/dist/src/result.d.ts +4 -0
  82. package/dist/src/result.d.ts.map +1 -0
  83. package/dist/src/result.js +29 -0
  84. package/dist/src/result.js.map +1 -0
  85. package/dist/src/solve.d.ts.map +1 -1
  86. package/dist/src/solve.js +34 -3
  87. package/dist/src/solve.js.map +1 -1
  88. package/dist/src/validate.d.ts +5 -0
  89. package/dist/src/validate.d.ts.map +1 -1
  90. package/dist/src/validate.js +323 -65
  91. package/dist/src/validate.js.map +1 -1
  92. package/dist/src/workflow.d.ts.map +1 -1
  93. package/dist/src/workflow.js +98 -26
  94. package/dist/src/workflow.js.map +1 -1
  95. package/package.json +2 -2
  96. package/src/adapter.ts +9 -0
  97. package/src/balanced-reduce.ts +9 -0
  98. package/src/canonical-owned.ts +186 -0
  99. package/src/canonical.ts +14 -90
  100. package/src/capability.ts +41 -30
  101. package/src/clone.ts +47 -0
  102. package/src/constant-sign.ts +59 -0
  103. package/src/context.ts +114 -0
  104. package/src/diagnostic-text.ts +4 -0
  105. package/src/evaluate-expression.ts +21 -0
  106. package/src/exact.ts +122 -27
  107. package/src/explain.ts +342 -94
  108. package/src/explanation-budget.ts +47 -0
  109. package/src/expression-key.ts +45 -0
  110. package/src/fraction-product.ts +10 -0
  111. package/src/fraction-sum.ts +20 -0
  112. package/src/integer-gcd.ts +47 -0
  113. package/src/linear.ts +82 -16
  114. package/src/numeric-size.ts +26 -0
  115. package/src/options.ts +18 -0
  116. package/src/result.ts +28 -0
  117. package/src/solve.ts +34 -3
  118. package/src/validate.ts +312 -66
  119. package/src/workflow.ts +92 -26
@@ -1,6 +1,57 @@
1
+ import { diagnosticText } from "./diagnostic-text.js";
1
2
  import { defaultLimits } from "./adapter.js";
2
3
  import * as Exact from "./exact.js";
4
+ import { numericDigits } from "./numeric-size.js";
3
5
  import { schema } from "./model.js";
6
+ export { validateContext as explanationContext } from "./context.js";
7
+ /** Validate a portable unknown-outcome reason without changing its contents. */
8
+ export const unknownReason = (value) => {
9
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
10
+ throw new TypeError('unknown solver reason must be an object');
11
+ }
12
+ const { kind, message, limit } = value;
13
+ if (typeof kind !== 'string' || !['timeout', 'resource-limit', 'cancelled', 'backend-error', 'indeterminate'].includes(kind)) {
14
+ throw new TypeError('unknown solver reason kind must be timeout, resource-limit, cancelled, backend-error or indeterminate');
15
+ }
16
+ if (typeof message !== 'string')
17
+ throw new TypeError('unknown solver reason message must be a string');
18
+ if (limit !== undefined && (typeof limit !== 'string' || !Object.hasOwn(defaultLimits, limit))) {
19
+ throw new TypeError('unknown solver reason limit must name a supported solver limit');
20
+ }
21
+ };
22
+ /** Validate metadata shared by adapter results and explanation runs. */
23
+ export const resultMetadata = (value) => {
24
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
25
+ throw new TypeError('solver result metadata must be an object');
26
+ }
27
+ const { backend, elapsedMs, diagnostics } = value;
28
+ if (backend === null || typeof backend !== 'object' || Array.isArray(backend)) {
29
+ throw new TypeError('solver backend must be an object');
30
+ }
31
+ const { name, version } = backend;
32
+ if (typeof name !== 'string' || name === '')
33
+ throw new TypeError('solver backend name must be a non-empty string');
34
+ if (typeof version !== 'string' || version === '')
35
+ throw new TypeError('solver backend version must be a non-empty string');
36
+ if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs) || elapsedMs < 0) {
37
+ throw new TypeError('solver elapsedMs must be a finite non-negative number');
38
+ }
39
+ if (!Array.isArray(diagnostics))
40
+ throw new TypeError('solver diagnostics must be an array');
41
+ for (let index = 0; index < diagnostics.length; index++) {
42
+ const entry = diagnostics[index];
43
+ if (!Object.hasOwn(diagnostics, index) || entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
44
+ throw new TypeError(`solver diagnostics[${index}] must be an object`);
45
+ }
46
+ const { level, code, message } = entry;
47
+ if (level !== 'info' && level !== 'warning' && level !== 'error') {
48
+ throw new TypeError(`solver diagnostics[${index}].level must be info, warning or error`);
49
+ }
50
+ if (typeof code !== 'string' || typeof message !== 'string') {
51
+ throw new TypeError(`solver diagnostics[${index}] code and message must be strings`);
52
+ }
53
+ }
54
+ };
4
55
  export class ModelValidationError extends Error {
5
56
  problems;
6
57
  constructor(problems) {
@@ -25,7 +76,8 @@ const idPattern = /^[A-Za-z][A-Za-z0-9._:/-]*$/;
25
76
  const numeric = (sort) => sort.kind === 'int' || sort.kind === 'real';
26
77
  const same = (left, right) => left.kind === right.kind && (left.kind !== 'enum' || (right.kind === 'enum' && left.domain === right.domain));
27
78
  const compatible = (left, right) => same(left, right) || (numeric(left) && numeric(right));
28
- const rationalOfInteger = (value, side) => String(Exact.integer(value[side]));
79
+ const captureRational = (value) => value !== null && typeof value === 'object'
80
+ ? { numerator: value.numerator, denominator: value.denominator } : value;
29
81
  const pushExactProblem = (problems, path, run) => {
30
82
  try {
31
83
  run();
@@ -34,19 +86,41 @@ const pushExactProblem = (problems, path, run) => {
34
86
  problems.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
35
87
  }
36
88
  };
89
+ /** Describe malformed fields without traversing caller objects or invoking JSON hooks. */
90
+ const diagnosticValue = (value) => {
91
+ if (typeof value === 'string')
92
+ return diagnosticText(value);
93
+ if (value === null || typeof value === 'number' || typeof value === 'boolean') {
94
+ return JSON.stringify(value);
95
+ }
96
+ return value === undefined ? 'undefined' : `<${typeof value}>`;
97
+ };
98
+ const diagnosticSort = (kind) => typeof kind === 'string' ? diagnosticText(kind, false) : diagnosticValue(kind);
37
99
  const requireId = (id, path, problems) => {
38
- if (!idPattern.test(id)) {
100
+ if (typeof id !== 'string' || id.match(idPattern)?.[0] !== id) {
39
101
  problems.push(`${path} must match ${String(idPattern)}`);
40
102
  }
41
103
  };
42
104
  const provenance = (value, path, problems) => {
105
+ const description = value.description;
106
+ if (description !== undefined && typeof description !== 'string') {
107
+ problems.push(`${path}.description must be a string`);
108
+ }
43
109
  if (value.declaration !== undefined) {
44
- if (value.declaration.uri.trim() === '')
45
- problems.push(`${path}.declaration.uri must not be empty`);
46
- for (const position of ['line', 'column']) {
47
- const at = value.declaration[position];
48
- if (at !== undefined && (!Number.isSafeInteger(at) || at <= 0)) {
49
- problems.push(`${path}.declaration.${position} must be a positive safe integer`);
110
+ const declaration = value.declaration;
111
+ if (declaration === null || typeof declaration !== 'object' || Array.isArray(declaration)) {
112
+ problems.push(`${path}.declaration must be an object`);
113
+ }
114
+ else {
115
+ if (typeof declaration.uri !== 'string')
116
+ problems.push(`${path}.declaration.uri must be a string`);
117
+ else if (declaration.uri.trim() === '')
118
+ problems.push(`${path}.declaration.uri must not be empty`);
119
+ for (const position of ['line', 'column']) {
120
+ const at = declaration[position];
121
+ if (at !== undefined && (!Number.isSafeInteger(at) || at <= 0)) {
122
+ problems.push(`${path}.declaration.${position} must be a positive safe integer`);
123
+ }
50
124
  }
51
125
  }
52
126
  }
@@ -56,58 +130,159 @@ const provenance = (value, path, problems) => {
56
130
  ]) {
57
131
  if (ids === undefined)
58
132
  continue;
59
- if (new Set(ids).size !== ids.length)
133
+ if (!Array.isArray(ids)) {
134
+ problems.push(`${path}.${name} must be an array`);
135
+ continue;
136
+ }
137
+ const seen = new Set();
138
+ let duplicate = false;
139
+ for (let index = 0; index < ids.length; index++) {
140
+ const id = Object.hasOwn(ids, index) ? ids[index] : undefined;
141
+ if (typeof id !== 'string')
142
+ problems.push(`${path}.${name}[${index}] must be a string`);
143
+ else {
144
+ if (id.trim() === '')
145
+ problems.push(`${path}.${name}[${index}] must not be empty`);
146
+ if (seen.has(id))
147
+ duplicate = true;
148
+ seen.add(id);
149
+ }
150
+ }
151
+ if (duplicate)
60
152
  problems.push(`${path}.${name} contains duplicate identifiers`);
61
- ids.forEach((id, index) => {
62
- if (id.trim() === '')
63
- problems.push(`${path}.${name}[${index}] must not be empty`);
64
- });
65
153
  }
66
154
  };
155
+ /** Traverse caller arrays without dispatching through overridden array methods. */
156
+ const eachIndexed = (values, visit) => {
157
+ const length = values.length;
158
+ for (let index = 0; index < length; index++)
159
+ visit(values[index], index);
160
+ };
161
+ const mapIndexed = (values, visit) => {
162
+ const result = [];
163
+ eachIndexed(values, (value, index) => result.push(visit(value, index)));
164
+ return result;
165
+ };
67
166
  const duplicates = (ids, path, problems) => {
68
167
  const seen = new Set();
69
168
  for (const id of ids) {
70
169
  if (seen.has(id)) {
71
- problems.push(`${path} contains duplicate identifier ${JSON.stringify(id)}`);
170
+ problems.push(`${path} contains duplicate identifier ${diagnosticValue(id)}`);
72
171
  }
73
172
  seen.add(id);
74
173
  }
75
174
  };
175
+ function* children(expression) {
176
+ switch (expression.kind) {
177
+ case 'not':
178
+ case 'negate':
179
+ yield { value: expression.value, suffix: 'value' };
180
+ return;
181
+ case 'and':
182
+ case 'or':
183
+ case 'add':
184
+ case 'multiply':
185
+ for (let index = 0; index < expression.operands.length; index++) {
186
+ yield { value: Object.hasOwn(expression.operands, index) ? expression.operands[index] : undefined, suffix: `operands[${index}]` };
187
+ }
188
+ return;
189
+ case 'if':
190
+ yield { value: expression.condition, suffix: 'condition' };
191
+ yield { value: expression.then, suffix: 'then' };
192
+ yield { value: expression.else, suffix: 'else' };
193
+ return;
194
+ case 'implies':
195
+ case 'eq':
196
+ case 'neq':
197
+ case 'lt':
198
+ case 'lte':
199
+ case 'gt':
200
+ case 'gte':
201
+ case 'subtract':
202
+ case 'divide':
203
+ yield { value: expression.left, suffix: 'left' };
204
+ yield { value: expression.right, suffix: 'right' };
205
+ }
206
+ }
76
207
  const infer = (expression, path, variables, domains, problems, walk, depth) => {
77
- walk.nodes += 1;
78
- walk.depth = Math.max(walk.depth, depth);
79
- const child = (value, suffix) => infer(value, `${path}.${suffix}`, variables, domains, problems, walk, depth + 1);
208
+ const frames = [];
209
+ const resolved = new WeakMap();
210
+ const enter = (value, at, level) => {
211
+ walk.nodes += 1;
212
+ walk.depth = Math.max(walk.depth, level);
213
+ if (walk.nodes > walk.limits.maxExpressionNodes) {
214
+ throw new ModelLimitError('maxExpressionNodes', walk.nodes, walk.limits.maxExpressionNodes);
215
+ }
216
+ if (level > walk.limits.maxExpressionDepth) {
217
+ throw new ModelLimitError('maxExpressionDepth', level, walk.limits.maxExpressionDepth);
218
+ }
219
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
220
+ throw new ModelValidationError([`${at} must be an expression object`]);
221
+ }
222
+ if ((value.kind === 'and' || value.kind === 'or' || value.kind === 'add' || value.kind === 'multiply') && !Array.isArray(value.operands)) {
223
+ throw new ModelValidationError([`${at}.operands must be an array`]);
224
+ }
225
+ frames.push({ expression: value, path: at, depth: level, children: children(value) });
226
+ };
227
+ enter(expression, path, depth);
228
+ while (frames.length > 0) {
229
+ const frame = frames[frames.length - 1];
230
+ const next = frame.children.next();
231
+ if (!next.done) {
232
+ enter(next.value.value, `${frame.path}.${next.value.suffix}`, frame.depth + 1);
233
+ }
234
+ else {
235
+ resolved.set(frame.expression, inferNode(frame.expression, frame.path, variables, domains, problems, resolved, walk));
236
+ frames.pop();
237
+ }
238
+ }
239
+ return resolved.get(expression);
240
+ };
241
+ const inferNode = (expression, path, variables, domains, problems, resolved, walk) => {
242
+ const child = (value, _suffix) => resolved.get(value);
80
243
  const expectBool = (sort, at) => {
81
244
  if (sort.kind !== 'bool')
82
- problems.push(`${at} must be boolean, received ${sort.kind}`);
245
+ problems.push(`${at} must be boolean, received ${diagnosticSort(sort.kind)}`);
83
246
  };
84
247
  const expectNumeric = (sort, at) => {
85
248
  if (!numeric(sort))
86
- problems.push(`${at} must be numeric, received ${sort.kind}`);
249
+ problems.push(`${at} must be numeric, received ${diagnosticSort(sort.kind)}`);
87
250
  };
88
251
  switch (expression.kind) {
89
252
  case 'literal':
90
253
  switch (expression.sort) {
91
- case 'bool': return { kind: 'bool' };
92
- case 'int':
93
- pushExactProblem(problems, path, () => Exact.integer(expression.value));
254
+ case 'bool':
255
+ if (typeof expression.value !== 'boolean')
256
+ problems.push(`${path}.value must be a Boolean`);
257
+ return { kind: 'bool' };
258
+ case 'int': {
259
+ const value = expression.value;
260
+ walk.charge(value, 'int');
261
+ pushExactProblem(problems, path, () => walk.zeroLiterals.set(expression, Exact.integer(value) === 0n));
94
262
  return { kind: 'int' };
95
- case 'real':
96
- pushExactProblem(problems, path, () => Exact.rational(expression.value));
263
+ }
264
+ case 'real': {
265
+ const value = captureRational(expression.value);
266
+ walk.charge(value, 'real');
267
+ pushExactProblem(problems, path, () => walk.zeroLiterals.set(expression, Exact.rational(value).numerator === '0'));
97
268
  return { kind: 'real' };
269
+ }
98
270
  case 'enum': {
99
271
  const values = domains.get(expression.domain);
100
272
  if (values === undefined)
101
- problems.push(`${path} references unknown enum domain ${JSON.stringify(expression.domain)}`);
102
- else if (!values.includes(expression.value))
103
- problems.push(`${path} value ${JSON.stringify(expression.value)} is outside enum domain ${JSON.stringify(expression.domain)}`);
273
+ problems.push(`${path} references unknown enum domain ${diagnosticValue(expression.domain)}`);
274
+ else if (!values.has(expression.value))
275
+ problems.push(`${path} value ${diagnosticValue(expression.value)} is outside enum domain ${diagnosticValue(expression.domain)}`);
104
276
  return { kind: 'enum', domain: expression.domain };
105
277
  }
278
+ default:
279
+ problems.push(`${path} uses an unsupported literal sort`);
280
+ return { kind: 'bool' };
106
281
  }
107
282
  case 'variable': {
108
283
  const variable = variables.get(expression.id);
109
284
  if (variable === undefined) {
110
- problems.push(`${path} references unknown variable ${JSON.stringify(expression.id)}`);
285
+ problems.push(`${path} references unknown variable ${diagnosticValue(expression.id)}`);
111
286
  return { kind: 'bool' };
112
287
  }
113
288
  return variable.sort === 'enum' ? { kind: 'enum', domain: variable.domain } : { kind: variable.sort };
@@ -120,7 +295,7 @@ const infer = (expression, path, variables, domains, problems, walk, depth) => {
120
295
  case 'or': {
121
296
  if (expression.operands.length < 2)
122
297
  problems.push(`${path}.${expression.kind} requires at least two operands`);
123
- expression.operands.forEach((operand, index) => expectBool(child(operand, `operands[${index}]`), `${path}.operands[${index}]`));
298
+ eachIndexed(expression.operands, (operand, index) => expectBool(child(operand, `operands[${index}]`), `${path}.operands[${index}]`));
124
299
  return { kind: 'bool' };
125
300
  }
126
301
  case 'implies': {
@@ -133,7 +308,7 @@ const infer = (expression, path, variables, domains, problems, walk, depth) => {
133
308
  const left = child(expression.left, 'left');
134
309
  const right = child(expression.right, 'right');
135
310
  if (!compatible(left, right))
136
- problems.push(`${path} compares incompatible ${left.kind} and ${right.kind} expressions`);
311
+ problems.push(`${path} compares incompatible ${diagnosticSort(left.kind)} and ${diagnosticSort(right.kind)} expressions`);
137
312
  return { kind: 'bool' };
138
313
  }
139
314
  case 'lt':
@@ -148,7 +323,7 @@ const infer = (expression, path, variables, domains, problems, walk, depth) => {
148
323
  case 'multiply': {
149
324
  if (expression.operands.length < 2)
150
325
  problems.push(`${path}.${expression.kind} requires at least two operands`);
151
- const sorts = expression.operands.map((operand, index) => child(operand, `operands[${index}]`));
326
+ const sorts = mapIndexed(expression.operands, (operand, index) => child(operand, `operands[${index}]`));
152
327
  sorts.forEach((sort, index) => expectNumeric(sort, `${path}.operands[${index}]`));
153
328
  return { kind: sorts.some(sort => sort.kind === 'real') ? 'real' : 'int' };
154
329
  }
@@ -165,7 +340,7 @@ const infer = (expression, path, variables, domains, problems, walk, depth) => {
165
340
  const divisor = expression.right;
166
341
  if (divisor.kind === 'literal' && (divisor.sort === 'int' || divisor.sort === 'real')) {
167
342
  pushExactProblem(problems, `${path}.right`, () => {
168
- const zero = divisor.sort === 'int' ? Exact.integer(divisor.value) === 0n : Exact.isZero(divisor.value);
343
+ const zero = walk.zeroLiterals.get(divisor);
169
344
  if (zero)
170
345
  throw new TypeError('literal divisor must not be zero');
171
346
  });
@@ -182,21 +357,29 @@ const infer = (expression, path, variables, domains, problems, walk, depth) => {
182
357
  const thenSort = child(expression.then, 'then');
183
358
  const elseSort = child(expression.else, 'else');
184
359
  if (!compatible(thenSort, elseSort))
185
- problems.push(`${path} branches have incompatible ${thenSort.kind} and ${elseSort.kind} sorts`);
360
+ problems.push(`${path} branches have incompatible ${diagnosticSort(thenSort.kind)} and ${diagnosticSort(elseSort.kind)} sorts`);
186
361
  return numeric(thenSort) && numeric(elseSort) && (thenSort.kind === 'real' || elseSort.kind === 'real')
187
362
  ? { kind: 'real' }
188
363
  : thenSort;
189
364
  }
190
365
  default:
191
- problems.push(`${path} uses unsupported expression kind ${JSON.stringify(expression.kind)}`);
366
+ problems.push(`${path} uses unsupported expression kind ${diagnosticValue(expression.kind)}`);
192
367
  return { kind: 'bool' };
193
368
  }
194
369
  };
195
370
  export const mergeLimits = (input = {}) => {
196
- const limits = { ...defaultLimits, ...input };
371
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
372
+ throw new TypeError('solver limits must be an object');
373
+ }
374
+ for (const name of Object.keys(input)) {
375
+ if (!Object.hasOwn(defaultLimits, name))
376
+ throw new TypeError(`unknown solver limit ${diagnosticText(name)}`);
377
+ }
378
+ const limits = Object.fromEntries(Object.entries(defaultLimits).map(([name, fallback]) => [name, name in input ? input[name] : fallback]));
197
379
  for (const [name, value] of Object.entries(limits)) {
198
380
  if (!Number.isSafeInteger(value) || value <= 0) {
199
- throw new TypeError(`solver limit ${name} must be a positive safe integer, received ${String(value)}`);
381
+ const received = typeof value === 'object' || typeof value === 'function' ? typeof value : diagnosticText(String(value), false);
382
+ throw new TypeError(`solver limit ${name} must be a positive safe integer, received ${received}`);
200
383
  }
201
384
  }
202
385
  return limits;
@@ -219,47 +402,117 @@ const enforce = (stats, limits) => {
219
402
  export const model = (input, limitInput = {}) => {
220
403
  const limits = mergeLimits(limitInput);
221
404
  const problems = [];
405
+ let numericTotal = 0;
406
+ const charge = (value, sort) => {
407
+ numericTotal += numericDigits(value, sort);
408
+ if (numericTotal > limits.maxNumericDigits) {
409
+ throw new ModelLimitError('maxNumericDigits', numericTotal, limits.maxNumericDigits);
410
+ }
411
+ };
412
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
413
+ throw new ModelValidationError(['model must be an object']);
414
+ }
415
+ for (const key of ['variables', 'constraints', 'enums', 'softConstraints', 'objectives']) {
416
+ const declarations = input[key];
417
+ if (declarations === undefined && key !== 'variables' && key !== 'constraints')
418
+ continue;
419
+ if (!Array.isArray(declarations)) {
420
+ problems.push(`${key} must be an array`);
421
+ }
422
+ }
423
+ if (problems.length > 0)
424
+ throw new ModelValidationError(problems);
425
+ enforce({
426
+ variables: input.variables.length,
427
+ constraints: input.constraints.length + (input.softConstraints?.length ?? 0),
428
+ objectives: input.objectives?.length ?? 0,
429
+ enumValues: 0, expressionNodes: 0, expressionDepth: 0,
430
+ }, limits);
431
+ for (const key of ['variables', 'constraints', 'enums', 'softConstraints', 'objectives']) {
432
+ const declarations = input[key];
433
+ if (declarations === undefined)
434
+ continue;
435
+ for (let index = 0; index < declarations.length; index++) {
436
+ const declaration = Object.hasOwn(declarations, index) ? declarations[index] : undefined;
437
+ if (declaration === null || typeof declaration !== 'object' || Array.isArray(declaration)) {
438
+ problems.push(`${key}[${index}] must be a declaration object`);
439
+ }
440
+ }
441
+ }
442
+ if (problems.length > 0)
443
+ throw new ModelValidationError(problems);
222
444
  if (input.schema !== schema)
223
445
  problems.push(`schema must be ${JSON.stringify(schema)}`);
224
446
  const domains = new Map();
225
447
  const enumDeclarations = input.enums ?? [];
226
- duplicates(enumDeclarations.map(domain => domain.id), 'enums', problems);
227
- enumDeclarations.forEach((domain, index) => {
448
+ let enumValues = 0;
449
+ duplicates(mapIndexed(enumDeclarations, domain => domain.id), 'enums', problems);
450
+ eachIndexed(enumDeclarations, (domain, index) => {
228
451
  requireId(domain.id, `enums[${index}].id`, problems);
229
452
  provenance(domain, `enums[${index}]`, problems);
230
- if (domain.values.length === 0)
453
+ const values = Array.isArray(domain.values) ? domain.values : [];
454
+ if (!Array.isArray(domain.values))
455
+ problems.push(`enums[${index}].values must be an array`);
456
+ enumValues += values.length;
457
+ if (enumValues > limits.maxEnumValues)
458
+ throw new ModelLimitError('maxEnumValues', enumValues, limits.maxEnumValues);
459
+ if (values.length === 0)
231
460
  problems.push(`enums[${index}] must contain at least one value`);
232
- if (new Set(domain.values).size !== domain.values.length)
461
+ const members = new Set();
462
+ let duplicate = false;
463
+ for (let member = 0; member < values.length; member++) {
464
+ const value = Object.hasOwn(values, member) ? values[member] : undefined;
465
+ if (typeof value !== 'string')
466
+ problems.push(`enums[${index}].values[${member}] must be a string`);
467
+ else {
468
+ if (members.has(value))
469
+ duplicate = true;
470
+ members.add(value);
471
+ }
472
+ }
473
+ if (duplicate)
233
474
  problems.push(`enums[${index}] contains duplicate values`);
234
- domains.set(domain.id, domain.values);
475
+ domains.set(domain.id, members);
235
476
  });
236
477
  const variables = new Map();
237
- duplicates(input.variables.map(variable => variable.id), 'variables', problems);
238
- input.variables.forEach((variable, index) => {
478
+ duplicates(mapIndexed(input.variables, variable => variable.id), 'variables', problems);
479
+ eachIndexed(input.variables, (variable, index) => {
239
480
  requireId(variable.id, `variables[${index}].id`, problems);
481
+ if (!['bool', 'int', 'real', 'enum'].includes(variable.sort))
482
+ problems.push(`variables[${index}] uses an unsupported sort`);
240
483
  provenance(variable, `variables[${index}]`, problems);
241
484
  variables.set(variable.id, variable);
242
485
  if (variable.sort === 'enum' && !domains.has(variable.domain)) {
243
- problems.push(`variables[${index}] references unknown enum domain ${JSON.stringify(variable.domain)}`);
486
+ problems.push(`variables[${index}] references unknown enum domain ${diagnosticValue(variable.domain)}`);
244
487
  }
245
488
  if (variable.sort === 'int') {
246
- pushExactProblem(problems, `variables[${index}].min`, () => Exact.integer(variable.min));
247
- pushExactProblem(problems, `variables[${index}].max`, () => Exact.integer(variable.max));
489
+ const min = variable.min, max = variable.max;
490
+ charge(min, 'int');
491
+ charge(max, 'int');
492
+ pushExactProblem(problems, `variables[${index}].min`, () => Exact.integer(min));
493
+ pushExactProblem(problems, `variables[${index}].max`, () => Exact.integer(max));
248
494
  try {
249
- if (Exact.compare(rationalOfInteger(variable, 'min'), rationalOfInteger(variable, 'max')) > 0) {
495
+ if (Exact.compare(String(Exact.integer(min)), String(Exact.integer(max))) > 0) {
250
496
  problems.push(`variables[${index}] has min greater than max`);
251
497
  }
252
498
  }
253
499
  catch { /* exact-value problems are already reported above */ }
254
500
  }
255
501
  if (variable.sort === 'real') {
256
- if (variable.min !== undefined)
257
- pushExactProblem(problems, `variables[${index}].min`, () => Exact.rational(variable.min));
258
- if (variable.max !== undefined)
259
- pushExactProblem(problems, `variables[${index}].max`, () => Exact.rational(variable.max));
260
- if (variable.min !== undefined && variable.max !== undefined) {
502
+ const rawMin = variable.min, rawMax = variable.max;
503
+ const min = rawMin === undefined ? undefined : captureRational(rawMin);
504
+ const max = rawMax === undefined ? undefined : captureRational(rawMax);
505
+ if (min !== undefined)
506
+ charge(min, 'real');
507
+ if (max !== undefined)
508
+ charge(max, 'real');
509
+ if (min !== undefined)
510
+ pushExactProblem(problems, `variables[${index}].min`, () => Exact.rational(min));
511
+ if (max !== undefined)
512
+ pushExactProblem(problems, `variables[${index}].max`, () => Exact.rational(max));
513
+ if (min !== undefined && max !== undefined) {
261
514
  try {
262
- if (Exact.compare(variable.min, variable.max) > 0)
515
+ if (Exact.compare(min, max) > 0)
263
516
  problems.push(`variables[${index}] has min greater than max`);
264
517
  }
265
518
  catch { /* exact-value problems are already reported above */ }
@@ -267,35 +520,40 @@ export const model = (input, limitInput = {}) => {
267
520
  }
268
521
  });
269
522
  duplicates([
270
- ...input.constraints.map(constraint => constraint.id),
271
- ...(input.softConstraints ?? []).map(constraint => constraint.id)
523
+ ...mapIndexed(input.constraints, constraint => constraint.id),
524
+ ...mapIndexed(input.softConstraints ?? [], constraint => constraint.id)
272
525
  ], 'constraints', problems);
273
- duplicates((input.objectives ?? []).map(objective => objective.id), 'objectives', problems);
274
- const walk = { nodes: 0, depth: 0 };
275
- input.constraints.forEach((constraint, index) => {
526
+ duplicates(mapIndexed(input.objectives ?? [], objective => objective.id), 'objectives', problems);
527
+ const walk = { nodes: 0, depth: 0, limits, charge, zeroLiterals: new WeakMap() };
528
+ eachIndexed(input.constraints, (constraint, index) => {
276
529
  requireId(constraint.id, `constraints[${index}].id`, problems);
277
530
  provenance(constraint, `constraints[${index}]`, problems);
278
531
  const sort = infer(constraint.expression, `constraints[${index}].expression`, variables, domains, problems, walk, 1);
279
532
  if (sort.kind !== 'bool')
280
- problems.push(`constraints[${index}].expression must be boolean, received ${sort.kind}`);
533
+ problems.push(`constraints[${index}].expression must be boolean, received ${diagnosticSort(sort.kind)}`);
281
534
  });
282
- (input.softConstraints ?? []).forEach((constraint, index) => {
535
+ eachIndexed(input.softConstraints ?? [], (constraint, index) => {
283
536
  requireId(constraint.id, `softConstraints[${index}].id`, problems);
284
537
  provenance(constraint, `softConstraints[${index}]`, problems);
285
538
  const sort = infer(constraint.expression, `softConstraints[${index}].expression`, variables, domains, problems, walk, 1);
286
539
  if (sort.kind !== 'bool')
287
- problems.push(`softConstraints[${index}].expression must be boolean, received ${sort.kind}`);
540
+ problems.push(`softConstraints[${index}].expression must be boolean, received ${diagnosticSort(sort.kind)}`);
541
+ const weight = captureRational(constraint.weight);
542
+ charge(weight, 'real');
288
543
  pushExactProblem(problems, `softConstraints[${index}].weight`, () => {
289
- if (Exact.compare(constraint.weight, '0') <= 0)
544
+ if (Exact.compare(weight, '0') <= 0)
290
545
  throw new TypeError('weight must be greater than zero');
291
546
  });
292
547
  });
293
- (input.objectives ?? []).forEach((objective, index) => {
548
+ eachIndexed(input.objectives ?? [], (objective, index) => {
294
549
  requireId(objective.id, `objectives[${index}].id`, problems);
550
+ if (objective.direction !== 'minimize' && objective.direction !== 'maximize') {
551
+ problems.push(`objectives[${index}].direction must be minimize or maximize`);
552
+ }
295
553
  provenance(objective, `objectives[${index}]`, problems);
296
554
  const sort = infer(objective.expression, `objectives[${index}].expression`, variables, domains, problems, walk, 1);
297
555
  if (!numeric(sort))
298
- problems.push(`objectives[${index}].expression must be numeric, received ${sort.kind}`);
556
+ problems.push(`objectives[${index}].expression must be numeric, received ${diagnosticSort(sort.kind)}`);
299
557
  });
300
558
  if (problems.length > 0)
301
559
  throw new ModelValidationError(problems);
@@ -303,7 +561,7 @@ export const model = (input, limitInput = {}) => {
303
561
  variables: input.variables.length,
304
562
  constraints: input.constraints.length + (input.softConstraints?.length ?? 0),
305
563
  objectives: input.objectives?.length ?? 0,
306
- enumValues: enumDeclarations.reduce((sum, domain) => sum + domain.values.length, 0),
564
+ enumValues,
307
565
  expressionNodes: walk.nodes,
308
566
  expressionDepth: walk.depth
309
567
  };