@cavelang/solver 0.27.14 → 0.29.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/Authors.md +5 -0
- package/License.md +2 -4
- package/README.md +95 -1
- package/dist/src/explain.d.ts +106 -0
- package/dist/src/explain.d.ts.map +1 -0
- package/dist/src/explain.js +256 -0
- package/dist/src/explain.js.map +1 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/model.d.ts +20 -15
- package/dist/src/model.d.ts.map +1 -1
- package/dist/src/solve.d.ts +3 -0
- package/dist/src/solve.d.ts.map +1 -1
- package/dist/src/solve.js +14 -4
- package/dist/src/solve.js.map +1 -1
- package/dist/src/validate.d.ts.map +1 -1
- package/dist/src/validate.js +30 -0
- package/dist/src/validate.js.map +1 -1
- package/dist/src/workflow.d.ts +112 -0
- package/dist/src/workflow.d.ts.map +1 -0
- package/dist/src/workflow.js +420 -0
- package/dist/src/workflow.js.map +1 -0
- package/package.json +15 -4
- package/src/explain.ts +401 -0
- package/src/index.ts +3 -1
- package/src/model.ts +25 -11
- package/src/solve.ts +24 -4
- package/src/validate.ts +31 -0
- package/src/workflow.ts +578 -0
package/src/explain.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import type { Backend, Diagnostic, Limits, ObjectiveValue, Result, UnknownReason } from './adapter.ts'
|
|
2
|
+
import * as Canonical from './canonical.ts'
|
|
3
|
+
import * as Exact from './exact.ts'
|
|
4
|
+
import type {
|
|
5
|
+
Assignment,
|
|
6
|
+
Declaration,
|
|
7
|
+
Expression,
|
|
8
|
+
HardConstraint,
|
|
9
|
+
Model,
|
|
10
|
+
Objective,
|
|
11
|
+
Provenance,
|
|
12
|
+
Rational,
|
|
13
|
+
SoftConstraint,
|
|
14
|
+
Value,
|
|
15
|
+
Variable
|
|
16
|
+
} from './model.ts'
|
|
17
|
+
|
|
18
|
+
export const schema = 'cave.solver/explanation@1' as const
|
|
19
|
+
|
|
20
|
+
const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
|
|
21
|
+
|
|
22
|
+
export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json | undefined }
|
|
23
|
+
|
|
24
|
+
export type Snapshot = {
|
|
25
|
+
readonly transactionTime: string | null
|
|
26
|
+
readonly validTime?: string
|
|
27
|
+
readonly aliases?: 'exact' | 'closure'
|
|
28
|
+
readonly resolution?: 'coexisting' | 'winner'
|
|
29
|
+
readonly minimumConfidence?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Input = {
|
|
33
|
+
readonly id: string
|
|
34
|
+
readonly query?: string
|
|
35
|
+
readonly value?: Json
|
|
36
|
+
readonly authoredValue?: Json
|
|
37
|
+
readonly evidenceRowIds: readonly string[]
|
|
38
|
+
readonly scenarioClaimIds: readonly string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type Scenario = {
|
|
42
|
+
readonly id: string
|
|
43
|
+
readonly inputDigest: string
|
|
44
|
+
readonly overlayDigest: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type Context = {
|
|
48
|
+
/** Reject accidentally replaying bindings against a different model. */
|
|
49
|
+
readonly modelDigest?: string
|
|
50
|
+
readonly scenario?: Scenario
|
|
51
|
+
readonly snapshot?: Snapshot
|
|
52
|
+
readonly inputs?: readonly Input[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type Run = {
|
|
56
|
+
readonly modelDigest: string
|
|
57
|
+
readonly backend: Backend
|
|
58
|
+
readonly elapsedMs: number
|
|
59
|
+
readonly limits: Limits
|
|
60
|
+
readonly diagnostics: readonly Diagnostic[]
|
|
61
|
+
readonly scenario?: Scenario
|
|
62
|
+
readonly snapshot?: Snapshot
|
|
63
|
+
readonly inputs: readonly Input[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type Element = {
|
|
67
|
+
readonly id: string
|
|
68
|
+
readonly description?: string
|
|
69
|
+
readonly declaration?: Declaration
|
|
70
|
+
readonly evidenceRowIds: readonly string[]
|
|
71
|
+
readonly scenarioInputIds: readonly string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type AssignmentValue = Element & {
|
|
75
|
+
/** False exposes an adapter value that has no matching model declaration. */
|
|
76
|
+
readonly declared: boolean
|
|
77
|
+
readonly value: Value
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type Constraint = Element & {
|
|
81
|
+
readonly evaluation: 'satisfied' | 'violated' | 'indeterminate'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type SoftConstraintResult = Element & {
|
|
85
|
+
readonly evaluation: 'accepted' | 'violated' | 'indeterminate'
|
|
86
|
+
readonly weight: ReturnType<typeof Exact.rational>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type ObjectiveResult = Element & {
|
|
90
|
+
/** False exposes an adapter value that has no matching model declaration. */
|
|
91
|
+
readonly declared: boolean
|
|
92
|
+
readonly direction: 'minimize' | 'maximize' | 'unknown'
|
|
93
|
+
readonly value: Value
|
|
94
|
+
readonly bound?: Value
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type CoreConstraint = Element & {
|
|
98
|
+
/** False means the backend returned an ID absent from the submitted model. */
|
|
99
|
+
readonly declared: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type Feasible = {
|
|
103
|
+
readonly assignments: readonly AssignmentValue[]
|
|
104
|
+
readonly hardConstraints: readonly Constraint[]
|
|
105
|
+
readonly softConstraints: readonly SoftConstraintResult[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type Outcome =
|
|
109
|
+
| ({ readonly status: 'satisfied' } & Feasible)
|
|
110
|
+
| ({
|
|
111
|
+
readonly status: 'optimal'
|
|
112
|
+
readonly objectives: readonly ObjectiveResult[]
|
|
113
|
+
readonly optimalityProved: true
|
|
114
|
+
} & Feasible)
|
|
115
|
+
| {
|
|
116
|
+
readonly status: 'unsatisfied'
|
|
117
|
+
readonly core?: readonly CoreConstraint[]
|
|
118
|
+
/** Solver cores explain one contradiction, but are not promised minimal. */
|
|
119
|
+
readonly coreMinimal: false
|
|
120
|
+
readonly infeasibilityProved: true
|
|
121
|
+
}
|
|
122
|
+
| { readonly status: 'unknown', readonly reason: UnknownReason }
|
|
123
|
+
|
|
124
|
+
export type Report = {
|
|
125
|
+
readonly schema: typeof schema
|
|
126
|
+
readonly run: Run
|
|
127
|
+
readonly outcome: Outcome
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type NumberValue = { readonly kind: 'number', readonly numerator: bigint, readonly denominator: bigint }
|
|
131
|
+
type Evaluated =
|
|
132
|
+
| { readonly kind: 'bool', readonly value: boolean }
|
|
133
|
+
| NumberValue
|
|
134
|
+
| { readonly kind: 'enum', readonly domain: string, readonly value: string }
|
|
135
|
+
|
|
136
|
+
const normalize = (numerator: bigint, denominator: bigint): NumberValue => {
|
|
137
|
+
if (denominator === 0n) throw new TypeError('cannot explain division by zero')
|
|
138
|
+
const exact = Exact.rational({ numerator: String(numerator), denominator: String(denominator) })
|
|
139
|
+
return { kind: 'number', numerator: BigInt(exact.numerator), denominator: BigInt(exact.denominator) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const number = (value: Rational): NumberValue => {
|
|
143
|
+
const exact = Exact.rational(value)
|
|
144
|
+
return { kind: 'number', numerator: BigInt(exact.numerator), denominator: BigInt(exact.denominator) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const numeric = (value: Evaluated): NumberValue => {
|
|
148
|
+
if (value.kind !== 'number') throw new TypeError(`expected a numeric explanation value, received ${value.kind}`)
|
|
149
|
+
return value
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const bool = (value: Evaluated): boolean => {
|
|
153
|
+
if (value.kind !== 'bool') throw new TypeError(`expected a Boolean explanation value, received ${value.kind}`)
|
|
154
|
+
return value.value
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const add = (left: NumberValue, right: NumberValue): NumberValue => normalize(
|
|
158
|
+
left.numerator * right.denominator + right.numerator * left.denominator,
|
|
159
|
+
left.denominator * right.denominator
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const multiply = (left: NumberValue, right: NumberValue): NumberValue =>
|
|
163
|
+
normalize(left.numerator * right.numerator, left.denominator * right.denominator)
|
|
164
|
+
|
|
165
|
+
const compare = (left: NumberValue, right: NumberValue): -1 | 0 | 1 => {
|
|
166
|
+
const difference = left.numerator * right.denominator - right.numerator * left.denominator
|
|
167
|
+
return difference < 0n ? -1 : difference > 0n ? 1 : 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const equal = (left: Evaluated, right: Evaluated): boolean => {
|
|
171
|
+
if (left.kind === 'number' && right.kind === 'number') return compare(left, right) === 0
|
|
172
|
+
if (left.kind !== right.kind) return false
|
|
173
|
+
if (left.kind === 'bool' && right.kind === 'bool') return left.value === right.value
|
|
174
|
+
if (left.kind === 'enum' && right.kind === 'enum') return left.domain === right.domain && left.value === right.value
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const assigned = (value: Value): Evaluated => {
|
|
179
|
+
switch (value.sort) {
|
|
180
|
+
case 'bool': return { kind: 'bool', value: value.value }
|
|
181
|
+
case 'int': return number({ numerator: value.value, denominator: '1' })
|
|
182
|
+
case 'real': return number({ numerator: value.numerator, denominator: value.denominator })
|
|
183
|
+
case 'enum': return { kind: 'enum', domain: value.domain, value: value.value }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const evaluate = (expression: Expression, assignment: Assignment): Evaluated => {
|
|
188
|
+
const run = (value: Expression): Evaluated => evaluate(value, assignment)
|
|
189
|
+
switch (expression.kind) {
|
|
190
|
+
case 'literal':
|
|
191
|
+
switch (expression.sort) {
|
|
192
|
+
case 'bool': return { kind: 'bool', value: expression.value }
|
|
193
|
+
case 'int': return number({ numerator: String(Exact.integer(expression.value)), denominator: '1' })
|
|
194
|
+
case 'real': return number(expression.value)
|
|
195
|
+
case 'enum': return { kind: 'enum', domain: expression.domain, value: expression.value }
|
|
196
|
+
}
|
|
197
|
+
case 'variable': {
|
|
198
|
+
const value = assignment[expression.id]
|
|
199
|
+
if (value === undefined) throw new TypeError(`assignment omits ${JSON.stringify(expression.id)}`)
|
|
200
|
+
return assigned(value)
|
|
201
|
+
}
|
|
202
|
+
case 'not': return { kind: 'bool', value: !bool(run(expression.value)) }
|
|
203
|
+
case 'and': return { kind: 'bool', value: expression.operands.every(value => bool(run(value))) }
|
|
204
|
+
case 'or': return { kind: 'bool', value: expression.operands.some(value => bool(run(value))) }
|
|
205
|
+
case 'implies': return { kind: 'bool', value: !bool(run(expression.left)) || bool(run(expression.right)) }
|
|
206
|
+
case 'eq': return { kind: 'bool', value: equal(run(expression.left), run(expression.right)) }
|
|
207
|
+
case 'neq': return { kind: 'bool', value: !equal(run(expression.left), run(expression.right)) }
|
|
208
|
+
case 'lt': return { kind: 'bool', value: compare(numeric(run(expression.left)), numeric(run(expression.right))) < 0 }
|
|
209
|
+
case 'lte': return { kind: 'bool', value: compare(numeric(run(expression.left)), numeric(run(expression.right))) <= 0 }
|
|
210
|
+
case 'gt': return { kind: 'bool', value: compare(numeric(run(expression.left)), numeric(run(expression.right))) > 0 }
|
|
211
|
+
case 'gte': return { kind: 'bool', value: compare(numeric(run(expression.left)), numeric(run(expression.right))) >= 0 }
|
|
212
|
+
case 'add': return expression.operands.map(run).map(numeric).reduce(add)
|
|
213
|
+
case 'multiply': return expression.operands.map(run).map(numeric).reduce(multiply)
|
|
214
|
+
case 'subtract': {
|
|
215
|
+
const left = numeric(run(expression.left))
|
|
216
|
+
const right = numeric(run(expression.right))
|
|
217
|
+
return add(left, normalize(-right.numerator, right.denominator))
|
|
218
|
+
}
|
|
219
|
+
case 'divide': {
|
|
220
|
+
const left = numeric(run(expression.left))
|
|
221
|
+
const right = numeric(run(expression.right))
|
|
222
|
+
return normalize(left.numerator * right.denominator, left.denominator * right.numerator)
|
|
223
|
+
}
|
|
224
|
+
case 'negate': {
|
|
225
|
+
const value = numeric(run(expression.value))
|
|
226
|
+
return normalize(-value.numerator, value.denominator)
|
|
227
|
+
}
|
|
228
|
+
case 'if': return bool(run(expression.condition)) ? run(expression.then) : run(expression.else)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sorted = (values: readonly string[] | undefined): readonly string[] =>
|
|
233
|
+
[...new Set(values ?? [])].sort()
|
|
234
|
+
|
|
235
|
+
const element = (value: { readonly id: string } & Provenance): Element => ({
|
|
236
|
+
id: value.id,
|
|
237
|
+
...(value.description === undefined ? {} : { description: value.description }),
|
|
238
|
+
...(value.declaration === undefined ? {} : { declaration: value.declaration }),
|
|
239
|
+
evidenceRowIds: sorted(value.evidenceRowIds),
|
|
240
|
+
scenarioInputIds: sorted(value.scenarioInputIds)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const constraint = (value: HardConstraint, assignment: Assignment): Constraint => {
|
|
244
|
+
let evaluation: Constraint['evaluation'] = 'indeterminate'
|
|
245
|
+
try {
|
|
246
|
+
evaluation = bool(evaluate(value.expression, assignment)) ? 'satisfied' : 'violated'
|
|
247
|
+
} catch { /* Keep the solver result usable when a backend uses totalized arithmetic. */ }
|
|
248
|
+
return { ...element(value), evaluation }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const softConstraint = (value: SoftConstraint, assignment: Assignment): SoftConstraintResult => ({
|
|
252
|
+
...element(value),
|
|
253
|
+
evaluation: (() => {
|
|
254
|
+
try {
|
|
255
|
+
return bool(evaluate(value.expression, assignment)) ? 'accepted' : 'violated'
|
|
256
|
+
} catch {
|
|
257
|
+
return 'indeterminate'
|
|
258
|
+
}
|
|
259
|
+
})(),
|
|
260
|
+
weight: Exact.rational(value.weight)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const assignments = (model: Model, assignment: Assignment): readonly AssignmentValue[] => {
|
|
264
|
+
const variables = new Map(model.variables.map(variable => [variable.id, variable]))
|
|
265
|
+
return Object.entries(assignment)
|
|
266
|
+
.sort(([left], [right]) => compareText(left, right))
|
|
267
|
+
.map(([id, value]) => {
|
|
268
|
+
const variable: Variable | undefined = variables.get(id)
|
|
269
|
+
return { ...element(variable ?? { id }), declared: variable !== undefined, value }
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const feasible = (model: Model, assignment: Assignment): Feasible => ({
|
|
274
|
+
assignments: assignments(model, assignment),
|
|
275
|
+
hardConstraints: model.constraints.map(value => constraint(value, assignment)),
|
|
276
|
+
softConstraints: (model.softConstraints ?? []).map(value => softConstraint(value, assignment))
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const objective = (declaration: Objective | undefined, result: ObjectiveValue): ObjectiveResult => ({
|
|
280
|
+
...element(declaration ?? { id: result.objectiveId }),
|
|
281
|
+
declared: declaration !== undefined,
|
|
282
|
+
direction: declaration?.direction ?? 'unknown',
|
|
283
|
+
value: result.value,
|
|
284
|
+
...(result.bound === undefined ? {} : { bound: result.bound })
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const outcome = (model: Model, result: Result): Outcome => {
|
|
288
|
+
switch (result.status) {
|
|
289
|
+
case 'satisfied': return { status: result.status, ...feasible(model, result.assignment) }
|
|
290
|
+
case 'optimal': {
|
|
291
|
+
const declarations = new Map((model.objectives ?? []).map(value => [value.id, value]))
|
|
292
|
+
return {
|
|
293
|
+
status: result.status,
|
|
294
|
+
...feasible(model, result.assignment),
|
|
295
|
+
objectives: result.objectives.map(value => objective(declarations.get(value.objectiveId), value)),
|
|
296
|
+
optimalityProved: result.optimalityProved
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
case 'unsatisfied': {
|
|
300
|
+
const declarations = new Map(model.constraints.map(value => [value.id, value]))
|
|
301
|
+
return {
|
|
302
|
+
status: result.status,
|
|
303
|
+
...(result.core === undefined ? {} : {
|
|
304
|
+
core: result.core.map(id => {
|
|
305
|
+
const declaration = declarations.get(id)
|
|
306
|
+
return { ...element(declaration ?? { id }), declared: declaration !== undefined }
|
|
307
|
+
})
|
|
308
|
+
}),
|
|
309
|
+
coreMinimal: false,
|
|
310
|
+
infeasibilityProved: result.infeasibilityProved
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
case 'unknown': return { status: result.status, reason: result.reason }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Build stable JSON data without mutating the model, scenario, or CAVE store. */
|
|
318
|
+
export const report = (model: Model, result: Result, limits: Limits, context: Context = {}): Report => {
|
|
319
|
+
const modelDigest = Canonical.digest(model)
|
|
320
|
+
if (context.modelDigest !== undefined && context.modelDigest !== modelDigest) {
|
|
321
|
+
throw new TypeError(`scenario model digest ${context.modelDigest} does not match ${modelDigest}`)
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
schema,
|
|
325
|
+
run: {
|
|
326
|
+
modelDigest,
|
|
327
|
+
backend: result.backend,
|
|
328
|
+
elapsedMs: result.elapsedMs,
|
|
329
|
+
limits,
|
|
330
|
+
diagnostics: result.diagnostics,
|
|
331
|
+
...(context.scenario === undefined ? {} : { scenario: context.scenario }),
|
|
332
|
+
...(context.snapshot === undefined ? {} : { snapshot: context.snapshot }),
|
|
333
|
+
inputs: [...(context.inputs ?? [])].sort((left, right) => compareText(left.id, right.id))
|
|
334
|
+
},
|
|
335
|
+
outcome: outcome(model, result)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const evidence = (value: Element): string => {
|
|
340
|
+
const parts = [
|
|
341
|
+
value.declaration === undefined ? undefined : `${value.declaration.uri}${value.declaration.line === undefined ? '' : `:${value.declaration.line}${value.declaration.column === undefined ? '' : `:${value.declaration.column}`}`}`,
|
|
342
|
+
value.evidenceRowIds.length === 0 ? undefined : `rows ${value.evidenceRowIds.join(', ')}`,
|
|
343
|
+
value.scenarioInputIds.length === 0 ? undefined : `inputs ${value.scenarioInputIds.join(', ')}`
|
|
344
|
+
].filter((part): part is string => part !== undefined)
|
|
345
|
+
return parts.length === 0 ? '' : ` — ${parts.join('; ')}`
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const valueText = (value: Value): string => {
|
|
349
|
+
switch (value.sort) {
|
|
350
|
+
case 'bool': return String(value.value)
|
|
351
|
+
case 'int': return value.value
|
|
352
|
+
case 'real': return `${value.numerator}/${value.denominator}`
|
|
353
|
+
case 'enum': return value.value
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Render the same report as concise, deterministic plain text. */
|
|
358
|
+
export const render = (value: Report): string => {
|
|
359
|
+
const lines = [
|
|
360
|
+
`Solver result: ${value.outcome.status}`,
|
|
361
|
+
`Model: ${value.run.modelDigest}`,
|
|
362
|
+
`Backend: ${value.run.backend.name} ${value.run.backend.version}`,
|
|
363
|
+
`Elapsed: ${value.run.elapsedMs} ms`
|
|
364
|
+
]
|
|
365
|
+
if (value.run.snapshot !== undefined) {
|
|
366
|
+
lines.push(`Snapshot: transaction ${value.run.snapshot.transactionTime ?? 'empty'}${value.run.snapshot.validTime === undefined ? '' : `, valid ${value.run.snapshot.validTime}`}`)
|
|
367
|
+
}
|
|
368
|
+
if (value.run.scenario !== undefined) lines.push(`Scenario: ${value.run.scenario.id} (${value.run.scenario.inputDigest})`)
|
|
369
|
+
for (const input of value.run.inputs) {
|
|
370
|
+
lines.push(`Input ${input.id}: ${JSON.stringify(input.value)}${input.query === undefined ? '' : ` via ${input.query}`}`)
|
|
371
|
+
}
|
|
372
|
+
switch (value.outcome.status) {
|
|
373
|
+
case 'satisfied':
|
|
374
|
+
case 'optimal':
|
|
375
|
+
for (const assignment of value.outcome.assignments) {
|
|
376
|
+
lines.push(`Assignment ${assignment.id} = ${valueText(assignment.value)}${assignment.declared ? '' : ' (undeclared backend ID)'}${evidence(assignment)}`)
|
|
377
|
+
}
|
|
378
|
+
for (const constraint of value.outcome.hardConstraints) {
|
|
379
|
+
lines.push(`Hard constraint ${constraint.id}: ${constraint.evaluation}${evidence(constraint)}`)
|
|
380
|
+
}
|
|
381
|
+
for (const constraint of value.outcome.softConstraints) {
|
|
382
|
+
lines.push(`Soft constraint ${constraint.id}: ${constraint.evaluation}, weight ${constraint.weight.numerator}/${constraint.weight.denominator}${evidence(constraint)}`)
|
|
383
|
+
}
|
|
384
|
+
if (value.outcome.status === 'optimal') {
|
|
385
|
+
for (const objective of value.outcome.objectives) {
|
|
386
|
+
lines.push(`Objective ${objective.id} (${objective.direction}) = ${valueText(objective.value)}${objective.declared ? '' : ' (undeclared backend ID)'}${evidence(objective)}`)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
break
|
|
390
|
+
case 'unsatisfied':
|
|
391
|
+
lines.push('Infeasibility proved. Unsatisfiable cores are not necessarily minimal.')
|
|
392
|
+
for (const constraint of value.outcome.core ?? []) {
|
|
393
|
+
lines.push(`Core constraint ${constraint.id}${constraint.declared ? '' : ' (undeclared backend ID)'}${evidence(constraint)}`)
|
|
394
|
+
}
|
|
395
|
+
break
|
|
396
|
+
case 'unknown':
|
|
397
|
+
lines.push(`Unknown: ${value.outcome.reason.kind} — ${value.outcome.reason.message}`)
|
|
398
|
+
break
|
|
399
|
+
}
|
|
400
|
+
return `${lines.join('\n')}\n`
|
|
401
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Solver-neutral formal reasoning contracts for CAVE.
|
|
2
|
+
* Solver-neutral formal reasoning contracts and bounded workflows for CAVE.
|
|
3
3
|
*
|
|
4
4
|
* This package contains no solver implementation and never imports Z3 or
|
|
5
5
|
* another backend. Adapters receive only validated portable model data.
|
|
@@ -9,7 +9,9 @@ export * as Adapter from './adapter.ts'
|
|
|
9
9
|
export * as Canonical from './canonical.ts'
|
|
10
10
|
export * as Capability from './capability.ts'
|
|
11
11
|
export * as Exact from './exact.ts'
|
|
12
|
+
export * as Explain from './explain.ts'
|
|
12
13
|
export * as Linear from './linear.ts'
|
|
13
14
|
export * as Model from './model.ts'
|
|
14
15
|
export * as Solve from './solve.ts'
|
|
15
16
|
export * as Validate from './validate.ts'
|
|
17
|
+
export * as Workflow from './workflow.ts'
|
package/src/model.ts
CHANGED
|
@@ -13,13 +13,31 @@ export type EnumDomain = {
|
|
|
13
13
|
readonly id: string
|
|
14
14
|
readonly values: readonly string[]
|
|
15
15
|
readonly description?: string
|
|
16
|
+
readonly declaration?: Declaration
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export type
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
export type Declaration = {
|
|
20
|
+
/** Stable model source. Prefer a repository-relative URI over a machine-local path. */
|
|
21
|
+
readonly uri: string
|
|
22
|
+
readonly line?: number
|
|
23
|
+
readonly column?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Provenance = {
|
|
27
|
+
readonly description?: string
|
|
28
|
+
readonly declaration?: Declaration
|
|
29
|
+
readonly evidenceRowIds?: readonly string[]
|
|
30
|
+
readonly scenarioInputIds?: readonly string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type VariableDeclaration = { readonly id: string } & Provenance
|
|
34
|
+
|
|
35
|
+
export type Variable = VariableDeclaration & (
|
|
36
|
+
| { readonly sort: 'bool' }
|
|
37
|
+
| { readonly sort: 'int', readonly min: Integer, readonly max: Integer }
|
|
38
|
+
| { readonly sort: 'real', readonly min?: Rational, readonly max?: Rational }
|
|
39
|
+
| { readonly sort: 'enum', readonly domain: string }
|
|
40
|
+
)
|
|
23
41
|
|
|
24
42
|
export type Literal =
|
|
25
43
|
| { readonly kind: 'literal', readonly sort: 'bool', readonly value: boolean }
|
|
@@ -39,11 +57,9 @@ export type Expression =
|
|
|
39
57
|
| { readonly kind: 'negate', readonly value: Expression }
|
|
40
58
|
| { readonly kind: 'if', readonly condition: Expression, readonly then: Expression, readonly else: Expression }
|
|
41
59
|
|
|
42
|
-
export type HardConstraint = {
|
|
60
|
+
export type HardConstraint = Provenance & {
|
|
43
61
|
readonly id: string
|
|
44
62
|
readonly expression: Expression
|
|
45
|
-
readonly description?: string
|
|
46
|
-
readonly evidenceRowIds?: readonly string[]
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
export type SoftConstraint = HardConstraint & {
|
|
@@ -51,12 +67,10 @@ export type SoftConstraint = HardConstraint & {
|
|
|
51
67
|
readonly weight: Rational
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
export type Objective = {
|
|
70
|
+
export type Objective = Provenance & {
|
|
55
71
|
readonly id: string
|
|
56
72
|
readonly direction: 'minimize' | 'maximize'
|
|
57
73
|
readonly expression: Expression
|
|
58
|
-
readonly description?: string
|
|
59
|
-
readonly evidenceRowIds?: readonly string[]
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
export type Model = {
|
package/src/solve.ts
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import type { Options, Result, SolverAdapter } from './adapter.ts'
|
|
2
2
|
import * as Capability from './capability.ts'
|
|
3
|
+
import * as Explain from './explain.ts'
|
|
3
4
|
import type { Model } from './model.ts'
|
|
4
5
|
import * as Validate from './validate.ts'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const prepared = (adapter: SolverAdapter, model: Model, options: Options): {
|
|
8
|
+
readonly limits: ReturnType<typeof Validate.mergeLimits>
|
|
9
|
+
readonly run: () => Promise<Result>
|
|
10
|
+
} => {
|
|
8
11
|
const limits = Validate.mergeLimits(options.limits)
|
|
9
12
|
Validate.model(model, limits)
|
|
10
|
-
const
|
|
13
|
+
const unsatCore = options.unsatCore ?? false
|
|
14
|
+
const missing = Capability.missing(adapter, model, unsatCore)
|
|
11
15
|
if (missing.length > 0) throw new Capability.UnsupportedModelError(adapter.backend.name, missing)
|
|
12
|
-
return adapter.solve(model, { limits, unsatCore
|
|
16
|
+
return { limits, run: () => adapter.solve(model, { limits, unsatCore }) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validates and negotiates a model before crossing the adapter boundary. */
|
|
20
|
+
export const run = async (adapter: SolverAdapter, model: Model, options: Options = {}): Promise<Result> => {
|
|
21
|
+
return prepared(adapter, model, options).run()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Solve and return a traceable JSON explanation envelope. */
|
|
25
|
+
export const runWithExplanation = async (
|
|
26
|
+
adapter: SolverAdapter,
|
|
27
|
+
model: Model,
|
|
28
|
+
options: Options = {},
|
|
29
|
+
context: Explain.Context = {}
|
|
30
|
+
): Promise<Explain.Report> => {
|
|
31
|
+
const solve = prepared(adapter, model, options)
|
|
32
|
+
return Explain.report(model, await solve.run(), solve.limits, context)
|
|
13
33
|
}
|
package/src/validate.ts
CHANGED
|
@@ -62,6 +62,32 @@ const requireId = (id: string, path: string, problems: string[]): void => {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
const provenance = (
|
|
66
|
+
value: { readonly declaration?: { readonly uri: string, readonly line?: number, readonly column?: number }, readonly evidenceRowIds?: readonly string[], readonly scenarioInputIds?: readonly string[] },
|
|
67
|
+
path: string,
|
|
68
|
+
problems: string[]
|
|
69
|
+
): void => {
|
|
70
|
+
if (value.declaration !== undefined) {
|
|
71
|
+
if (value.declaration.uri.trim() === '') problems.push(`${path}.declaration.uri must not be empty`)
|
|
72
|
+
for (const position of ['line', 'column'] as const) {
|
|
73
|
+
const at = value.declaration[position]
|
|
74
|
+
if (at !== undefined && (!Number.isSafeInteger(at) || at <= 0)) {
|
|
75
|
+
problems.push(`${path}.declaration.${position} must be a positive safe integer`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [name, ids] of [
|
|
80
|
+
['evidenceRowIds', value.evidenceRowIds],
|
|
81
|
+
['scenarioInputIds', value.scenarioInputIds]
|
|
82
|
+
] as const) {
|
|
83
|
+
if (ids === undefined) continue
|
|
84
|
+
if (new Set(ids).size !== ids.length) problems.push(`${path}.${name} contains duplicate identifiers`)
|
|
85
|
+
ids.forEach((id, index) => {
|
|
86
|
+
if (id.trim() === '') problems.push(`${path}.${name}[${index}] must not be empty`)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
const duplicates = (ids: readonly string[], path: string, problems: string[]): void => {
|
|
66
92
|
const seen = new Set<string>()
|
|
67
93
|
for (const id of ids) {
|
|
@@ -229,6 +255,7 @@ export const model = (input: Model, limitInput: Partial<Limits> = {}): Stats =>
|
|
|
229
255
|
duplicates(enumDeclarations.map(domain => domain.id), 'enums', problems)
|
|
230
256
|
enumDeclarations.forEach((domain, index) => {
|
|
231
257
|
requireId(domain.id, `enums[${index}].id`, problems)
|
|
258
|
+
provenance(domain, `enums[${index}]`, problems)
|
|
232
259
|
if (domain.values.length === 0) problems.push(`enums[${index}] must contain at least one value`)
|
|
233
260
|
if (new Set(domain.values).size !== domain.values.length) problems.push(`enums[${index}] contains duplicate values`)
|
|
234
261
|
domains.set(domain.id, domain.values)
|
|
@@ -238,6 +265,7 @@ export const model = (input: Model, limitInput: Partial<Limits> = {}): Stats =>
|
|
|
238
265
|
duplicates(input.variables.map(variable => variable.id), 'variables', problems)
|
|
239
266
|
input.variables.forEach((variable, index) => {
|
|
240
267
|
requireId(variable.id, `variables[${index}].id`, problems)
|
|
268
|
+
provenance(variable, `variables[${index}]`, problems)
|
|
241
269
|
variables.set(variable.id, variable)
|
|
242
270
|
if (variable.sort === 'enum' && !domains.has(variable.domain)) {
|
|
243
271
|
problems.push(`variables[${index}] references unknown enum domain ${JSON.stringify(variable.domain)}`)
|
|
@@ -271,11 +299,13 @@ export const model = (input: Model, limitInput: Partial<Limits> = {}): Stats =>
|
|
|
271
299
|
const walk: Walk = { nodes: 0, depth: 0 }
|
|
272
300
|
input.constraints.forEach((constraint, index) => {
|
|
273
301
|
requireId(constraint.id, `constraints[${index}].id`, problems)
|
|
302
|
+
provenance(constraint, `constraints[${index}]`, problems)
|
|
274
303
|
const sort = infer(constraint.expression, `constraints[${index}].expression`, variables, domains, problems, walk, 1)
|
|
275
304
|
if (sort.kind !== 'bool') problems.push(`constraints[${index}].expression must be boolean, received ${sort.kind}`)
|
|
276
305
|
})
|
|
277
306
|
;(input.softConstraints ?? []).forEach((constraint, index) => {
|
|
278
307
|
requireId(constraint.id, `softConstraints[${index}].id`, problems)
|
|
308
|
+
provenance(constraint, `softConstraints[${index}]`, problems)
|
|
279
309
|
const sort = infer(constraint.expression, `softConstraints[${index}].expression`, variables, domains, problems, walk, 1)
|
|
280
310
|
if (sort.kind !== 'bool') problems.push(`softConstraints[${index}].expression must be boolean, received ${sort.kind}`)
|
|
281
311
|
pushExactProblem(problems, `softConstraints[${index}].weight`, () => {
|
|
@@ -284,6 +314,7 @@ export const model = (input: Model, limitInput: Partial<Limits> = {}): Stats =>
|
|
|
284
314
|
})
|
|
285
315
|
;(input.objectives ?? []).forEach((objective, index) => {
|
|
286
316
|
requireId(objective.id, `objectives[${index}].id`, problems)
|
|
317
|
+
provenance(objective, `objectives[${index}]`, problems)
|
|
287
318
|
const sort = infer(objective.expression, `objectives[${index}].expression`, variables, domains, problems, walk, 1)
|
|
288
319
|
if (!numeric(sort)) problems.push(`objectives[${index}].expression must be numeric, received ${sort.kind}`)
|
|
289
320
|
})
|