@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/adapter.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Assignment, Model, Rational, Value } from './model.ts'
|
|
2
|
+
|
|
3
|
+
export const capabilities = [
|
|
4
|
+
'booleans',
|
|
5
|
+
'integers',
|
|
6
|
+
'rationals',
|
|
7
|
+
'finite-enums',
|
|
8
|
+
'conditionals',
|
|
9
|
+
'nonlinear-arithmetic',
|
|
10
|
+
'optimization',
|
|
11
|
+
'lexicographic-objectives',
|
|
12
|
+
'soft-constraints',
|
|
13
|
+
'unsat-cores'
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
export type Capability = typeof capabilities[number]
|
|
17
|
+
|
|
18
|
+
export type Limits = {
|
|
19
|
+
readonly timeoutMs: number
|
|
20
|
+
/** Backend working-memory ceiling. Adapters must reject or isolate when they cannot enforce it. */
|
|
21
|
+
readonly maxMemoryBytes: number
|
|
22
|
+
readonly maxVariables: number
|
|
23
|
+
readonly maxConstraints: number
|
|
24
|
+
readonly maxObjectives: number
|
|
25
|
+
readonly maxEnumValues: number
|
|
26
|
+
readonly maxExpressionNodes: number
|
|
27
|
+
readonly maxExpressionDepth: number
|
|
28
|
+
readonly maxOutputBytes: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const defaultLimits: Limits = Object.freeze({
|
|
32
|
+
timeoutMs: 10_000,
|
|
33
|
+
maxMemoryBytes: 512 * 1024 * 1024,
|
|
34
|
+
maxVariables: 1_000,
|
|
35
|
+
maxConstraints: 5_000,
|
|
36
|
+
maxObjectives: 16,
|
|
37
|
+
maxEnumValues: 1_000,
|
|
38
|
+
maxExpressionNodes: 100_000,
|
|
39
|
+
maxExpressionDepth: 128,
|
|
40
|
+
maxOutputBytes: 1_000_000
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export type Backend = {
|
|
44
|
+
readonly name: string
|
|
45
|
+
readonly version: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type Diagnostic = {
|
|
49
|
+
readonly level: 'info' | 'warning' | 'error'
|
|
50
|
+
readonly code: string
|
|
51
|
+
readonly message: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type CommonResult = {
|
|
55
|
+
readonly backend: Backend
|
|
56
|
+
readonly diagnostics: readonly Diagnostic[]
|
|
57
|
+
readonly elapsedMs: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ObjectiveValue = {
|
|
61
|
+
readonly objectiveId: string
|
|
62
|
+
readonly value: Value
|
|
63
|
+
readonly bound?: Value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type UnknownReason = {
|
|
67
|
+
readonly kind: 'timeout' | 'resource-limit' | 'cancelled' | 'backend-error' | 'indeterminate'
|
|
68
|
+
readonly message: string
|
|
69
|
+
readonly limit?: keyof Limits
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type Result = CommonResult & (
|
|
73
|
+
| { readonly status: 'satisfied', readonly assignment: Assignment }
|
|
74
|
+
| {
|
|
75
|
+
readonly status: 'optimal'
|
|
76
|
+
readonly assignment: Assignment
|
|
77
|
+
readonly objectives: readonly ObjectiveValue[]
|
|
78
|
+
readonly optimalityProved: true
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
readonly status: 'unsatisfied'
|
|
82
|
+
readonly core?: readonly string[]
|
|
83
|
+
readonly infeasibilityProved: true
|
|
84
|
+
}
|
|
85
|
+
| { readonly status: 'unknown', readonly reason: UnknownReason }
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
export type Options = {
|
|
89
|
+
readonly limits?: Partial<Limits>
|
|
90
|
+
readonly unsatCore?: boolean
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Request = {
|
|
94
|
+
readonly limits: Limits
|
|
95
|
+
readonly unsatCore: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type SolverAdapter = {
|
|
99
|
+
readonly backend: Backend
|
|
100
|
+
readonly capabilities: ReadonlySet<Capability>
|
|
101
|
+
readonly solve: (model: Model, request: Request) => Promise<Result>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type t = SolverAdapter
|
|
105
|
+
|
|
106
|
+
/** Utility type for adapters that report exact objective bounds. */
|
|
107
|
+
export type ExactBound = {
|
|
108
|
+
readonly value: Rational
|
|
109
|
+
readonly bound?: Rational
|
|
110
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import * as Exact from './exact.ts'
|
|
3
|
+
import type { Expression, Model, Rational } from './model.ts'
|
|
4
|
+
import { model as validate } from './validate.ts'
|
|
5
|
+
|
|
6
|
+
type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json }
|
|
7
|
+
|
|
8
|
+
const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
|
|
9
|
+
|
|
10
|
+
const exact = (value: Rational): Json => Exact.rational(value)
|
|
11
|
+
|
|
12
|
+
const expression = (input: Expression): Json => {
|
|
13
|
+
switch (input.kind) {
|
|
14
|
+
case 'literal':
|
|
15
|
+
if (input.sort === 'int') return { kind: input.kind, sort: input.sort, value: String(Exact.integer(input.value)) }
|
|
16
|
+
if (input.sort === 'real') return { kind: input.kind, sort: input.sort, value: exact(input.value) }
|
|
17
|
+
if (input.sort === 'enum') return { kind: input.kind, sort: input.sort, domain: input.domain, value: input.value }
|
|
18
|
+
return { kind: input.kind, sort: input.sort, value: input.value }
|
|
19
|
+
case 'variable': return { kind: input.kind, id: input.id }
|
|
20
|
+
case 'not':
|
|
21
|
+
case 'negate': return { kind: input.kind, value: expression(input.value) }
|
|
22
|
+
case 'and':
|
|
23
|
+
case 'or':
|
|
24
|
+
case 'add':
|
|
25
|
+
case 'multiply': {
|
|
26
|
+
const operands = input.operands.map(expression)
|
|
27
|
+
.sort((left, right) => compareText(stableStringify(left), stableStringify(right)))
|
|
28
|
+
return { kind: input.kind, operands }
|
|
29
|
+
}
|
|
30
|
+
case 'implies':
|
|
31
|
+
case 'subtract':
|
|
32
|
+
case 'divide':
|
|
33
|
+
case 'lt':
|
|
34
|
+
case 'lte':
|
|
35
|
+
case 'gt':
|
|
36
|
+
case 'gte':
|
|
37
|
+
return { kind: input.kind, left: expression(input.left), right: expression(input.right) }
|
|
38
|
+
case 'eq':
|
|
39
|
+
case 'neq': {
|
|
40
|
+
const operands = [expression(input.left), expression(input.right)]
|
|
41
|
+
.sort((left, right) => compareText(stableStringify(left), stableStringify(right)))
|
|
42
|
+
return { kind: input.kind, left: operands[0]!, right: operands[1]! }
|
|
43
|
+
}
|
|
44
|
+
case 'if':
|
|
45
|
+
return { kind: input.kind, condition: expression(input.condition), then: expression(input.then), else: expression(input.else) }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const stableStringify = (value: Json): string => {
|
|
50
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
51
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
52
|
+
const record = value as Readonly<Record<string, Json>>
|
|
53
|
+
return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${stableStringify(record[key]!)}`).join(',')}}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const byId = <T extends { readonly id: string }>(values: readonly T[]): readonly T[] =>
|
|
57
|
+
[...values].sort((left, right) => compareText(left.id, right.id))
|
|
58
|
+
|
|
59
|
+
/** Canonical semantic JSON. Descriptions and evidence links are deliberately not identity. */
|
|
60
|
+
export const serialize = (input: Model): string => {
|
|
61
|
+
validate(input)
|
|
62
|
+
const canonical: Json = {
|
|
63
|
+
schema: input.schema,
|
|
64
|
+
enums: byId(input.enums ?? []).map(domain => ({ id: domain.id, values: [...domain.values].sort() })),
|
|
65
|
+
variables: byId(input.variables).map((variable): Json => {
|
|
66
|
+
switch (variable.sort) {
|
|
67
|
+
case 'bool': return { id: variable.id, sort: variable.sort }
|
|
68
|
+
case 'int': return { id: variable.id, sort: variable.sort, min: String(Exact.integer(variable.min)), max: String(Exact.integer(variable.max)) }
|
|
69
|
+
case 'real': return {
|
|
70
|
+
id: variable.id,
|
|
71
|
+
sort: variable.sort,
|
|
72
|
+
...(variable.min === undefined ? {} : { min: exact(variable.min) }),
|
|
73
|
+
...(variable.max === undefined ? {} : { max: exact(variable.max) })
|
|
74
|
+
}
|
|
75
|
+
case 'enum': return { id: variable.id, sort: variable.sort, domain: variable.domain }
|
|
76
|
+
}
|
|
77
|
+
}),
|
|
78
|
+
constraints: byId(input.constraints).map(constraint => ({ id: constraint.id, expression: expression(constraint.expression) })),
|
|
79
|
+
softConstraints: byId(input.softConstraints ?? []).map(constraint => ({
|
|
80
|
+
id: constraint.id,
|
|
81
|
+
expression: expression(constraint.expression),
|
|
82
|
+
weight: exact(constraint.weight)
|
|
83
|
+
})),
|
|
84
|
+
objectives: (input.objectives ?? []).map(objective => ({
|
|
85
|
+
id: objective.id,
|
|
86
|
+
direction: objective.direction,
|
|
87
|
+
expression: expression(objective.expression)
|
|
88
|
+
}))
|
|
89
|
+
}
|
|
90
|
+
return stableStringify(canonical)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const digest = (input: Model): string =>
|
|
94
|
+
`sha256:${createHash('sha256').update(serialize(input)).digest('hex')}`
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Capability, SolverAdapter } from './adapter.ts'
|
|
2
|
+
import type { Expression, Model } from './model.ts'
|
|
3
|
+
|
|
4
|
+
export class UnsupportedModelError extends Error {
|
|
5
|
+
readonly backend: string
|
|
6
|
+
readonly missing: readonly Capability[]
|
|
7
|
+
|
|
8
|
+
constructor(backend: string, missing: readonly Capability[]) {
|
|
9
|
+
super(`solver backend ${JSON.stringify(backend)} lacks capabilities: ${missing.join(', ')}`)
|
|
10
|
+
this.name = 'UnsupportedModelError'
|
|
11
|
+
this.backend = backend
|
|
12
|
+
this.missing = missing
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const walk = (expression: Expression, visit: (expression: Expression) => void): void => {
|
|
17
|
+
visit(expression)
|
|
18
|
+
switch (expression.kind) {
|
|
19
|
+
case 'literal':
|
|
20
|
+
case 'variable': return
|
|
21
|
+
case 'not':
|
|
22
|
+
case 'negate': walk(expression.value, visit); return
|
|
23
|
+
case 'and':
|
|
24
|
+
case 'or':
|
|
25
|
+
case 'add':
|
|
26
|
+
case 'multiply': expression.operands.forEach(operand => walk(operand, visit)); return
|
|
27
|
+
case 'if':
|
|
28
|
+
walk(expression.condition, visit)
|
|
29
|
+
walk(expression.then, visit)
|
|
30
|
+
walk(expression.else, visit)
|
|
31
|
+
return
|
|
32
|
+
default:
|
|
33
|
+
walk(expression.left, visit)
|
|
34
|
+
walk(expression.right, visit)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hasNonlinearArithmetic = (expression: Expression): boolean => {
|
|
39
|
+
let nonlinear = false
|
|
40
|
+
const containsVariable = (candidate: Expression): boolean => {
|
|
41
|
+
let found = false
|
|
42
|
+
walk(candidate, node => { if (node.kind === 'variable') found = true })
|
|
43
|
+
return found
|
|
44
|
+
}
|
|
45
|
+
walk(expression, node => {
|
|
46
|
+
if (node.kind === 'multiply' && node.operands.filter(containsVariable).length > 1) nonlinear = true
|
|
47
|
+
if (node.kind === 'divide' && containsVariable(node.right)) nonlinear = true
|
|
48
|
+
})
|
|
49
|
+
return nonlinear
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const required = (model: Model, unsatCore = false): ReadonlySet<Capability> => {
|
|
53
|
+
const result = new Set<Capability>(['booleans'])
|
|
54
|
+
if (model.variables.some(variable => variable.sort === 'int')) result.add('integers')
|
|
55
|
+
if (model.variables.some(variable => variable.sort === 'real')) result.add('rationals')
|
|
56
|
+
if ((model.enums?.length ?? 0) > 0 || model.variables.some(variable => variable.sort === 'enum')) result.add('finite-enums')
|
|
57
|
+
if ((model.softConstraints?.length ?? 0) > 0) {
|
|
58
|
+
result.add('soft-constraints')
|
|
59
|
+
result.add('rationals')
|
|
60
|
+
}
|
|
61
|
+
if ((model.objectives?.length ?? 0) > 0) result.add('optimization')
|
|
62
|
+
if ((model.objectives?.length ?? 0) > 1) result.add('lexicographic-objectives')
|
|
63
|
+
if (unsatCore) result.add('unsat-cores')
|
|
64
|
+
const expressions = [
|
|
65
|
+
...model.constraints.map(constraint => constraint.expression),
|
|
66
|
+
...(model.softConstraints ?? []).map(constraint => constraint.expression),
|
|
67
|
+
...(model.objectives ?? []).map(objective => objective.expression)
|
|
68
|
+
]
|
|
69
|
+
for (const expression of expressions) {
|
|
70
|
+
walk(expression, node => {
|
|
71
|
+
if (node.kind === 'literal' && node.sort === 'int') result.add('integers')
|
|
72
|
+
if (node.kind === 'literal' && node.sort === 'real') result.add('rationals')
|
|
73
|
+
if (node.kind === 'literal' && node.sort === 'enum') result.add('finite-enums')
|
|
74
|
+
if (node.kind === 'if') result.add('conditionals')
|
|
75
|
+
})
|
|
76
|
+
if (hasNonlinearArithmetic(expression)) result.add('nonlinear-arithmetic')
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const missing = (adapter: SolverAdapter, model: Model, unsatCore = false): readonly Capability[] =>
|
|
82
|
+
[...required(model, unsatCore)].filter(capability => !adapter.capabilities.has(capability)).sort()
|
package/src/exact.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Integer, Rational } from './model.ts'
|
|
2
|
+
|
|
3
|
+
export type Normalized = {
|
|
4
|
+
readonly numerator: string
|
|
5
|
+
readonly denominator: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const integerPattern = /^[+-]?\d+$/
|
|
9
|
+
const decimalPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/
|
|
10
|
+
|
|
11
|
+
const gcd = (left: bigint, right: bigint): bigint => {
|
|
12
|
+
let a = left < 0n ? -left : left
|
|
13
|
+
let b = right < 0n ? -right : right
|
|
14
|
+
while (b !== 0n) {
|
|
15
|
+
const remainder = a % b
|
|
16
|
+
a = b
|
|
17
|
+
b = remainder
|
|
18
|
+
}
|
|
19
|
+
return a
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const integer = (input: Integer): bigint => {
|
|
23
|
+
if (typeof input === 'number') {
|
|
24
|
+
if (!Number.isSafeInteger(input)) {
|
|
25
|
+
throw new TypeError(`expected a safe integer, received ${String(input)}`)
|
|
26
|
+
}
|
|
27
|
+
return BigInt(input)
|
|
28
|
+
}
|
|
29
|
+
if (!integerPattern.test(input)) {
|
|
30
|
+
throw new TypeError(`expected an integer string, received ${JSON.stringify(input)}`)
|
|
31
|
+
}
|
|
32
|
+
return BigInt(input)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const decimal = (input: string): readonly [bigint, bigint] => {
|
|
36
|
+
if (!decimalPattern.test(input)) {
|
|
37
|
+
throw new TypeError(`expected an exact decimal string, received ${JSON.stringify(input)}`)
|
|
38
|
+
}
|
|
39
|
+
const [coefficient = '', exponentText] = input.toLowerCase().split('e')
|
|
40
|
+
const exponent = exponentText === undefined ? 0 : Number(exponentText)
|
|
41
|
+
if (!Number.isSafeInteger(exponent)) {
|
|
42
|
+
throw new TypeError(`decimal exponent is outside the supported range: ${JSON.stringify(input)}`)
|
|
43
|
+
}
|
|
44
|
+
const negative = coefficient.startsWith('-')
|
|
45
|
+
const unsigned = coefficient.replace(/^[+-]/, '')
|
|
46
|
+
const [whole = '', fraction = ''] = unsigned.split('.')
|
|
47
|
+
const digits = `${whole === '' ? '0' : whole}${fraction}`
|
|
48
|
+
const scale = fraction.length - exponent
|
|
49
|
+
const signed = BigInt(digits === '' ? '0' : digits) * (negative ? -1n : 1n)
|
|
50
|
+
return scale <= 0
|
|
51
|
+
? [signed * (10n ** BigInt(-scale)), 1n]
|
|
52
|
+
: [signed, 10n ** BigInt(scale)]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const rational = (input: Rational): Normalized => {
|
|
56
|
+
let numerator: bigint
|
|
57
|
+
let denominator: bigint
|
|
58
|
+
if (typeof input === 'string') {
|
|
59
|
+
;[numerator, denominator] = decimal(input)
|
|
60
|
+
} else {
|
|
61
|
+
numerator = integer(input.numerator)
|
|
62
|
+
denominator = integer(input.denominator)
|
|
63
|
+
}
|
|
64
|
+
if (denominator === 0n) {
|
|
65
|
+
throw new TypeError('rational denominator must not be zero')
|
|
66
|
+
}
|
|
67
|
+
if (denominator < 0n) {
|
|
68
|
+
numerator = -numerator
|
|
69
|
+
denominator = -denominator
|
|
70
|
+
}
|
|
71
|
+
const divisor = gcd(numerator, denominator)
|
|
72
|
+
return {
|
|
73
|
+
numerator: String(numerator / divisor),
|
|
74
|
+
denominator: String(denominator / divisor)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const compare = (left: Rational, right: Rational): -1 | 0 | 1 => {
|
|
79
|
+
const a = rational(left)
|
|
80
|
+
const b = rational(right)
|
|
81
|
+
const difference = BigInt(a.numerator) * BigInt(b.denominator) - BigInt(b.numerator) * BigInt(a.denominator)
|
|
82
|
+
return difference < 0n ? -1 : difference > 0n ? 1 : 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const isZero = (input: Rational): boolean => rational(input).numerator === '0'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solver-neutral formal reasoning contracts for CAVE.
|
|
3
|
+
*
|
|
4
|
+
* This package contains no solver implementation and never imports Z3 or
|
|
5
|
+
* another backend. Adapters receive only validated portable model data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * as Adapter from './adapter.ts'
|
|
9
|
+
export * as Canonical from './canonical.ts'
|
|
10
|
+
export * as Capability from './capability.ts'
|
|
11
|
+
export * as Exact from './exact.ts'
|
|
12
|
+
export * as Linear from './linear.ts'
|
|
13
|
+
export * as Model from './model.ts'
|
|
14
|
+
export * as Solve from './solve.ts'
|
|
15
|
+
export * as Validate from './validate.ts'
|
package/src/linear.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Expression, Model } from './model.ts'
|
|
2
|
+
import { model as validate } from './validate.ts'
|
|
3
|
+
|
|
4
|
+
export type Analysis = {
|
|
5
|
+
readonly linear: boolean
|
|
6
|
+
readonly problems: readonly string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type Affine = { readonly linear: boolean, readonly constant: boolean }
|
|
10
|
+
|
|
11
|
+
const affine = (expression: Expression): Affine => {
|
|
12
|
+
switch (expression.kind) {
|
|
13
|
+
case 'literal': return { linear: expression.sort === 'int' || expression.sort === 'real', constant: true }
|
|
14
|
+
case 'variable': return { linear: true, constant: false }
|
|
15
|
+
case 'negate': return affine(expression.value)
|
|
16
|
+
case 'add': {
|
|
17
|
+
const parts = expression.operands.map(affine)
|
|
18
|
+
return { linear: parts.every(part => part.linear), constant: parts.every(part => part.constant) }
|
|
19
|
+
}
|
|
20
|
+
case 'subtract': {
|
|
21
|
+
const left = affine(expression.left)
|
|
22
|
+
const right = affine(expression.right)
|
|
23
|
+
return { linear: left.linear && right.linear, constant: left.constant && right.constant }
|
|
24
|
+
}
|
|
25
|
+
case 'multiply': {
|
|
26
|
+
const parts = expression.operands.map(affine)
|
|
27
|
+
return { linear: parts.every(part => part.linear) && parts.filter(part => !part.constant).length <= 1, constant: parts.every(part => part.constant) }
|
|
28
|
+
}
|
|
29
|
+
case 'divide': {
|
|
30
|
+
const left = affine(expression.left)
|
|
31
|
+
const right = affine(expression.right)
|
|
32
|
+
return { linear: left.linear && right.linear && right.constant, constant: left.constant && right.constant }
|
|
33
|
+
}
|
|
34
|
+
default: return { linear: false, constant: false }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const constraintIsLinear = (expression: Expression): boolean => {
|
|
39
|
+
if (expression.kind !== 'eq' && expression.kind !== 'lte' && expression.kind !== 'gte') return false
|
|
40
|
+
return affine(expression.left).linear && affine(expression.right).linear
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Recognizes the portable LP/MIP subset without consulting a backend AST. */
|
|
44
|
+
export const model = (input: Model): Analysis => {
|
|
45
|
+
validate(input)
|
|
46
|
+
const problems: string[] = []
|
|
47
|
+
if (input.variables.some(variable => variable.sort === 'bool' || variable.sort === 'enum')) {
|
|
48
|
+
problems.push('Boolean and enum variables are outside the linear subset')
|
|
49
|
+
}
|
|
50
|
+
if ((input.softConstraints?.length ?? 0) > 0) problems.push('soft constraints are outside the linear subset')
|
|
51
|
+
input.constraints.forEach(constraint => {
|
|
52
|
+
if (!constraintIsLinear(constraint.expression)) problems.push(`constraint ${constraint.id} is not a non-strict linear comparison`)
|
|
53
|
+
})
|
|
54
|
+
;(input.objectives ?? []).forEach(objective => {
|
|
55
|
+
if (!affine(objective.expression).linear) problems.push(`objective ${objective.id} is not linear`)
|
|
56
|
+
})
|
|
57
|
+
return { linear: problems.length === 0, problems }
|
|
58
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Portable model schema understood by solver adapters. */
|
|
2
|
+
export const schema = 'cave.solver/model@1' as const
|
|
3
|
+
|
|
4
|
+
export type Integer = string | number
|
|
5
|
+
|
|
6
|
+
/** Exact rational input. Decimal strings are parsed without using `number`. */
|
|
7
|
+
export type Rational = string | {
|
|
8
|
+
readonly numerator: Integer
|
|
9
|
+
readonly denominator: Integer
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type EnumDomain = {
|
|
13
|
+
readonly id: string
|
|
14
|
+
readonly values: readonly string[]
|
|
15
|
+
readonly description?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Variable =
|
|
19
|
+
| { readonly id: string, readonly sort: 'bool', readonly description?: string }
|
|
20
|
+
| { readonly id: string, readonly sort: 'int', readonly min: Integer, readonly max: Integer, readonly description?: string }
|
|
21
|
+
| { readonly id: string, readonly sort: 'real', readonly min?: Rational, readonly max?: Rational, readonly description?: string }
|
|
22
|
+
| { readonly id: string, readonly sort: 'enum', readonly domain: string, readonly description?: string }
|
|
23
|
+
|
|
24
|
+
export type Literal =
|
|
25
|
+
| { readonly kind: 'literal', readonly sort: 'bool', readonly value: boolean }
|
|
26
|
+
| { readonly kind: 'literal', readonly sort: 'int', readonly value: Integer }
|
|
27
|
+
| { readonly kind: 'literal', readonly sort: 'real', readonly value: Rational }
|
|
28
|
+
| { readonly kind: 'literal', readonly sort: 'enum', readonly domain: string, readonly value: string }
|
|
29
|
+
|
|
30
|
+
export type Expression =
|
|
31
|
+
| Literal
|
|
32
|
+
| { readonly kind: 'variable', readonly id: string }
|
|
33
|
+
| { readonly kind: 'not', readonly value: Expression }
|
|
34
|
+
| { readonly kind: 'and' | 'or', readonly operands: readonly Expression[] }
|
|
35
|
+
| { readonly kind: 'implies', readonly left: Expression, readonly right: Expression }
|
|
36
|
+
| { readonly kind: 'eq' | 'neq' | 'lt' | 'lte' | 'gt' | 'gte', readonly left: Expression, readonly right: Expression }
|
|
37
|
+
| { readonly kind: 'add' | 'multiply', readonly operands: readonly Expression[] }
|
|
38
|
+
| { readonly kind: 'subtract' | 'divide', readonly left: Expression, readonly right: Expression }
|
|
39
|
+
| { readonly kind: 'negate', readonly value: Expression }
|
|
40
|
+
| { readonly kind: 'if', readonly condition: Expression, readonly then: Expression, readonly else: Expression }
|
|
41
|
+
|
|
42
|
+
export type HardConstraint = {
|
|
43
|
+
readonly id: string
|
|
44
|
+
readonly expression: Expression
|
|
45
|
+
readonly description?: string
|
|
46
|
+
readonly evidenceRowIds?: readonly string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SoftConstraint = HardConstraint & {
|
|
50
|
+
/** Deliberate preference weight; never inferred from CAVE confidence. */
|
|
51
|
+
readonly weight: Rational
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type Objective = {
|
|
55
|
+
readonly id: string
|
|
56
|
+
readonly direction: 'minimize' | 'maximize'
|
|
57
|
+
readonly expression: Expression
|
|
58
|
+
readonly description?: string
|
|
59
|
+
readonly evidenceRowIds?: readonly string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Model = {
|
|
63
|
+
readonly schema: typeof schema
|
|
64
|
+
readonly enums?: readonly EnumDomain[]
|
|
65
|
+
readonly variables: readonly Variable[]
|
|
66
|
+
readonly constraints: readonly HardConstraint[]
|
|
67
|
+
readonly softConstraints?: readonly SoftConstraint[]
|
|
68
|
+
/** Objective order is semantic and means lexicographic priority. */
|
|
69
|
+
readonly objectives?: readonly Objective[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type t = Model
|
|
73
|
+
|
|
74
|
+
export type Value =
|
|
75
|
+
| { readonly sort: 'bool', readonly value: boolean }
|
|
76
|
+
| { readonly sort: 'int', readonly value: string }
|
|
77
|
+
| { readonly sort: 'real', readonly numerator: string, readonly denominator: string }
|
|
78
|
+
| { readonly sort: 'enum', readonly domain: string, readonly value: string }
|
|
79
|
+
|
|
80
|
+
export type Assignment = Readonly<Record<string, Value>>
|
package/src/solve.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Options, Result, SolverAdapter } from './adapter.ts'
|
|
2
|
+
import * as Capability from './capability.ts'
|
|
3
|
+
import type { Model } from './model.ts'
|
|
4
|
+
import * as Validate from './validate.ts'
|
|
5
|
+
|
|
6
|
+
/** Validates and negotiates a model before crossing the adapter boundary. */
|
|
7
|
+
export const run = async (adapter: SolverAdapter, model: Model, options: Options = {}): Promise<Result> => {
|
|
8
|
+
const limits = Validate.mergeLimits(options.limits)
|
|
9
|
+
Validate.model(model, limits)
|
|
10
|
+
const missing = Capability.missing(adapter, model, options.unsatCore ?? false)
|
|
11
|
+
if (missing.length > 0) throw new Capability.UnsupportedModelError(adapter.backend.name, missing)
|
|
12
|
+
return adapter.solve(model, { limits, unsatCore: options.unsatCore ?? false })
|
|
13
|
+
}
|