@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.
Files changed (48) hide show
  1. package/License.md +8 -0
  2. package/README.md +95 -0
  3. package/dist/src/adapter.d.ts +77 -0
  4. package/dist/src/adapter.d.ts.map +1 -0
  5. package/dist/src/adapter.js +24 -0
  6. package/dist/src/adapter.js.map +1 -0
  7. package/dist/src/canonical.d.ts +5 -0
  8. package/dist/src/canonical.d.ts.map +1 -0
  9. package/dist/src/canonical.js +88 -0
  10. package/dist/src/canonical.js.map +1 -0
  11. package/dist/src/capability.d.ts +10 -0
  12. package/dist/src/capability.d.ts.map +1 -0
  13. package/dist/src/capability.js +92 -0
  14. package/dist/src/capability.js.map +1 -0
  15. package/dist/src/exact.d.ts +10 -0
  16. package/dist/src/exact.d.ts.map +1 -0
  17. package/dist/src/exact.js +75 -0
  18. package/dist/src/exact.js.map +1 -0
  19. package/dist/src/index.d.ts +15 -0
  20. package/dist/src/index.d.ts.map +1 -0
  21. package/dist/src/index.js +15 -0
  22. package/dist/src/index.js.map +1 -0
  23. package/dist/src/linear.d.ts +8 -0
  24. package/dist/src/linear.d.ts.map +1 -0
  25. package/dist/src/linear.js +52 -0
  26. package/dist/src/linear.js.map +1 -0
  27. package/dist/src/model.d.ts +130 -0
  28. package/dist/src/model.d.ts.map +1 -0
  29. package/dist/src/model.js +3 -0
  30. package/dist/src/model.js.map +1 -0
  31. package/dist/src/solve.d.ts +5 -0
  32. package/dist/src/solve.d.ts.map +1 -0
  33. package/dist/src/solve.js +12 -0
  34. package/dist/src/solve.js.map +1 -0
  35. package/dist/src/validate.d.ts +24 -0
  36. package/dist/src/validate.d.ts.map +1 -0
  37. package/dist/src/validate.js +283 -0
  38. package/dist/src/validate.js.map +1 -0
  39. package/package.json +39 -0
  40. package/src/adapter.ts +110 -0
  41. package/src/canonical.ts +94 -0
  42. package/src/capability.ts +82 -0
  43. package/src/exact.ts +85 -0
  44. package/src/index.ts +15 -0
  45. package/src/linear.ts +58 -0
  46. package/src/model.ts +80 -0
  47. package/src/solve.ts +13 -0
  48. package/src/validate.ts +302 -0
@@ -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
+ }