@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.
- package/README.md +2155 -3
- package/dist/src/adapter.d.ts +6 -0
- package/dist/src/adapter.d.ts.map +1 -1
- package/dist/src/adapter.js +3 -0
- package/dist/src/adapter.js.map +1 -1
- package/dist/src/balanced-reduce.d.ts +3 -0
- package/dist/src/balanced-reduce.d.ts.map +1 -0
- package/dist/src/balanced-reduce.js +10 -0
- package/dist/src/balanced-reduce.js.map +1 -0
- package/dist/src/canonical-owned.d.ts +5 -0
- package/dist/src/canonical-owned.d.ts.map +1 -0
- package/dist/src/canonical-owned.js +206 -0
- package/dist/src/canonical-owned.js.map +1 -0
- package/dist/src/canonical.d.ts +3 -2
- package/dist/src/canonical.d.ts.map +1 -1
- package/dist/src/canonical.js +12 -85
- package/dist/src/canonical.js.map +1 -1
- package/dist/src/capability.d.ts.map +1 -1
- package/dist/src/capability.js +43 -33
- package/dist/src/capability.js.map +1 -1
- package/dist/src/clone.d.ts +3 -0
- package/dist/src/clone.d.ts.map +1 -0
- package/dist/src/clone.js +54 -0
- package/dist/src/clone.js.map +1 -0
- package/dist/src/constant-sign.d.ts +5 -0
- package/dist/src/constant-sign.d.ts.map +1 -0
- package/dist/src/constant-sign.js +54 -0
- package/dist/src/constant-sign.js.map +1 -0
- package/dist/src/context.d.ts +6 -0
- package/dist/src/context.d.ts.map +1 -0
- package/dist/src/context.js +151 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/diagnostic-text.d.ts +3 -0
- package/dist/src/diagnostic-text.d.ts.map +1 -0
- package/dist/src/diagnostic-text.js +5 -0
- package/dist/src/diagnostic-text.js.map +1 -0
- package/dist/src/evaluate-expression.d.ts +4 -0
- package/dist/src/evaluate-expression.d.ts.map +1 -0
- package/dist/src/evaluate-expression.js +24 -0
- package/dist/src/evaluate-expression.js.map +1 -0
- package/dist/src/exact.d.ts +2 -0
- package/dist/src/exact.d.ts.map +1 -1
- package/dist/src/exact.js +125 -28
- package/dist/src/exact.js.map +1 -1
- package/dist/src/explain.d.ts +4 -0
- package/dist/src/explain.d.ts.map +1 -1
- package/dist/src/explain.js +330 -91
- package/dist/src/explain.js.map +1 -1
- package/dist/src/explanation-budget.d.ts +14 -0
- package/dist/src/explanation-budget.d.ts.map +1 -0
- package/dist/src/explanation-budget.js +46 -0
- package/dist/src/explanation-budget.js.map +1 -0
- package/dist/src/expression-key.d.ts +8 -0
- package/dist/src/expression-key.d.ts.map +1 -0
- package/dist/src/expression-key.js +54 -0
- package/dist/src/expression-key.js.map +1 -0
- package/dist/src/fraction-product.d.ts +3 -0
- package/dist/src/fraction-product.d.ts.map +1 -0
- package/dist/src/fraction-product.js +13 -0
- package/dist/src/fraction-product.js.map +1 -0
- package/dist/src/fraction-sum.d.ts +3 -0
- package/dist/src/fraction-sum.d.ts.map +1 -0
- package/dist/src/fraction-sum.js +23 -0
- package/dist/src/fraction-sum.js.map +1 -0
- package/dist/src/integer-gcd.d.ts +3 -0
- package/dist/src/integer-gcd.d.ts.map +1 -0
- package/dist/src/integer-gcd.js +50 -0
- package/dist/src/integer-gcd.js.map +1 -0
- package/dist/src/linear.d.ts +2 -1
- package/dist/src/linear.d.ts.map +1 -1
- package/dist/src/linear.js +85 -17
- package/dist/src/linear.js.map +1 -1
- package/dist/src/numeric-size.d.ts +2 -0
- package/dist/src/numeric-size.d.ts.map +1 -0
- package/dist/src/numeric-size.js +33 -0
- package/dist/src/numeric-size.js.map +1 -0
- package/dist/src/options.d.ts +4 -0
- package/dist/src/options.d.ts.map +1 -0
- package/dist/src/options.js +18 -0
- package/dist/src/options.js.map +1 -0
- package/dist/src/result.d.ts +4 -0
- package/dist/src/result.d.ts.map +1 -0
- package/dist/src/result.js +29 -0
- package/dist/src/result.js.map +1 -0
- package/dist/src/solve.d.ts.map +1 -1
- package/dist/src/solve.js +34 -3
- package/dist/src/solve.js.map +1 -1
- package/dist/src/validate.d.ts +5 -0
- package/dist/src/validate.d.ts.map +1 -1
- package/dist/src/validate.js +323 -65
- package/dist/src/validate.js.map +1 -1
- package/dist/src/workflow.d.ts.map +1 -1
- package/dist/src/workflow.js +98 -26
- package/dist/src/workflow.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +9 -0
- package/src/balanced-reduce.ts +9 -0
- package/src/canonical-owned.ts +186 -0
- package/src/canonical.ts +14 -90
- package/src/capability.ts +41 -30
- package/src/clone.ts +47 -0
- package/src/constant-sign.ts +59 -0
- package/src/context.ts +114 -0
- package/src/diagnostic-text.ts +4 -0
- package/src/evaluate-expression.ts +21 -0
- package/src/exact.ts +122 -27
- package/src/explain.ts +342 -94
- package/src/explanation-budget.ts +47 -0
- package/src/expression-key.ts +45 -0
- package/src/fraction-product.ts +10 -0
- package/src/fraction-sum.ts +20 -0
- package/src/integer-gcd.ts +47 -0
- package/src/linear.ts +82 -16
- package/src/numeric-size.ts +26 -0
- package/src/options.ts +18 -0
- package/src/result.ts +28 -0
- package/src/solve.ts +34 -3
- package/src/validate.ts +312 -66
- 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
|
|
49
|
-
|
|
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 (
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 (
|
|
85
|
-
|
|
86
|
-
|
|
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 ${
|
|
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,
|
|
230
|
+
domains: ReadonlyMap<string, ReadonlySet<string>>,
|
|
108
231
|
problems: string[],
|
|
109
232
|
walk: Walk,
|
|
110
233
|
depth: number
|
|
111
234
|
): Sort => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
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':
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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 ${
|
|
136
|
-
else if (!values.
|
|
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 ${
|
|
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
|
|
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
|
|
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 =
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
470
|
+
const domains = new Map<string, ReadonlySet<string>>()
|
|
254
471
|
const enumDeclarations = input.enums ?? []
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
260
|
-
if (
|
|
261
|
-
|
|
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
|
|
266
|
-
input.variables
|
|
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 ${
|
|
504
|
+
problems.push(`variables[${index}] references unknown enum domain ${diagnosticValue(variable.domain)}`)
|
|
272
505
|
}
|
|
273
506
|
if (variable.sort === 'int') {
|
|
274
|
-
|
|
275
|
-
|
|
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(
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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(
|
|
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
|
|
295
|
-
...(input.softConstraints ?? []
|
|
535
|
+
...mapIndexed(input.constraints, constraint => constraint.id),
|
|
536
|
+
...mapIndexed(input.softConstraints ?? [], constraint => constraint.id)
|
|
296
537
|
], 'constraints', problems)
|
|
297
|
-
duplicates((input.objectives ?? []
|
|
538
|
+
duplicates(mapIndexed(input.objectives ?? [], objective => objective.id), 'objectives', problems)
|
|
298
539
|
|
|
299
|
-
const walk: Walk = { nodes: 0, depth: 0 }
|
|
300
|
-
input.constraints
|
|
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
|
-
|
|
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(
|
|
555
|
+
if (Exact.compare(weight, '0') <= 0) throw new TypeError('weight must be greater than zero')
|
|
313
556
|
})
|
|
314
557
|
})
|
|
315
|
-
|
|
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
|
|
573
|
+
enumValues,
|
|
328
574
|
expressionNodes: walk.nodes,
|
|
329
575
|
expressionDepth: walk.depth
|
|
330
576
|
}
|