@cavelang/solver-z3 0.29.0

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/src/runtime.ts ADDED
@@ -0,0 +1,479 @@
1
+ import { performance } from 'node:perf_hooks'
2
+ import { Adapter, Exact, Model } from '@cavelang/solver'
3
+ import type {
4
+ AnyExpr,
5
+ Arith,
6
+ Bool,
7
+ Context,
8
+ Model as Z3Model,
9
+ Optimize,
10
+ Solver
11
+ } from 'z3-solver'
12
+
13
+ type Z3 = Awaited<ReturnType<typeof import('z3-solver')['init']>>
14
+ type Name = 'cave'
15
+ type Expression = AnyExpr<Name>
16
+
17
+ type Compiled = {
18
+ readonly variables: ReadonlyMap<string, Expression>
19
+ readonly enums: ReadonlyMap<string, readonly string[]>
20
+ readonly bounds: readonly Bool<Name>[]
21
+ readonly constraints: ReadonlyMap<string, Bool<Name>>
22
+ readonly softConstraints: readonly {
23
+ readonly expression: Bool<Name>
24
+ readonly weight: string
25
+ }[]
26
+ readonly objectives: readonly {
27
+ readonly id: string
28
+ readonly direction: 'minimize' | 'maximize'
29
+ readonly expression: Arith<Name>
30
+ }[]
31
+ }
32
+
33
+ export type Runtime = Adapter.t & {
34
+ /** Time spent loading and initializing the Z3 Wasm module. */
35
+ readonly initializationMs: number
36
+ /** Wait for queued checks and terminate Z3's Emscripten workers. Idempotent. */
37
+ readonly close: () => Promise<void>
38
+ }
39
+
40
+ const backendError = (
41
+ backend: Adapter.Backend,
42
+ started: number,
43
+ error: unknown
44
+ ): Adapter.Result => ({
45
+ status: 'unknown',
46
+ reason: {
47
+ kind: 'backend-error',
48
+ message: error instanceof Error ? error.message : String(error)
49
+ },
50
+ backend,
51
+ diagnostics: [],
52
+ elapsedMs: Math.round(performance.now() - started)
53
+ })
54
+
55
+ const exact = (value: Model.Rational): { readonly numerator: bigint, readonly denominator: bigint } => {
56
+ const normalized = Exact.rational(value)
57
+ return {
58
+ numerator: BigInt(normalized.numerator),
59
+ denominator: BigInt(normalized.denominator)
60
+ }
61
+ }
62
+
63
+ const rationalText = (value: Model.Rational): string => {
64
+ const normalized = Exact.rational(value)
65
+ return `${normalized.numerator}/${normalized.denominator}`
66
+ }
67
+
68
+ const asBool = (value: Expression): Bool<Name> => value as Bool<Name>
69
+ const asArith = (value: Expression): Arith<Name> => value as Arith<Name>
70
+
71
+ const compiler = (
72
+ context: Context<Name>,
73
+ model: Model.t
74
+ ): Compiled => {
75
+ const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
76
+ const enums = new Map((model.enums ?? []).map(domain => [
77
+ domain.id,
78
+ [...domain.values].sort(compareText)
79
+ ] as const))
80
+ const variables = new Map<string, Expression>()
81
+ const bounds: Bool<Name>[] = []
82
+
83
+ for (const variable of model.variables) {
84
+ switch (variable.sort) {
85
+ case 'bool':
86
+ variables.set(variable.id, context.Bool.const(variable.id))
87
+ break
88
+ case 'int': {
89
+ const expression = context.Int.const(variable.id)
90
+ variables.set(variable.id, expression)
91
+ bounds.push(expression.ge(Exact.integer(variable.min)), expression.le(Exact.integer(variable.max)))
92
+ break
93
+ }
94
+ case 'real': {
95
+ const expression = context.Real.const(variable.id)
96
+ variables.set(variable.id, expression)
97
+ if (variable.min !== undefined) bounds.push(expression.ge(exact(variable.min)))
98
+ if (variable.max !== undefined) bounds.push(expression.le(exact(variable.max)))
99
+ break
100
+ }
101
+ case 'enum': {
102
+ // A finite enum is encoded as a bounded integer. The mapping is stable
103
+ // across semantically irrelevant declaration reordering.
104
+ const values = enums.get(variable.domain)!
105
+ const expression = context.Int.const(variable.id)
106
+ variables.set(variable.id, expression)
107
+ bounds.push(expression.ge(0), expression.lt(values.length))
108
+ break
109
+ }
110
+ }
111
+ }
112
+
113
+ const compile = (expression: Model.Expression): Expression => {
114
+ switch (expression.kind) {
115
+ case 'literal':
116
+ switch (expression.sort) {
117
+ case 'bool': return context.Bool.val(expression.value)
118
+ case 'int': return context.Int.val(Exact.integer(expression.value))
119
+ case 'real': return context.Real.val(exact(expression.value))
120
+ case 'enum': return context.Int.val(enums.get(expression.domain)!.indexOf(expression.value))
121
+ }
122
+ case 'variable': return variables.get(expression.id)!
123
+ case 'not': return asBool(compile(expression.value)).not()
124
+ case 'and': return context.And(...expression.operands.map(value => asBool(compile(value))))
125
+ case 'or': return context.Or(...expression.operands.map(value => asBool(compile(value))))
126
+ case 'implies': return context.Implies(asBool(compile(expression.left)), asBool(compile(expression.right)))
127
+ case 'eq': return compile(expression.left).eq(compile(expression.right))
128
+ case 'neq': return compile(expression.left).neq(compile(expression.right))
129
+ case 'lt': return asArith(compile(expression.left)).lt(asArith(compile(expression.right)))
130
+ case 'lte': return asArith(compile(expression.left)).le(asArith(compile(expression.right)))
131
+ case 'gt': return asArith(compile(expression.left)).gt(asArith(compile(expression.right)))
132
+ case 'gte': return asArith(compile(expression.left)).ge(asArith(compile(expression.right)))
133
+ case 'add': {
134
+ const [first, ...rest] = expression.operands.map(value => asArith(compile(value)))
135
+ return rest.reduce((left, right) => left.add(right), first!)
136
+ }
137
+ case 'multiply': {
138
+ const [first, ...rest] = expression.operands.map(value => asArith(compile(value)))
139
+ return rest.reduce((left, right) => left.mul(right), first!)
140
+ }
141
+ case 'subtract': return asArith(compile(expression.left)).sub(asArith(compile(expression.right)))
142
+ case 'divide': {
143
+ const left = asArith(compile(expression.left))
144
+ const right = asArith(compile(expression.right))
145
+ // Portable division is exact-real division even when both operands are
146
+ // integers; Z3's native Int / Int operation would truncate instead.
147
+ const real = (value: Arith<Name>): Arith<Name> => context.isInt(value) ? context.ToReal(value) : value
148
+ return real(left).div(real(right))
149
+ }
150
+ case 'negate': return asArith(compile(expression.value)).neg()
151
+ case 'if': return context.If(
152
+ asBool(compile(expression.condition)),
153
+ compile(expression.then),
154
+ compile(expression.else)
155
+ ) as Expression
156
+ }
157
+ }
158
+
159
+ return {
160
+ variables,
161
+ enums,
162
+ bounds,
163
+ constraints: new Map(model.constraints.map(constraint => [constraint.id, asBool(compile(constraint.expression))])),
164
+ softConstraints: (model.softConstraints ?? []).map(constraint => ({
165
+ expression: asBool(compile(constraint.expression)),
166
+ weight: rationalText(constraint.weight)
167
+ })),
168
+ objectives: (model.objectives ?? []).map(objective => ({
169
+ id: objective.id,
170
+ direction: objective.direction,
171
+ expression: asArith(compile(objective.expression))
172
+ }))
173
+ }
174
+ }
175
+
176
+ const assignment = (
177
+ context: Context<Name>,
178
+ compiled: Compiled,
179
+ model: Model.t,
180
+ z3Model: Z3Model<Name>
181
+ ): Model.Assignment => Object.fromEntries(
182
+ [...model.variables]
183
+ .sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
184
+ .map(variable => {
185
+ const value = z3Model.eval(compiled.variables.get(variable.id)!, true)
186
+ switch (variable.sort) {
187
+ case 'bool':
188
+ if (context.isTrue(value)) return [variable.id, { sort: 'bool', value: true }]
189
+ if (context.isFalse(value)) return [variable.id, { sort: 'bool', value: false }]
190
+ throw new TypeError(`Z3 returned a non-Boolean value for ${JSON.stringify(variable.id)}`)
191
+ case 'int':
192
+ if (!context.isIntVal(value)) throw new TypeError(`Z3 returned a non-integer value for ${JSON.stringify(variable.id)}`)
193
+ return [variable.id, { sort: 'int', value: value.value().toString() }]
194
+ case 'real':
195
+ if (!context.isRealVal(value)) throw new TypeError(`Z3 returned a non-rational value for ${JSON.stringify(variable.id)}`)
196
+ return [variable.id, {
197
+ sort: 'real',
198
+ numerator: value.value().numerator.toString(),
199
+ denominator: value.value().denominator.toString()
200
+ }]
201
+ case 'enum': {
202
+ if (!context.isIntVal(value)) throw new TypeError(`Z3 returned a non-enum value for ${JSON.stringify(variable.id)}`)
203
+ const index = Number(value.value())
204
+ const member = compiled.enums.get(variable.domain)?.[index]
205
+ if (member === undefined) throw new TypeError(`Z3 returned an out-of-domain enum value for ${JSON.stringify(variable.id)}`)
206
+ return [variable.id, { sort: 'enum', domain: variable.domain, value: member }]
207
+ }
208
+ }
209
+ })
210
+ ) as Model.Assignment
211
+
212
+ const numericValue = (context: Context<Name>, value: Arith<Name>): Model.Value => {
213
+ if (context.isIntVal(value)) return { sort: 'int', value: value.value().toString() }
214
+ if (context.isRealVal(value)) return {
215
+ sort: 'real',
216
+ numerator: value.value().numerator.toString(),
217
+ denominator: value.value().denominator.toString()
218
+ }
219
+ throw new TypeError(`Z3 returned a non-rational objective value: ${value.sexpr()}`)
220
+ }
221
+
222
+ const withDeadline = async (
223
+ context: Context<Name>,
224
+ timeoutMs: number,
225
+ check: () => Promise<'sat' | 'unsat' | 'unknown'>
226
+ ): Promise<{ readonly status: 'sat' | 'unsat' | 'unknown', readonly interrupted: boolean }> => {
227
+ let interrupted = false
228
+ const timer = setTimeout(() => {
229
+ interrupted = true
230
+ context.interrupt()
231
+ }, timeoutMs)
232
+ timer.unref()
233
+ try {
234
+ return { status: await check(), interrupted }
235
+ } finally {
236
+ clearTimeout(timer)
237
+ }
238
+ }
239
+
240
+ const unknown = (
241
+ backend: Adapter.Backend,
242
+ started: number,
243
+ reason: string,
244
+ interrupted: boolean
245
+ ): Adapter.Result => {
246
+ const timeout = interrupted || /timeout|canceled|cancelled/i.test(reason)
247
+ const memory = /memory|memout/i.test(reason)
248
+ return {
249
+ status: 'unknown',
250
+ reason: timeout
251
+ ? { kind: 'timeout', message: reason || 'Z3 deadline reached', limit: 'timeoutMs' }
252
+ : memory
253
+ ? { kind: 'resource-limit', message: reason, limit: 'maxMemoryBytes' }
254
+ : { kind: 'indeterminate', message: reason || 'Z3 returned unknown' },
255
+ backend,
256
+ diagnostics: [],
257
+ elapsedMs: Math.round(performance.now() - started)
258
+ }
259
+ }
260
+
261
+ const addHard = (
262
+ solver: Solver<Name> | Optimize<Name>,
263
+ compiled: Compiled,
264
+ track: boolean
265
+ ): ReadonlyMap<number, string> => {
266
+ solver.add(...compiled.bounds)
267
+ const trackers = new Map<number, string>()
268
+ for (const [id, expression] of compiled.constraints) {
269
+ if (track) {
270
+ const symbol = `cave.constraint.${id}`
271
+ const tracker = solver.ctx.Bool.const(symbol)
272
+ solver.addAndTrack(expression, tracker)
273
+ trackers.set(tracker.id(), id)
274
+ } else {
275
+ solver.add(expression)
276
+ }
277
+ }
278
+ return trackers
279
+ }
280
+
281
+ const coreFor = async (
282
+ context: Context<Name>,
283
+ compiled: Compiled,
284
+ limits: Adapter.Limits
285
+ ): Promise<readonly string[] | undefined> => {
286
+ const solver = new context.Solver()
287
+ try {
288
+ solver.set('timeout', limits.timeoutMs)
289
+ const trackers = addHard(solver, compiled, true)
290
+ if ((await withDeadline(context, limits.timeoutMs, () => solver.check())).status !== 'unsat') return undefined
291
+ return [...solver.unsatCore()]
292
+ .map(value => trackers.get(value.id()))
293
+ .filter((value): value is string => value !== undefined)
294
+ .sort()
295
+ } finally {
296
+ solver.release()
297
+ }
298
+ }
299
+
300
+ const limitOutput = (
301
+ result: Adapter.Result,
302
+ limits: Adapter.Limits
303
+ ): Adapter.Result => Buffer.byteLength(JSON.stringify(result)) <= limits.maxOutputBytes
304
+ ? result
305
+ : {
306
+ status: 'unknown',
307
+ reason: {
308
+ kind: 'resource-limit',
309
+ message: 'solver result exceeds maxOutputBytes',
310
+ limit: 'maxOutputBytes'
311
+ },
312
+ backend: result.backend,
313
+ diagnostics: result.diagnostics,
314
+ elapsedMs: result.elapsedMs
315
+ }
316
+
317
+ const solveModel = async (
318
+ api: Z3,
319
+ context: Context<Name>,
320
+ backend: Adapter.Backend,
321
+ model: Model.t,
322
+ request: Adapter.Request
323
+ ): Promise<Adapter.Result> => {
324
+ const started = performance.now()
325
+ try {
326
+ api.setParam('memory_max_size', Math.max(1, Math.floor(request.limits.maxMemoryBytes / 1024 / 1024)))
327
+ const compiled = compiler(context, model)
328
+ const optimize = compiled.objectives.length > 0 || compiled.softConstraints.length > 0
329
+
330
+ if (!optimize) {
331
+ const solver = new context.Solver()
332
+ try {
333
+ solver.set('timeout', request.limits.timeoutMs)
334
+ const trackers = addHard(solver, compiled, request.unsatCore)
335
+ const checked = await withDeadline(context, request.limits.timeoutMs, () => solver.check())
336
+ if (checked.status === 'unknown') {
337
+ return unknown(backend, started, solver.reasonUnknown(), checked.interrupted)
338
+ }
339
+ if (checked.status === 'unsat') {
340
+ const core = request.unsatCore
341
+ ? [...solver.unsatCore()]
342
+ .map(value => trackers.get(value.id()))
343
+ .filter((value): value is string => value !== undefined)
344
+ .sort()
345
+ : undefined
346
+ return limitOutput({
347
+ status: 'unsatisfied',
348
+ ...(core === undefined ? {} : { core }),
349
+ infeasibilityProved: true,
350
+ backend,
351
+ diagnostics: [],
352
+ elapsedMs: Math.round(performance.now() - started)
353
+ }, request.limits)
354
+ }
355
+ return limitOutput({
356
+ status: 'satisfied',
357
+ assignment: assignment(context, compiled, model, solver.model()),
358
+ backend,
359
+ diagnostics: [],
360
+ elapsedMs: Math.round(performance.now() - started)
361
+ }, request.limits)
362
+ } finally {
363
+ solver.release()
364
+ }
365
+ }
366
+
367
+ const optimizer = new context.Optimize()
368
+ try {
369
+ optimizer.set('timeout', request.limits.timeoutMs)
370
+ optimizer.set('priority', 'lex')
371
+ addHard(optimizer, compiled, false)
372
+ for (const objective of compiled.objectives) {
373
+ if (objective.direction === 'minimize') optimizer.minimize(objective.expression)
374
+ else optimizer.maximize(objective.expression)
375
+ }
376
+ // Soft preferences are deliberately the lowest-priority objective. Their
377
+ // weights are explicit model data and never derived from CAVE confidence.
378
+ for (const constraint of compiled.softConstraints) {
379
+ optimizer.addSoft(constraint.expression, constraint.weight)
380
+ }
381
+ const checked = await withDeadline(context, request.limits.timeoutMs, () => optimizer.check())
382
+ if (checked.status === 'unknown') {
383
+ const reason = api.Z3.optimize_get_reason_unknown(context.ptr, optimizer.ptr)
384
+ return unknown(backend, started, reason, checked.interrupted)
385
+ }
386
+ if (checked.status === 'unsat') {
387
+ return limitOutput({
388
+ status: 'unsatisfied',
389
+ ...(request.unsatCore ? { core: await coreFor(context, compiled, request.limits) } : {}),
390
+ infeasibilityProved: true,
391
+ backend,
392
+ diagnostics: [],
393
+ elapsedMs: Math.round(performance.now() - started)
394
+ }, request.limits)
395
+ }
396
+ const z3Model = optimizer.model()
397
+ return limitOutput({
398
+ status: 'optimal',
399
+ assignment: assignment(context, compiled, model, z3Model),
400
+ objectives: compiled.objectives.map(objective => ({
401
+ objectiveId: objective.id,
402
+ value: numericValue(context, z3Model.eval(objective.expression, true))
403
+ })),
404
+ optimalityProved: true,
405
+ backend,
406
+ diagnostics: [],
407
+ elapsedMs: Math.round(performance.now() - started)
408
+ }, request.limits)
409
+ } finally {
410
+ optimizer.release()
411
+ }
412
+ } catch (error) {
413
+ if (api.Z3.get_estimated_alloc_size() > BigInt(request.limits.maxMemoryBytes)) {
414
+ return {
415
+ status: 'unknown',
416
+ reason: {
417
+ kind: 'resource-limit',
418
+ message: 'Z3 exceeded maxMemoryBytes',
419
+ limit: 'maxMemoryBytes'
420
+ },
421
+ backend,
422
+ diagnostics: [],
423
+ elapsedMs: Math.round(performance.now() - started)
424
+ }
425
+ }
426
+ return backendError(backend, started, error)
427
+ }
428
+ }
429
+
430
+ let singleton: Promise<Runtime> | undefined
431
+
432
+ const initialize = async (): Promise<Runtime> => {
433
+ const started = performance.now()
434
+ // Keep this as the only runtime import: importing @cavelang/solver-z3 itself
435
+ // must not load the 34 MB Wasm artifact or start worker threads.
436
+ const api = await import('z3-solver').then(module => module.init())
437
+ const context = api.Context('cave')
438
+ const backend = Object.freeze({ name: 'z3-wasm', version: api.Z3.get_full_version() })
439
+ let tail: Promise<void> = Promise.resolve()
440
+ let closing = false
441
+ let closed = false
442
+
443
+ const enqueue = <T>(work: () => Promise<T>): Promise<T> => {
444
+ const result = tail.then(work, work)
445
+ tail = result.then(() => undefined, () => undefined)
446
+ return result
447
+ }
448
+
449
+ const runtime: Runtime = {
450
+ backend,
451
+ capabilities: new Set(Adapter.capabilities),
452
+ initializationMs: Math.round(performance.now() - started),
453
+ solve: (model, request) => {
454
+ if (closing) return Promise.resolve(backendError(backend, performance.now(), new Error('Z3 runtime is closed')))
455
+ return enqueue(() => solveModel(api, context, backend, model, request))
456
+ },
457
+ close: async () => {
458
+ if (closed) return
459
+ closing = true
460
+ await enqueue(async () => {
461
+ if (closed) return
462
+ // Let the completed pthread post its cleanup message before forcibly
463
+ // terminating the pool. Without this turn, Emscripten can report a
464
+ // late `cleanupThread` command from a worker we just terminated.
465
+ await new Promise<void>(resolve => setTimeout(resolve, 0))
466
+ api.em?.PThread?.terminateAllThreads?.()
467
+ closed = true
468
+ singleton = undefined
469
+ })
470
+ }
471
+ }
472
+ return runtime
473
+ }
474
+
475
+ /** Lazily initialize and reuse one process-wide Z3 runtime. */
476
+ export const create = (): Promise<Runtime> => singleton ??= initialize().catch(error => {
477
+ singleton = undefined
478
+ throw error
479
+ })