@cavelang/solver 0.34.0 → 0.36.0

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