@cavelang/solver 0.27.14
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/License.md +8 -0
- package/README.md +95 -0
- package/dist/src/adapter.d.ts +77 -0
- package/dist/src/adapter.d.ts.map +1 -0
- package/dist/src/adapter.js +24 -0
- package/dist/src/adapter.js.map +1 -0
- package/dist/src/canonical.d.ts +5 -0
- package/dist/src/canonical.d.ts.map +1 -0
- package/dist/src/canonical.js +88 -0
- package/dist/src/canonical.js.map +1 -0
- package/dist/src/capability.d.ts +10 -0
- package/dist/src/capability.d.ts.map +1 -0
- package/dist/src/capability.js +92 -0
- package/dist/src/capability.js.map +1 -0
- package/dist/src/exact.d.ts +10 -0
- package/dist/src/exact.d.ts.map +1 -0
- package/dist/src/exact.js +75 -0
- package/dist/src/exact.js.map +1 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +15 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/linear.d.ts +8 -0
- package/dist/src/linear.d.ts.map +1 -0
- package/dist/src/linear.js +52 -0
- package/dist/src/linear.js.map +1 -0
- package/dist/src/model.d.ts +130 -0
- package/dist/src/model.d.ts.map +1 -0
- package/dist/src/model.js +3 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/solve.d.ts +5 -0
- package/dist/src/solve.d.ts.map +1 -0
- package/dist/src/solve.js +12 -0
- package/dist/src/solve.js.map +1 -0
- package/dist/src/validate.d.ts +24 -0
- package/dist/src/validate.d.ts.map +1 -0
- package/dist/src/validate.js +283 -0
- package/dist/src/validate.js.map +1 -0
- package/package.json +39 -0
- package/src/adapter.ts +110 -0
- package/src/canonical.ts +94 -0
- package/src/capability.ts +82 -0
- package/src/exact.ts +85 -0
- package/src/index.ts +15 -0
- package/src/linear.ts +58 -0
- package/src/model.ts +80 -0
- package/src/solve.ts +13 -0
- package/src/validate.ts +302 -0
package/src/validate.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { Limits } from './adapter.ts'
|
|
2
|
+
import { defaultLimits } from './adapter.ts'
|
|
3
|
+
import * as Exact from './exact.ts'
|
|
4
|
+
import { schema, type Expression, type Model, type Rational, type Variable } from './model.ts'
|
|
5
|
+
|
|
6
|
+
export type Stats = {
|
|
7
|
+
readonly variables: number
|
|
8
|
+
readonly constraints: number
|
|
9
|
+
readonly objectives: number
|
|
10
|
+
readonly enumValues: number
|
|
11
|
+
readonly expressionNodes: number
|
|
12
|
+
readonly expressionDepth: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ModelValidationError extends Error {
|
|
16
|
+
readonly problems: readonly string[]
|
|
17
|
+
|
|
18
|
+
constructor(problems: readonly string[]) {
|
|
19
|
+
super(`invalid solver model:\n- ${problems.join('\n- ')}`)
|
|
20
|
+
this.name = 'ModelValidationError'
|
|
21
|
+
this.problems = problems
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ModelLimitError extends Error {
|
|
26
|
+
readonly limit: keyof Limits
|
|
27
|
+
readonly actual: number
|
|
28
|
+
readonly maximum: number
|
|
29
|
+
|
|
30
|
+
constructor(limit: keyof Limits, actual: number, maximum: number) {
|
|
31
|
+
super(`solver model exceeds ${limit}: ${actual} > ${maximum}`)
|
|
32
|
+
this.name = 'ModelLimitError'
|
|
33
|
+
this.limit = limit
|
|
34
|
+
this.actual = actual
|
|
35
|
+
this.maximum = maximum
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Sort = { readonly kind: 'bool' | 'int' | 'real' } | { readonly kind: 'enum', readonly domain: string }
|
|
40
|
+
|
|
41
|
+
const idPattern = /^[A-Za-z][A-Za-z0-9._:/-]*$/
|
|
42
|
+
const numeric = (sort: Sort): boolean => sort.kind === 'int' || sort.kind === 'real'
|
|
43
|
+
const same = (left: Sort, right: Sort): boolean =>
|
|
44
|
+
left.kind === right.kind && (left.kind !== 'enum' || (right.kind === 'enum' && left.domain === right.domain))
|
|
45
|
+
|
|
46
|
+
const compatible = (left: Sort, right: Sort): boolean => same(left, right) || (numeric(left) && numeric(right))
|
|
47
|
+
|
|
48
|
+
const rationalOfInteger = (value: Variable & { readonly sort: 'int' }, side: 'min' | 'max'): Rational =>
|
|
49
|
+
String(Exact.integer(value[side]))
|
|
50
|
+
|
|
51
|
+
const pushExactProblem = (problems: string[], path: string, run: () => unknown): void => {
|
|
52
|
+
try {
|
|
53
|
+
run()
|
|
54
|
+
} catch (error) {
|
|
55
|
+
problems.push(`${path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const requireId = (id: string, path: string, problems: string[]): void => {
|
|
60
|
+
if (!idPattern.test(id)) {
|
|
61
|
+
problems.push(`${path} must match ${String(idPattern)}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const duplicates = (ids: readonly string[], path: string, problems: string[]): void => {
|
|
66
|
+
const seen = new Set<string>()
|
|
67
|
+
for (const id of ids) {
|
|
68
|
+
if (seen.has(id)) {
|
|
69
|
+
problems.push(`${path} contains duplicate identifier ${JSON.stringify(id)}`)
|
|
70
|
+
}
|
|
71
|
+
seen.add(id)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Walk = { nodes: number, depth: number }
|
|
76
|
+
|
|
77
|
+
const infer = (
|
|
78
|
+
expression: Expression,
|
|
79
|
+
path: string,
|
|
80
|
+
variables: ReadonlyMap<string, Variable>,
|
|
81
|
+
domains: ReadonlyMap<string, readonly string[]>,
|
|
82
|
+
problems: string[],
|
|
83
|
+
walk: Walk,
|
|
84
|
+
depth: number
|
|
85
|
+
): Sort => {
|
|
86
|
+
walk.nodes += 1
|
|
87
|
+
walk.depth = Math.max(walk.depth, depth)
|
|
88
|
+
const child = (value: Expression, suffix: string): Sort =>
|
|
89
|
+
infer(value, `${path}.${suffix}`, variables, domains, problems, walk, depth + 1)
|
|
90
|
+
const expectBool = (sort: Sort, at: string): void => {
|
|
91
|
+
if (sort.kind !== 'bool') problems.push(`${at} must be boolean, received ${sort.kind}`)
|
|
92
|
+
}
|
|
93
|
+
const expectNumeric = (sort: Sort, at: string): void => {
|
|
94
|
+
if (!numeric(sort)) problems.push(`${at} must be numeric, received ${sort.kind}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
switch (expression.kind) {
|
|
98
|
+
case 'literal':
|
|
99
|
+
switch (expression.sort) {
|
|
100
|
+
case 'bool': return { kind: 'bool' }
|
|
101
|
+
case 'int':
|
|
102
|
+
pushExactProblem(problems, path, () => Exact.integer(expression.value))
|
|
103
|
+
return { kind: 'int' }
|
|
104
|
+
case 'real':
|
|
105
|
+
pushExactProblem(problems, path, () => Exact.rational(expression.value))
|
|
106
|
+
return { kind: 'real' }
|
|
107
|
+
case 'enum': {
|
|
108
|
+
const values = domains.get(expression.domain)
|
|
109
|
+
if (values === undefined) problems.push(`${path} references unknown enum domain ${JSON.stringify(expression.domain)}`)
|
|
110
|
+
else if (!values.includes(expression.value)) problems.push(`${path} value ${JSON.stringify(expression.value)} is outside enum domain ${JSON.stringify(expression.domain)}`)
|
|
111
|
+
return { kind: 'enum', domain: expression.domain }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
case 'variable': {
|
|
115
|
+
const variable = variables.get(expression.id)
|
|
116
|
+
if (variable === undefined) {
|
|
117
|
+
problems.push(`${path} references unknown variable ${JSON.stringify(expression.id)}`)
|
|
118
|
+
return { kind: 'bool' }
|
|
119
|
+
}
|
|
120
|
+
return variable.sort === 'enum' ? { kind: 'enum', domain: variable.domain } : { kind: variable.sort }
|
|
121
|
+
}
|
|
122
|
+
case 'not': {
|
|
123
|
+
expectBool(child(expression.value, 'value'), `${path}.value`)
|
|
124
|
+
return { kind: 'bool' }
|
|
125
|
+
}
|
|
126
|
+
case 'and':
|
|
127
|
+
case 'or': {
|
|
128
|
+
if (expression.operands.length < 2) problems.push(`${path}.${expression.kind} requires at least two operands`)
|
|
129
|
+
expression.operands.forEach((operand, index) => expectBool(child(operand, `operands[${index}]`), `${path}.operands[${index}]`))
|
|
130
|
+
return { kind: 'bool' }
|
|
131
|
+
}
|
|
132
|
+
case 'implies': {
|
|
133
|
+
expectBool(child(expression.left, 'left'), `${path}.left`)
|
|
134
|
+
expectBool(child(expression.right, 'right'), `${path}.right`)
|
|
135
|
+
return { kind: 'bool' }
|
|
136
|
+
}
|
|
137
|
+
case 'eq':
|
|
138
|
+
case 'neq': {
|
|
139
|
+
const left = child(expression.left, 'left')
|
|
140
|
+
const right = child(expression.right, 'right')
|
|
141
|
+
if (!compatible(left, right)) problems.push(`${path} compares incompatible ${left.kind} and ${right.kind} expressions`)
|
|
142
|
+
return { kind: 'bool' }
|
|
143
|
+
}
|
|
144
|
+
case 'lt':
|
|
145
|
+
case 'lte':
|
|
146
|
+
case 'gt':
|
|
147
|
+
case 'gte': {
|
|
148
|
+
expectNumeric(child(expression.left, 'left'), `${path}.left`)
|
|
149
|
+
expectNumeric(child(expression.right, 'right'), `${path}.right`)
|
|
150
|
+
return { kind: 'bool' }
|
|
151
|
+
}
|
|
152
|
+
case 'add':
|
|
153
|
+
case 'multiply': {
|
|
154
|
+
if (expression.operands.length < 2) problems.push(`${path}.${expression.kind} requires at least two operands`)
|
|
155
|
+
const sorts = expression.operands.map((operand, index) => child(operand, `operands[${index}]`))
|
|
156
|
+
sorts.forEach((sort, index) => expectNumeric(sort, `${path}.operands[${index}]`))
|
|
157
|
+
return { kind: sorts.some(sort => sort.kind === 'real') ? 'real' : 'int' }
|
|
158
|
+
}
|
|
159
|
+
case 'subtract': {
|
|
160
|
+
const left = child(expression.left, 'left')
|
|
161
|
+
const right = child(expression.right, 'right')
|
|
162
|
+
expectNumeric(left, `${path}.left`)
|
|
163
|
+
expectNumeric(right, `${path}.right`)
|
|
164
|
+
return { kind: left.kind === 'real' || right.kind === 'real' ? 'real' : 'int' }
|
|
165
|
+
}
|
|
166
|
+
case 'divide': {
|
|
167
|
+
expectNumeric(child(expression.left, 'left'), `${path}.left`)
|
|
168
|
+
expectNumeric(child(expression.right, 'right'), `${path}.right`)
|
|
169
|
+
const divisor = expression.right
|
|
170
|
+
if (divisor.kind === 'literal' && (divisor.sort === 'int' || divisor.sort === 'real')) {
|
|
171
|
+
pushExactProblem(problems, `${path}.right`, () => {
|
|
172
|
+
const zero = divisor.sort === 'int' ? Exact.integer(divisor.value) === 0n : Exact.isZero(divisor.value)
|
|
173
|
+
if (zero) throw new TypeError('literal divisor must not be zero')
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
return { kind: 'real' }
|
|
177
|
+
}
|
|
178
|
+
case 'negate': {
|
|
179
|
+
const sort = child(expression.value, 'value')
|
|
180
|
+
expectNumeric(sort, `${path}.value`)
|
|
181
|
+
return sort
|
|
182
|
+
}
|
|
183
|
+
case 'if': {
|
|
184
|
+
expectBool(child(expression.condition, 'condition'), `${path}.condition`)
|
|
185
|
+
const thenSort = child(expression.then, 'then')
|
|
186
|
+
const elseSort = child(expression.else, 'else')
|
|
187
|
+
if (!compatible(thenSort, elseSort)) problems.push(`${path} branches have incompatible ${thenSort.kind} and ${elseSort.kind} sorts`)
|
|
188
|
+
return numeric(thenSort) && numeric(elseSort) && (thenSort.kind === 'real' || elseSort.kind === 'real')
|
|
189
|
+
? { kind: 'real' }
|
|
190
|
+
: thenSort
|
|
191
|
+
}
|
|
192
|
+
default:
|
|
193
|
+
problems.push(`${path} uses unsupported expression kind ${JSON.stringify((expression as { readonly kind?: unknown }).kind)}`)
|
|
194
|
+
return { kind: 'bool' }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const mergeLimits = (input: Partial<Limits> = {}): Limits => {
|
|
199
|
+
const limits = { ...defaultLimits, ...input }
|
|
200
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
201
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
202
|
+
throw new TypeError(`solver limit ${name} must be a positive safe integer, received ${String(value)}`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return limits
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const enforce = (stats: Stats, limits: Limits): void => {
|
|
209
|
+
const checks: readonly [keyof Limits, number][] = [
|
|
210
|
+
['maxVariables', stats.variables],
|
|
211
|
+
['maxConstraints', stats.constraints],
|
|
212
|
+
['maxObjectives', stats.objectives],
|
|
213
|
+
['maxEnumValues', stats.enumValues],
|
|
214
|
+
['maxExpressionNodes', stats.expressionNodes],
|
|
215
|
+
['maxExpressionDepth', stats.expressionDepth]
|
|
216
|
+
]
|
|
217
|
+
for (const [limit, actual] of checks) {
|
|
218
|
+
if (actual > limits[limit]) throw new ModelLimitError(limit, actual, limits[limit])
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Validates sorts and exact values before any backend is invoked. */
|
|
223
|
+
export const model = (input: Model, limitInput: Partial<Limits> = {}): Stats => {
|
|
224
|
+
const limits = mergeLimits(limitInput)
|
|
225
|
+
const problems: string[] = []
|
|
226
|
+
if (input.schema !== schema) problems.push(`schema must be ${JSON.stringify(schema)}`)
|
|
227
|
+
const domains = new Map<string, readonly string[]>()
|
|
228
|
+
const enumDeclarations = input.enums ?? []
|
|
229
|
+
duplicates(enumDeclarations.map(domain => domain.id), 'enums', problems)
|
|
230
|
+
enumDeclarations.forEach((domain, index) => {
|
|
231
|
+
requireId(domain.id, `enums[${index}].id`, problems)
|
|
232
|
+
if (domain.values.length === 0) problems.push(`enums[${index}] must contain at least one value`)
|
|
233
|
+
if (new Set(domain.values).size !== domain.values.length) problems.push(`enums[${index}] contains duplicate values`)
|
|
234
|
+
domains.set(domain.id, domain.values)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const variables = new Map<string, Variable>()
|
|
238
|
+
duplicates(input.variables.map(variable => variable.id), 'variables', problems)
|
|
239
|
+
input.variables.forEach((variable, index) => {
|
|
240
|
+
requireId(variable.id, `variables[${index}].id`, problems)
|
|
241
|
+
variables.set(variable.id, variable)
|
|
242
|
+
if (variable.sort === 'enum' && !domains.has(variable.domain)) {
|
|
243
|
+
problems.push(`variables[${index}] references unknown enum domain ${JSON.stringify(variable.domain)}`)
|
|
244
|
+
}
|
|
245
|
+
if (variable.sort === 'int') {
|
|
246
|
+
pushExactProblem(problems, `variables[${index}].min`, () => Exact.integer(variable.min))
|
|
247
|
+
pushExactProblem(problems, `variables[${index}].max`, () => Exact.integer(variable.max))
|
|
248
|
+
try {
|
|
249
|
+
if (Exact.compare(rationalOfInteger(variable, 'min'), rationalOfInteger(variable, 'max')) > 0) {
|
|
250
|
+
problems.push(`variables[${index}] has min greater than max`)
|
|
251
|
+
}
|
|
252
|
+
} catch { /* exact-value problems are already reported above */ }
|
|
253
|
+
}
|
|
254
|
+
if (variable.sort === 'real') {
|
|
255
|
+
if (variable.min !== undefined) pushExactProblem(problems, `variables[${index}].min`, () => Exact.rational(variable.min!))
|
|
256
|
+
if (variable.max !== undefined) pushExactProblem(problems, `variables[${index}].max`, () => Exact.rational(variable.max!))
|
|
257
|
+
if (variable.min !== undefined && variable.max !== undefined) {
|
|
258
|
+
try {
|
|
259
|
+
if (Exact.compare(variable.min, variable.max) > 0) problems.push(`variables[${index}] has min greater than max`)
|
|
260
|
+
} catch { /* exact-value problems are already reported above */ }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
duplicates([
|
|
266
|
+
...input.constraints.map(constraint => constraint.id),
|
|
267
|
+
...(input.softConstraints ?? []).map(constraint => constraint.id)
|
|
268
|
+
], 'constraints', problems)
|
|
269
|
+
duplicates((input.objectives ?? []).map(objective => objective.id), 'objectives', problems)
|
|
270
|
+
|
|
271
|
+
const walk: Walk = { nodes: 0, depth: 0 }
|
|
272
|
+
input.constraints.forEach((constraint, index) => {
|
|
273
|
+
requireId(constraint.id, `constraints[${index}].id`, problems)
|
|
274
|
+
const sort = infer(constraint.expression, `constraints[${index}].expression`, variables, domains, problems, walk, 1)
|
|
275
|
+
if (sort.kind !== 'bool') problems.push(`constraints[${index}].expression must be boolean, received ${sort.kind}`)
|
|
276
|
+
})
|
|
277
|
+
;(input.softConstraints ?? []).forEach((constraint, index) => {
|
|
278
|
+
requireId(constraint.id, `softConstraints[${index}].id`, problems)
|
|
279
|
+
const sort = infer(constraint.expression, `softConstraints[${index}].expression`, variables, domains, problems, walk, 1)
|
|
280
|
+
if (sort.kind !== 'bool') problems.push(`softConstraints[${index}].expression must be boolean, received ${sort.kind}`)
|
|
281
|
+
pushExactProblem(problems, `softConstraints[${index}].weight`, () => {
|
|
282
|
+
if (Exact.compare(constraint.weight, '0') <= 0) throw new TypeError('weight must be greater than zero')
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
;(input.objectives ?? []).forEach((objective, index) => {
|
|
286
|
+
requireId(objective.id, `objectives[${index}].id`, problems)
|
|
287
|
+
const sort = infer(objective.expression, `objectives[${index}].expression`, variables, domains, problems, walk, 1)
|
|
288
|
+
if (!numeric(sort)) problems.push(`objectives[${index}].expression must be numeric, received ${sort.kind}`)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
if (problems.length > 0) throw new ModelValidationError(problems)
|
|
292
|
+
const stats: Stats = {
|
|
293
|
+
variables: input.variables.length,
|
|
294
|
+
constraints: input.constraints.length + (input.softConstraints?.length ?? 0),
|
|
295
|
+
objectives: input.objectives?.length ?? 0,
|
|
296
|
+
enumValues: enumDeclarations.reduce((sum, domain) => sum + domain.values.length, 0),
|
|
297
|
+
expressionNodes: walk.nodes,
|
|
298
|
+
expressionDepth: walk.depth
|
|
299
|
+
}
|
|
300
|
+
enforce(stats, limits)
|
|
301
|
+
return stats
|
|
302
|
+
}
|