@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/workflow.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import type { Limits, Options, Result, SolverAdapter } from './adapter.ts'
|
|
2
|
+
import * as Canonical from './canonical.ts'
|
|
3
|
+
import * as Exact from './exact.ts'
|
|
4
|
+
import * as Explain from './explain.ts'
|
|
5
|
+
import type {
|
|
6
|
+
Assignment,
|
|
7
|
+
Expression,
|
|
8
|
+
HardConstraint,
|
|
9
|
+
Model,
|
|
10
|
+
Objective,
|
|
11
|
+
Value,
|
|
12
|
+
Variable
|
|
13
|
+
} from './model.ts'
|
|
14
|
+
import * as Solve from './solve.ts'
|
|
15
|
+
import * as Validate from './validate.ts'
|
|
16
|
+
|
|
17
|
+
export const schema = 'cave.solver/workflow@1' as const
|
|
18
|
+
|
|
19
|
+
const syntheticPrefix = 'cave.workflow/'
|
|
20
|
+
const defaultMaxSensitivityRuns = 64
|
|
21
|
+
const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
|
|
22
|
+
|
|
23
|
+
export class WorkflowValidationError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(`invalid solver workflow: ${message}`)
|
|
26
|
+
this.name = 'WorkflowValidationError'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Domain =
|
|
31
|
+
| { readonly variableId: string, readonly kind: 'finite', readonly sort: 'bool', readonly values: readonly boolean[] }
|
|
32
|
+
| { readonly variableId: string, readonly kind: 'finite', readonly sort: 'enum', readonly domain: string, readonly values: readonly string[] }
|
|
33
|
+
| { readonly variableId: string, readonly kind: 'interval', readonly sort: 'int', readonly min: string, readonly max: string }
|
|
34
|
+
| {
|
|
35
|
+
readonly variableId: string
|
|
36
|
+
readonly kind: 'interval'
|
|
37
|
+
readonly sort: 'real'
|
|
38
|
+
readonly min: { readonly numerator: string, readonly denominator: string }
|
|
39
|
+
readonly max: { readonly numerator: string, readonly denominator: string }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type Scope = {
|
|
43
|
+
readonly domains: readonly Domain[]
|
|
44
|
+
readonly assumptions: readonly string[]
|
|
45
|
+
readonly theories: readonly ('booleans' | 'bounded-integers' | 'exact-rationals' | 'finite-enums')[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TieBreak = {
|
|
49
|
+
readonly variableId: string
|
|
50
|
+
readonly preference: 'false-first' | 'smallest-first' | 'lexical-first'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type Operation =
|
|
54
|
+
| { readonly kind: 'feasibility' }
|
|
55
|
+
| { readonly kind: 'optimization' }
|
|
56
|
+
| { readonly kind: 'counterexample', readonly invariantId: string, readonly verdict: 'counterexample' | 'holds-within-scope' | 'unknown' }
|
|
57
|
+
|
|
58
|
+
export type Report = {
|
|
59
|
+
readonly schema: typeof schema
|
|
60
|
+
readonly operation: Operation
|
|
61
|
+
readonly modelDigest: string
|
|
62
|
+
readonly scope: Scope
|
|
63
|
+
readonly tieBreak: readonly TieBreak[]
|
|
64
|
+
readonly explanation: Explain.Report
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type Binding = {
|
|
68
|
+
readonly variableId: string
|
|
69
|
+
readonly value: Value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type SensitivityRequest = {
|
|
73
|
+
readonly variableId: string
|
|
74
|
+
readonly samples: readonly Value[]
|
|
75
|
+
/** Assignments to keep constant while the selected variable changes. */
|
|
76
|
+
readonly fixed?: readonly Binding[]
|
|
77
|
+
/** Variables whose assignments define a preferred-result transition. */
|
|
78
|
+
readonly observe?: readonly string[]
|
|
79
|
+
readonly operation?: 'feasibility' | 'optimization'
|
|
80
|
+
readonly maxRuns?: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type SensitivityPoint = {
|
|
84
|
+
readonly index: number
|
|
85
|
+
readonly value: Value
|
|
86
|
+
readonly report: Report
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type Transition = {
|
|
90
|
+
readonly afterIndex: number
|
|
91
|
+
readonly from: Value
|
|
92
|
+
readonly to: Value
|
|
93
|
+
readonly fromSignature: string
|
|
94
|
+
readonly toSignature: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type UnknownRegion = {
|
|
98
|
+
readonly startIndex: number
|
|
99
|
+
readonly endIndex: number
|
|
100
|
+
readonly from: Value
|
|
101
|
+
readonly to: Value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type SensitivityReport = {
|
|
105
|
+
readonly schema: typeof schema
|
|
106
|
+
readonly operation: 'sensitivity'
|
|
107
|
+
readonly modelDigest: string
|
|
108
|
+
readonly variableId: string
|
|
109
|
+
readonly fixed: readonly Binding[]
|
|
110
|
+
readonly observe: readonly string[]
|
|
111
|
+
readonly points: readonly SensitivityPoint[]
|
|
112
|
+
readonly transitions: readonly Transition[]
|
|
113
|
+
readonly unknownRegions: readonly UnknownRegion[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const maxSensitivityRuns = defaultMaxSensitivityRuns
|
|
117
|
+
|
|
118
|
+
const boundedScope = (model: Model, limits: Partial<Limits> = {}): Scope => {
|
|
119
|
+
Validate.model(model, limits)
|
|
120
|
+
const domainsById = new Map((model.enums ?? []).map(domain => [domain.id, domain.values]))
|
|
121
|
+
const domains: Domain[] = model.variables
|
|
122
|
+
.map((variable): Domain => {
|
|
123
|
+
switch (variable.sort) {
|
|
124
|
+
case 'bool':
|
|
125
|
+
return { variableId: variable.id, kind: 'finite', sort: variable.sort, values: [false, true] }
|
|
126
|
+
case 'enum': {
|
|
127
|
+
const values = domainsById.get(variable.domain)
|
|
128
|
+
if (values === undefined) throw new WorkflowValidationError(`variable ${JSON.stringify(variable.id)} has no enum domain`)
|
|
129
|
+
return {
|
|
130
|
+
variableId: variable.id,
|
|
131
|
+
kind: 'finite',
|
|
132
|
+
sort: variable.sort,
|
|
133
|
+
domain: variable.domain,
|
|
134
|
+
values: [...values].sort(compareText)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
case 'int':
|
|
138
|
+
return {
|
|
139
|
+
variableId: variable.id,
|
|
140
|
+
kind: 'interval',
|
|
141
|
+
sort: variable.sort,
|
|
142
|
+
min: String(Exact.integer(variable.min)),
|
|
143
|
+
max: String(Exact.integer(variable.max))
|
|
144
|
+
}
|
|
145
|
+
case 'real':
|
|
146
|
+
if (variable.min === undefined || variable.max === undefined) {
|
|
147
|
+
throw new WorkflowValidationError(
|
|
148
|
+
`real variable ${JSON.stringify(variable.id)} needs explicit min and max bounds`
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
variableId: variable.id,
|
|
153
|
+
kind: 'interval',
|
|
154
|
+
sort: variable.sort,
|
|
155
|
+
min: Exact.rational(variable.min),
|
|
156
|
+
max: Exact.rational(variable.max)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
.sort((left, right) => compareText(left.variableId, right.variableId))
|
|
161
|
+
const sorts = new Set(model.variables.map(variable => variable.sort))
|
|
162
|
+
const theories: Scope['theories'][number][] = ['booleans']
|
|
163
|
+
if (sorts.has('int')) theories.push('bounded-integers')
|
|
164
|
+
if (sorts.has('real')) theories.push('exact-rationals')
|
|
165
|
+
if (sorts.has('enum')) theories.push('finite-enums')
|
|
166
|
+
return {
|
|
167
|
+
domains,
|
|
168
|
+
assumptions: model.constraints.map(constraint => constraint.id),
|
|
169
|
+
theories
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const variableMap = (model: Model): ReadonlyMap<string, Variable> =>
|
|
174
|
+
new Map(model.variables.map(variable => [variable.id, variable]))
|
|
175
|
+
|
|
176
|
+
const normalizedValue = (variable: Variable, value: Value, domains: ReadonlyMap<string, readonly string[]>): Value => {
|
|
177
|
+
if (variable.sort !== value.sort) {
|
|
178
|
+
throw new WorkflowValidationError(
|
|
179
|
+
`variable ${JSON.stringify(variable.id)} is ${variable.sort}, received ${value.sort}`
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
switch (value.sort) {
|
|
183
|
+
case 'bool': return value
|
|
184
|
+
case 'int': {
|
|
185
|
+
const normalized = String(Exact.integer(value.value))
|
|
186
|
+
if (Exact.compare(normalized, String(Exact.integer((variable as Variable & { sort: 'int' }).min))) < 0 ||
|
|
187
|
+
Exact.compare(normalized, String(Exact.integer((variable as Variable & { sort: 'int' }).max))) > 0) {
|
|
188
|
+
throw new WorkflowValidationError(`value ${normalized} is outside ${JSON.stringify(variable.id)} bounds`)
|
|
189
|
+
}
|
|
190
|
+
return { sort: value.sort, value: normalized }
|
|
191
|
+
}
|
|
192
|
+
case 'real': {
|
|
193
|
+
const normalized = Exact.rational(value)
|
|
194
|
+
const real = variable as Variable & { sort: 'real' }
|
|
195
|
+
if ((real.min !== undefined && Exact.compare(normalized, real.min) < 0) ||
|
|
196
|
+
(real.max !== undefined && Exact.compare(normalized, real.max) > 0)) {
|
|
197
|
+
throw new WorkflowValidationError(`value ${normalized.numerator}/${normalized.denominator} is outside ${JSON.stringify(variable.id)} bounds`)
|
|
198
|
+
}
|
|
199
|
+
return { sort: value.sort, ...normalized }
|
|
200
|
+
}
|
|
201
|
+
case 'enum': {
|
|
202
|
+
const enumeration = variable as Variable & { sort: 'enum' }
|
|
203
|
+
if (value.domain !== enumeration.domain) {
|
|
204
|
+
throw new WorkflowValidationError(
|
|
205
|
+
`variable ${JSON.stringify(variable.id)} uses enum ${JSON.stringify(enumeration.domain)}, received ${JSON.stringify(value.domain)}`
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
if (!(domains.get(value.domain) ?? []).includes(value.value)) {
|
|
209
|
+
throw new WorkflowValidationError(`value ${JSON.stringify(value.value)} is outside enum ${JSON.stringify(value.domain)}`)
|
|
210
|
+
}
|
|
211
|
+
return value
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const literal = (value: Value): Expression => {
|
|
217
|
+
switch (value.sort) {
|
|
218
|
+
case 'bool': return { kind: 'literal', sort: value.sort, value: value.value }
|
|
219
|
+
case 'int': return { kind: 'literal', sort: value.sort, value: value.value }
|
|
220
|
+
case 'real': return { kind: 'literal', sort: value.sort, value: { numerator: value.numerator, denominator: value.denominator } }
|
|
221
|
+
case 'enum': return { kind: 'literal', sort: value.sort, domain: value.domain, value: value.value }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const freshId = (used: Set<string>, name: string): string => {
|
|
226
|
+
let id = `${syntheticPrefix}${name}`
|
|
227
|
+
let suffix = 2
|
|
228
|
+
while (used.has(id)) id = `${syntheticPrefix}${name}-${suffix++}`
|
|
229
|
+
used.add(id)
|
|
230
|
+
return id
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const enumRank = (variable: Variable & { readonly sort: 'enum' }, values: readonly string[]): Expression => {
|
|
234
|
+
const sorted = [...values].sort(compareText)
|
|
235
|
+
return sorted.slice(0, -1).reduceRight<Expression>(
|
|
236
|
+
(otherwise, value, index) => ({
|
|
237
|
+
kind: 'if',
|
|
238
|
+
condition: {
|
|
239
|
+
kind: 'eq',
|
|
240
|
+
left: { kind: 'variable', id: variable.id },
|
|
241
|
+
right: { kind: 'literal', sort: 'enum', domain: variable.domain, value }
|
|
242
|
+
},
|
|
243
|
+
then: { kind: 'literal', sort: 'int', value: index },
|
|
244
|
+
else: otherwise
|
|
245
|
+
}),
|
|
246
|
+
{ kind: 'literal', sort: 'int', value: Math.max(0, sorted.length - 1) }
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const tieBreak = (model: Model, used: Set<string>): { objectives: readonly Objective[], descriptions: readonly TieBreak[] } => {
|
|
251
|
+
const domains = new Map((model.enums ?? []).map(domain => [domain.id, domain.values]))
|
|
252
|
+
const ordered = [...model.variables].sort((left, right) => compareText(left.id, right.id))
|
|
253
|
+
return {
|
|
254
|
+
objectives: ordered.map(variable => ({
|
|
255
|
+
id: freshId(used, `tie/${variable.id}`),
|
|
256
|
+
direction: 'minimize',
|
|
257
|
+
expression: (() => {
|
|
258
|
+
switch (variable.sort) {
|
|
259
|
+
case 'bool': return {
|
|
260
|
+
kind: 'if',
|
|
261
|
+
condition: { kind: 'variable', id: variable.id },
|
|
262
|
+
then: { kind: 'literal', sort: 'int', value: 1 },
|
|
263
|
+
else: { kind: 'literal', sort: 'int', value: 0 }
|
|
264
|
+
}
|
|
265
|
+
case 'int':
|
|
266
|
+
case 'real': return { kind: 'variable', id: variable.id }
|
|
267
|
+
case 'enum': return enumRank(variable, domains.get(variable.domain) ?? [])
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
})),
|
|
271
|
+
descriptions: ordered.map(variable => ({
|
|
272
|
+
variableId: variable.id,
|
|
273
|
+
preference: variable.sort === 'bool' ? 'false-first' : variable.sort === 'enum' ? 'lexical-first' : 'smallest-first'
|
|
274
|
+
}))
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const softObjective = (model: Model, used: Set<string>): Objective | undefined => {
|
|
279
|
+
const expressions = (model.softConstraints ?? []).map((constraint): Expression => ({
|
|
280
|
+
kind: 'if',
|
|
281
|
+
condition: constraint.expression,
|
|
282
|
+
then: { kind: 'literal', sort: 'real', value: constraint.weight },
|
|
283
|
+
else: { kind: 'literal', sort: 'real', value: '0' }
|
|
284
|
+
}))
|
|
285
|
+
if (expressions.length === 0) return undefined
|
|
286
|
+
return {
|
|
287
|
+
id: freshId(used, 'soft-score'),
|
|
288
|
+
direction: 'maximize',
|
|
289
|
+
expression: expressions.length === 1 ? expressions[0]! : { kind: 'add', operands: expressions }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const preparedModel = (model: Model, kind: 'feasibility' | 'optimization'): {
|
|
294
|
+
readonly model: Model
|
|
295
|
+
readonly tieBreak: readonly TieBreak[]
|
|
296
|
+
readonly authoredObjectiveIds: ReadonlySet<string>
|
|
297
|
+
} => {
|
|
298
|
+
const used = new Set((model.objectives ?? []).map(objective => objective.id))
|
|
299
|
+
const tie = tieBreak(model, used)
|
|
300
|
+
const authoredObjectiveIds = new Set((model.objectives ?? []).map(objective => objective.id))
|
|
301
|
+
if (kind === 'feasibility') {
|
|
302
|
+
return {
|
|
303
|
+
model: { ...model, softConstraints: [], objectives: tie.objectives },
|
|
304
|
+
tieBreak: tie.descriptions,
|
|
305
|
+
authoredObjectiveIds
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if ((model.objectives?.length ?? 0) === 0 && (model.softConstraints?.length ?? 0) === 0) {
|
|
309
|
+
throw new WorkflowValidationError('optimization needs at least one objective or soft constraint')
|
|
310
|
+
}
|
|
311
|
+
const soft = softObjective(model, used)
|
|
312
|
+
return {
|
|
313
|
+
model: {
|
|
314
|
+
...model,
|
|
315
|
+
softConstraints: [],
|
|
316
|
+
objectives: [...(model.objectives ?? []), ...(soft === undefined ? [] : [soft]), ...tie.objectives]
|
|
317
|
+
},
|
|
318
|
+
tieBreak: tie.descriptions,
|
|
319
|
+
authoredObjectiveIds
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const feasibleResult = (result: Result): Result => result.status !== 'optimal' ? result : {
|
|
324
|
+
status: 'satisfied',
|
|
325
|
+
assignment: result.assignment,
|
|
326
|
+
backend: result.backend,
|
|
327
|
+
diagnostics: result.diagnostics,
|
|
328
|
+
elapsedMs: result.elapsedMs
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const optimizedResult = (result: Result, authoredObjectiveIds: ReadonlySet<string>): Result => {
|
|
332
|
+
if (result.status === 'satisfied') {
|
|
333
|
+
return {
|
|
334
|
+
status: 'unknown',
|
|
335
|
+
reason: { kind: 'indeterminate', message: 'backend returned a feasible assignment without proving optimality' },
|
|
336
|
+
backend: result.backend,
|
|
337
|
+
diagnostics: result.diagnostics,
|
|
338
|
+
elapsedMs: result.elapsedMs
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (result.status !== 'optimal') return result
|
|
342
|
+
return {
|
|
343
|
+
...result,
|
|
344
|
+
objectives: result.objectives.filter(objective => authoredObjectiveIds.has(objective.objectiveId))
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const run = async (
|
|
349
|
+
adapter: SolverAdapter,
|
|
350
|
+
sourceModel: Model,
|
|
351
|
+
solveModel: Model,
|
|
352
|
+
kind: 'feasibility' | 'optimization',
|
|
353
|
+
options: Options,
|
|
354
|
+
context: Explain.Context
|
|
355
|
+
): Promise<{ readonly explanation: Explain.Report, readonly tieBreak: readonly TieBreak[] }> => {
|
|
356
|
+
const limits = Validate.mergeLimits(options.limits)
|
|
357
|
+
const prepared = preparedModel(solveModel, kind)
|
|
358
|
+
const result = await Solve.run(adapter, prepared.model, {
|
|
359
|
+
limits,
|
|
360
|
+
unsatCore: options.unsatCore ?? (kind === 'feasibility')
|
|
361
|
+
})
|
|
362
|
+
const normalized = kind === 'feasibility'
|
|
363
|
+
? feasibleResult(result)
|
|
364
|
+
: optimizedResult(result, prepared.authoredObjectiveIds)
|
|
365
|
+
return {
|
|
366
|
+
explanation: Explain.report(sourceModel, normalized, limits, context),
|
|
367
|
+
tieBreak: prepared.tieBreak
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const report = (
|
|
372
|
+
model: Model,
|
|
373
|
+
operation: Operation,
|
|
374
|
+
scope: Scope,
|
|
375
|
+
tie: readonly TieBreak[],
|
|
376
|
+
explanation: Explain.Report
|
|
377
|
+
): Report => ({
|
|
378
|
+
schema,
|
|
379
|
+
operation,
|
|
380
|
+
modelDigest: Canonical.digest(model),
|
|
381
|
+
scope,
|
|
382
|
+
tieBreak: tie,
|
|
383
|
+
explanation
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
export const feasibility = async (
|
|
387
|
+
adapter: SolverAdapter,
|
|
388
|
+
model: Model,
|
|
389
|
+
options: Options = {},
|
|
390
|
+
context: Explain.Context = {}
|
|
391
|
+
): Promise<Report> => {
|
|
392
|
+
const scope = boundedScope(model, options.limits)
|
|
393
|
+
const result = await run(adapter, model, model, 'feasibility', options, context)
|
|
394
|
+
return report(model, { kind: 'feasibility' }, scope, result.tieBreak, result.explanation)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export const optimization = async (
|
|
398
|
+
adapter: SolverAdapter,
|
|
399
|
+
model: Model,
|
|
400
|
+
options: Options = {},
|
|
401
|
+
context: Explain.Context = {}
|
|
402
|
+
): Promise<Report> => {
|
|
403
|
+
const scope = boundedScope(model, options.limits)
|
|
404
|
+
const result = await run(adapter, model, model, 'optimization', options, context)
|
|
405
|
+
return report(model, { kind: 'optimization' }, scope, result.tieBreak, result.explanation)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export const counterexample = async (
|
|
409
|
+
adapter: SolverAdapter,
|
|
410
|
+
model: Model,
|
|
411
|
+
invariantId: string,
|
|
412
|
+
options: Options = {},
|
|
413
|
+
context: Explain.Context = {}
|
|
414
|
+
): Promise<Report> => {
|
|
415
|
+
const baseScope = boundedScope(model, options.limits)
|
|
416
|
+
const invariant = model.constraints.find(constraint => constraint.id === invariantId)
|
|
417
|
+
if (invariant === undefined) {
|
|
418
|
+
throw new WorkflowValidationError(`unknown invariant constraint ${JSON.stringify(invariantId)}`)
|
|
419
|
+
}
|
|
420
|
+
const negated: HardConstraint = { ...invariant, expression: { kind: 'not', value: invariant.expression } }
|
|
421
|
+
const solveModel: Model = {
|
|
422
|
+
...model,
|
|
423
|
+
constraints: model.constraints.map(constraint => constraint.id === invariantId ? negated : constraint)
|
|
424
|
+
}
|
|
425
|
+
const result = await run(adapter, model, solveModel, 'feasibility', { ...options, unsatCore: options.unsatCore ?? true }, context)
|
|
426
|
+
const verdict = result.explanation.outcome.status === 'satisfied'
|
|
427
|
+
? 'counterexample'
|
|
428
|
+
: result.explanation.outcome.status === 'unsatisfied'
|
|
429
|
+
? 'holds-within-scope'
|
|
430
|
+
: 'unknown'
|
|
431
|
+
const scope: Scope = {
|
|
432
|
+
...baseScope,
|
|
433
|
+
assumptions: baseScope.assumptions.filter(id => id !== invariantId)
|
|
434
|
+
}
|
|
435
|
+
return report(model, { kind: 'counterexample', invariantId, verdict }, scope, result.tieBreak, result.explanation)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const bindingConstraint = (binding: Binding, id: string): HardConstraint => ({
|
|
439
|
+
id,
|
|
440
|
+
expression: {
|
|
441
|
+
kind: 'eq',
|
|
442
|
+
left: { kind: 'variable', id: binding.variableId },
|
|
443
|
+
right: literal(binding.value)
|
|
444
|
+
},
|
|
445
|
+
scenarioInputIds: [binding.variableId]
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
const normalizeBindings = (model: Model, bindings: readonly Binding[]): readonly Binding[] => {
|
|
449
|
+
const variables = variableMap(model)
|
|
450
|
+
const domains = new Map((model.enums ?? []).map(domain => [domain.id, domain.values]))
|
|
451
|
+
const seen = new Set<string>()
|
|
452
|
+
return bindings.map(binding => {
|
|
453
|
+
if (seen.has(binding.variableId)) {
|
|
454
|
+
throw new WorkflowValidationError(`variable ${JSON.stringify(binding.variableId)} is fixed more than once`)
|
|
455
|
+
}
|
|
456
|
+
seen.add(binding.variableId)
|
|
457
|
+
const variable = variables.get(binding.variableId)
|
|
458
|
+
if (variable === undefined) throw new WorkflowValidationError(`unknown variable ${JSON.stringify(binding.variableId)}`)
|
|
459
|
+
return { variableId: binding.variableId, value: normalizedValue(variable, binding.value, domains) }
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const assignmentSignature = (report: Report, observe: readonly string[]): string => {
|
|
464
|
+
const outcome = report.explanation.outcome
|
|
465
|
+
if (outcome.status !== 'satisfied' && outcome.status !== 'optimal') return outcome.status
|
|
466
|
+
const assignment: Assignment = Object.fromEntries(outcome.assignments.map(item => [item.id, item.value]))
|
|
467
|
+
return JSON.stringify({
|
|
468
|
+
status: outcome.status,
|
|
469
|
+
assignment: Object.fromEntries(observe.map(id => [id, assignment[id] ?? null]))
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const transitions = (points: readonly SensitivityPoint[], observe: readonly string[]): readonly Transition[] => {
|
|
474
|
+
const result: Transition[] = []
|
|
475
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
476
|
+
const previous = points[index - 1]!
|
|
477
|
+
const current = points[index]!
|
|
478
|
+
const fromSignature = assignmentSignature(previous.report, observe)
|
|
479
|
+
const toSignature = assignmentSignature(current.report, observe)
|
|
480
|
+
if (fromSignature !== toSignature) {
|
|
481
|
+
result.push({
|
|
482
|
+
afterIndex: index - 1,
|
|
483
|
+
from: previous.value,
|
|
484
|
+
to: current.value,
|
|
485
|
+
fromSignature,
|
|
486
|
+
toSignature
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return result
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const unknownRegions = (points: readonly SensitivityPoint[]): readonly UnknownRegion[] => {
|
|
494
|
+
const result: UnknownRegion[] = []
|
|
495
|
+
let start: number | undefined
|
|
496
|
+
for (let index = 0; index <= points.length; index += 1) {
|
|
497
|
+
const unknown = index < points.length && points[index]!.report.explanation.outcome.status === 'unknown'
|
|
498
|
+
if (unknown && start === undefined) start = index
|
|
499
|
+
if (!unknown && start !== undefined) {
|
|
500
|
+
const end = index - 1
|
|
501
|
+
result.push({
|
|
502
|
+
startIndex: start,
|
|
503
|
+
endIndex: end,
|
|
504
|
+
from: points[start]!.value,
|
|
505
|
+
to: points[end]!.value
|
|
506
|
+
})
|
|
507
|
+
start = undefined
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return result
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export const sensitivity = async (
|
|
514
|
+
adapter: SolverAdapter,
|
|
515
|
+
model: Model,
|
|
516
|
+
request: SensitivityRequest,
|
|
517
|
+
options: Options = {},
|
|
518
|
+
context: Explain.Context = {}
|
|
519
|
+
): Promise<SensitivityReport> => {
|
|
520
|
+
const scope = boundedScope(model, options.limits)
|
|
521
|
+
const maxRuns = request.maxRuns ?? defaultMaxSensitivityRuns
|
|
522
|
+
if (!Number.isSafeInteger(maxRuns) || maxRuns <= 0) {
|
|
523
|
+
throw new WorkflowValidationError(`maxRuns must be a positive safe integer, received ${String(maxRuns)}`)
|
|
524
|
+
}
|
|
525
|
+
if (request.samples.length === 0) throw new WorkflowValidationError('sensitivity needs at least one sample')
|
|
526
|
+
if (request.samples.length > maxRuns) {
|
|
527
|
+
throw new WorkflowValidationError(`sensitivity samples exceed maxRuns: ${request.samples.length} > ${maxRuns}`)
|
|
528
|
+
}
|
|
529
|
+
const variables = variableMap(model)
|
|
530
|
+
const selected = variables.get(request.variableId)
|
|
531
|
+
if (selected === undefined) throw new WorkflowValidationError(`unknown sensitivity variable ${JSON.stringify(request.variableId)}`)
|
|
532
|
+
const domains = new Map((model.enums ?? []).map(domain => [domain.id, domain.values]))
|
|
533
|
+
const samples = request.samples.map(value => normalizedValue(selected, value, domains))
|
|
534
|
+
if (new Set(samples.map(value => JSON.stringify(value))).size !== samples.length) {
|
|
535
|
+
throw new WorkflowValidationError('sensitivity samples contain duplicate values')
|
|
536
|
+
}
|
|
537
|
+
const fixed = normalizeBindings(model, request.fixed ?? [])
|
|
538
|
+
if (fixed.some(binding => binding.variableId === request.variableId)) {
|
|
539
|
+
throw new WorkflowValidationError(`sensitivity variable ${JSON.stringify(request.variableId)} is also fixed`)
|
|
540
|
+
}
|
|
541
|
+
const observe = [...new Set(request.observe ?? [])]
|
|
542
|
+
for (const id of observe) {
|
|
543
|
+
if (!variables.has(id)) throw new WorkflowValidationError(`unknown observed variable ${JSON.stringify(id)}`)
|
|
544
|
+
}
|
|
545
|
+
const kind = request.operation ?? 'optimization'
|
|
546
|
+
const used = new Set(model.constraints.map(constraint => constraint.id))
|
|
547
|
+
const fixedConstraints = fixed.map((binding, index) =>
|
|
548
|
+
bindingConstraint(binding, freshId(used, `fixed/${index}`)))
|
|
549
|
+
const points: SensitivityPoint[] = []
|
|
550
|
+
for (const [index, value] of samples.entries()) {
|
|
551
|
+
const sample: Binding = { variableId: request.variableId, value }
|
|
552
|
+
const solveModel: Model = {
|
|
553
|
+
...model,
|
|
554
|
+
constraints: [
|
|
555
|
+
...model.constraints,
|
|
556
|
+
...fixedConstraints,
|
|
557
|
+
bindingConstraint(sample, freshId(used, `sample/${index}`))
|
|
558
|
+
]
|
|
559
|
+
}
|
|
560
|
+
const solved = await run(adapter, model, solveModel, kind, options, context)
|
|
561
|
+
points.push({
|
|
562
|
+
index,
|
|
563
|
+
value,
|
|
564
|
+
report: report(model, { kind }, scope, solved.tieBreak, solved.explanation)
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
schema,
|
|
569
|
+
operation: 'sensitivity',
|
|
570
|
+
modelDigest: Canonical.digest(model),
|
|
571
|
+
variableId: request.variableId,
|
|
572
|
+
fixed,
|
|
573
|
+
observe,
|
|
574
|
+
points,
|
|
575
|
+
transitions: transitions(points, observe),
|
|
576
|
+
unknownRegions: unknownRegions(points)
|
|
577
|
+
}
|
|
578
|
+
}
|