@cavelang/solver 0.35.0 → 0.36.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.
Files changed (119) hide show
  1. package/README.md +2155 -3
  2. package/dist/src/adapter.d.ts +6 -0
  3. package/dist/src/adapter.d.ts.map +1 -1
  4. package/dist/src/adapter.js +3 -0
  5. package/dist/src/adapter.js.map +1 -1
  6. package/dist/src/balanced-reduce.d.ts +3 -0
  7. package/dist/src/balanced-reduce.d.ts.map +1 -0
  8. package/dist/src/balanced-reduce.js +10 -0
  9. package/dist/src/balanced-reduce.js.map +1 -0
  10. package/dist/src/canonical-owned.d.ts +5 -0
  11. package/dist/src/canonical-owned.d.ts.map +1 -0
  12. package/dist/src/canonical-owned.js +206 -0
  13. package/dist/src/canonical-owned.js.map +1 -0
  14. package/dist/src/canonical.d.ts +3 -2
  15. package/dist/src/canonical.d.ts.map +1 -1
  16. package/dist/src/canonical.js +12 -85
  17. package/dist/src/canonical.js.map +1 -1
  18. package/dist/src/capability.d.ts.map +1 -1
  19. package/dist/src/capability.js +43 -33
  20. package/dist/src/capability.js.map +1 -1
  21. package/dist/src/clone.d.ts +3 -0
  22. package/dist/src/clone.d.ts.map +1 -0
  23. package/dist/src/clone.js +54 -0
  24. package/dist/src/clone.js.map +1 -0
  25. package/dist/src/constant-sign.d.ts +5 -0
  26. package/dist/src/constant-sign.d.ts.map +1 -0
  27. package/dist/src/constant-sign.js +54 -0
  28. package/dist/src/constant-sign.js.map +1 -0
  29. package/dist/src/context.d.ts +6 -0
  30. package/dist/src/context.d.ts.map +1 -0
  31. package/dist/src/context.js +151 -0
  32. package/dist/src/context.js.map +1 -0
  33. package/dist/src/diagnostic-text.d.ts +3 -0
  34. package/dist/src/diagnostic-text.d.ts.map +1 -0
  35. package/dist/src/diagnostic-text.js +5 -0
  36. package/dist/src/diagnostic-text.js.map +1 -0
  37. package/dist/src/evaluate-expression.d.ts +4 -0
  38. package/dist/src/evaluate-expression.d.ts.map +1 -0
  39. package/dist/src/evaluate-expression.js +24 -0
  40. package/dist/src/evaluate-expression.js.map +1 -0
  41. package/dist/src/exact.d.ts +2 -0
  42. package/dist/src/exact.d.ts.map +1 -1
  43. package/dist/src/exact.js +125 -28
  44. package/dist/src/exact.js.map +1 -1
  45. package/dist/src/explain.d.ts +4 -0
  46. package/dist/src/explain.d.ts.map +1 -1
  47. package/dist/src/explain.js +330 -91
  48. package/dist/src/explain.js.map +1 -1
  49. package/dist/src/explanation-budget.d.ts +14 -0
  50. package/dist/src/explanation-budget.d.ts.map +1 -0
  51. package/dist/src/explanation-budget.js +46 -0
  52. package/dist/src/explanation-budget.js.map +1 -0
  53. package/dist/src/expression-key.d.ts +8 -0
  54. package/dist/src/expression-key.d.ts.map +1 -0
  55. package/dist/src/expression-key.js +54 -0
  56. package/dist/src/expression-key.js.map +1 -0
  57. package/dist/src/fraction-product.d.ts +3 -0
  58. package/dist/src/fraction-product.d.ts.map +1 -0
  59. package/dist/src/fraction-product.js +13 -0
  60. package/dist/src/fraction-product.js.map +1 -0
  61. package/dist/src/fraction-sum.d.ts +3 -0
  62. package/dist/src/fraction-sum.d.ts.map +1 -0
  63. package/dist/src/fraction-sum.js +23 -0
  64. package/dist/src/fraction-sum.js.map +1 -0
  65. package/dist/src/integer-gcd.d.ts +3 -0
  66. package/dist/src/integer-gcd.d.ts.map +1 -0
  67. package/dist/src/integer-gcd.js +50 -0
  68. package/dist/src/integer-gcd.js.map +1 -0
  69. package/dist/src/linear.d.ts +2 -1
  70. package/dist/src/linear.d.ts.map +1 -1
  71. package/dist/src/linear.js +85 -17
  72. package/dist/src/linear.js.map +1 -1
  73. package/dist/src/numeric-size.d.ts +2 -0
  74. package/dist/src/numeric-size.d.ts.map +1 -0
  75. package/dist/src/numeric-size.js +33 -0
  76. package/dist/src/numeric-size.js.map +1 -0
  77. package/dist/src/options.d.ts +4 -0
  78. package/dist/src/options.d.ts.map +1 -0
  79. package/dist/src/options.js +18 -0
  80. package/dist/src/options.js.map +1 -0
  81. package/dist/src/result.d.ts +4 -0
  82. package/dist/src/result.d.ts.map +1 -0
  83. package/dist/src/result.js +29 -0
  84. package/dist/src/result.js.map +1 -0
  85. package/dist/src/solve.d.ts.map +1 -1
  86. package/dist/src/solve.js +34 -3
  87. package/dist/src/solve.js.map +1 -1
  88. package/dist/src/validate.d.ts +5 -0
  89. package/dist/src/validate.d.ts.map +1 -1
  90. package/dist/src/validate.js +323 -65
  91. package/dist/src/validate.js.map +1 -1
  92. package/dist/src/workflow.d.ts.map +1 -1
  93. package/dist/src/workflow.js +98 -26
  94. package/dist/src/workflow.js.map +1 -1
  95. package/package.json +2 -2
  96. package/src/adapter.ts +9 -0
  97. package/src/balanced-reduce.ts +9 -0
  98. package/src/canonical-owned.ts +186 -0
  99. package/src/canonical.ts +14 -90
  100. package/src/capability.ts +41 -30
  101. package/src/clone.ts +47 -0
  102. package/src/constant-sign.ts +59 -0
  103. package/src/context.ts +114 -0
  104. package/src/diagnostic-text.ts +4 -0
  105. package/src/evaluate-expression.ts +21 -0
  106. package/src/exact.ts +122 -27
  107. package/src/explain.ts +342 -94
  108. package/src/explanation-budget.ts +47 -0
  109. package/src/expression-key.ts +45 -0
  110. package/src/fraction-product.ts +10 -0
  111. package/src/fraction-sum.ts +20 -0
  112. package/src/integer-gcd.ts +47 -0
  113. package/src/linear.ts +82 -16
  114. package/src/numeric-size.ts +26 -0
  115. package/src/options.ts +18 -0
  116. package/src/result.ts +28 -0
  117. package/src/solve.ts +34 -3
  118. package/src/validate.ts +312 -66
  119. package/src/workflow.ts +92 -26
@@ -0,0 +1,45 @@
1
+ import type { Expression } from './model.ts'
2
+
3
+ /** Ordered syntax identity for an already captured, validated expression.
4
+ * Unlike model canonicalization, this preserves operand order and literal
5
+ * representation because local evaluation can fail before later operands run.
6
+ * The string-ID table must share the evaluation cache's report lifetime.
7
+ */
8
+ export const expressionKey = (expression: Expression, strings: Map<string, number>): string => {
9
+ const intern = (value: string): number => {
10
+ let id = strings.get(value)
11
+ if (id === undefined) {
12
+ id = strings.size
13
+ strings.set(value, id)
14
+ }
15
+ return id
16
+ }
17
+ const parts: string[] = []
18
+ const pending = [expression]
19
+ while (pending.length > 0) {
20
+ const node = pending.pop()!
21
+ switch (node.kind) {
22
+ case 'literal': {
23
+ const value = node.sort === 'enum' ? intern(node.value) : node.sort === 'real' && typeof node.value === 'object'
24
+ ? [node.value.numerator, node.value.denominator] : node.value
25
+ parts.push(JSON.stringify([node.kind, node.sort, node.sort === 'enum' ? intern(node.domain) : null, value]))
26
+ break
27
+ }
28
+ case 'variable': {
29
+ parts.push(JSON.stringify([node.kind, intern(node.id)]))
30
+ break
31
+ }
32
+ case 'not': case 'negate':
33
+ parts.push(node.kind); pending.push(node.value); break
34
+ case 'and': case 'or': case 'add': case 'multiply':
35
+ parts.push(JSON.stringify([node.kind, node.operands.length]))
36
+ for (let index = node.operands.length - 1; index >= 0; index--) pending.push(node.operands[index]!)
37
+ break
38
+ case 'if':
39
+ parts.push(node.kind); pending.push(node.else, node.then, node.condition); break
40
+ default:
41
+ parts.push(node.kind); pending.push(node.right, node.left)
42
+ }
43
+ }
44
+ return parts.join('\n')
45
+ }
@@ -0,0 +1,10 @@
1
+ import { gcd } from './integer-gcd.ts'
2
+ /** Cancel across factors before allocating a rational product's intermediates. */
3
+ export const product = (a: bigint, b: bigint, c: bigint, d: bigint): readonly [bigint, bigint] => {
4
+ // Leave zero-denominator diagnostics to the caller's existing normalizer.
5
+ if (b === 0n || d === 0n) return [0n, 0n]
6
+ if (a === 0n || c === 0n) return [0n, 1n]
7
+ const left = gcd(a, d)
8
+ const right = gcd(c, b)
9
+ return [(a / left) * (c / right), (b / right) * (d / left)]
10
+ }
@@ -0,0 +1,20 @@
1
+ const probeThreshold = 1n << 1024n
2
+
3
+ /** Avoid repeated large denominator factors when a short Euclidean probe finds them. */
4
+ export const sum = (a: bigint, b: bigint, c: bigint, d: bigint): readonly [bigint, bigint] => {
5
+ if (b === d) return [a + c, b]
6
+ let x = b < 0n ? -b : b
7
+ let y = d < 0n ? -d : d
8
+ if (x >= probeThreshold && y >= probeThreshold) {
9
+ if (x < y) [x, y] = [y, x]
10
+ // A full GCD can cost more than the products it avoids for coprime inputs.
11
+ // An unfinished probe leaves the ordinary exact addition unchanged.
12
+ for (let step = 0; step < 6 && y !== 0n; step++) [x, y] = [y, x % y]
13
+ if (y === 0n && x > 1n) {
14
+ const leftScale = d / x
15
+ const rightScale = b / x
16
+ return [a * leftScale + c * rightScale, b * leftScale]
17
+ }
18
+ }
19
+ return [a * d + c * b, b * d]
20
+ }
@@ -0,0 +1,47 @@
1
+ const batchThreshold = 1n << 1024n
2
+
3
+ /** Exact nonnegative GCD, with Lehmer batches for large operands. */
4
+ export const gcd = (left: bigint, right: bigint): bigint => {
5
+ let a = left < 0n ? -left : left
6
+ let b = right < 0n ? -right : right
7
+ if (a < b) [a, b] = [b, a]
8
+ while (b !== 0n) {
9
+ if (a === b) return a
10
+ if (b < batchThreshold) {
11
+ while (b !== 0n) {
12
+ const remainder = a % b
13
+ a = b
14
+ b = remainder
15
+ }
16
+ return a
17
+ }
18
+ {
19
+ // Hex length gives a cheap common shift retaining at most 64 leading bits.
20
+ const shift = BigInt(Math.max(0, a.toString(16).length * 4 - 64))
21
+ let x = a >> shift
22
+ let y = b >> shift
23
+ let A = 1n, B = 0n, C = 0n, D = 1n
24
+ for (let step = 0; step < 64 && x >= y && y > 0n && y + C > 0n && y + D > 0n; step++) {
25
+ const q = (x + A) / (y + C)
26
+ if (q <= 0n || q !== (x + B) / (y + D)) break
27
+ ;[A, B, C, D] = [C, D, A - q * C, B - q * D]
28
+ ;[x, y] = [y, x - q * y]
29
+ }
30
+ if (B !== 0n) {
31
+ const nextA = A * a + B * b
32
+ const nextB = C * a + D * b
33
+ // Each matrix step has determinant -1, preserving the GCD exactly.
34
+ // Check full-width progress before accepting the approximate batch.
35
+ if (nextA >= nextB && nextB >= 0n && nextA < a && nextB < b) {
36
+ a = nextA
37
+ b = nextB
38
+ continue
39
+ }
40
+ }
41
+ }
42
+ const remainder = a % b
43
+ a = b
44
+ b = remainder
45
+ }
46
+ return a
47
+ }
package/src/linear.ts CHANGED
@@ -1,5 +1,13 @@
1
+ import { evaluate } from './evaluate-expression.ts'
2
+ import { constantSign, type Sign } from './constant-sign.ts'
3
+ import { balancedReduce } from './balanced-reduce.ts'
4
+ import { sum } from './fraction-sum.ts'
5
+ import { product } from './fraction-product.ts'
6
+ import { clone } from './clone.ts'
1
7
  import type { Expression, Model } from './model.ts'
2
- import { model as validate } from './validate.ts'
8
+ import type { Limits } from './adapter.ts'
9
+ import { mergeLimits, model as validate } from './validate.ts'
10
+ import * as Exact from './exact.ts'
3
11
 
4
12
  export type Analysis = {
5
13
  readonly linear: boolean
@@ -8,48 +16,106 @@ export type Analysis = {
8
16
 
9
17
  type Affine = { readonly linear: boolean, readonly constant: boolean }
10
18
 
11
- const affine = (expression: Expression): Affine => {
19
+ const combine = (left: Exact.Normalized, right: Exact.Normalized, kind: 'add' | 'subtract' | 'multiply' | 'divide'): Exact.Normalized => {
20
+ const a = BigInt(left.numerator)
21
+ const b = BigInt(left.denominator)
22
+ const c = BigInt(right.numerator)
23
+ const d = BigInt(right.denominator)
24
+ const [numerator, denominator] = kind === 'add' ? sum(a, b, c, d)
25
+ : kind === 'subtract' ? sum(a, b, -c, d)
26
+ : kind === 'multiply' ? product(a, b, c, d) : product(a, b, d, c)
27
+ return Exact.fromBigInts(numerator, denominator)
28
+ }
29
+
30
+ /** Called only for numeric constant expressions already classified as affine. */
31
+ function* constantSteps(expression: Expression): Generator<Expression, Exact.Normalized, Exact.Normalized> {
32
+ switch (expression.kind) {
33
+ case 'literal':
34
+ if (expression.sort === 'int') return Exact.rational(String(Exact.integer(expression.value)))
35
+ if (expression.sort === 'real') return Exact.rational(expression.value)
36
+ break
37
+ case 'negate': return combine(Exact.rational('0'), (yield expression.value), 'subtract')
38
+ case 'add':
39
+ case 'multiply': {
40
+ const values: Exact.Normalized[] = []
41
+ for (const value of expression.operands) values.push(yield value)
42
+ if (expression.kind === 'add') return balancedReduce(values, (left, right) => combine(left, right, 'add'))
43
+ let numerator = 1n, denominator = 1n
44
+ for (const value of values) {
45
+ [numerator, denominator] = product(numerator, denominator, BigInt(value.numerator), BigInt(value.denominator))
46
+ }
47
+ return Exact.fromBigInts(numerator, denominator)
48
+ }
49
+ case 'subtract':
50
+ case 'divide': return combine((yield expression.left), (yield expression.right), expression.kind)
51
+ }
52
+ throw new TypeError('expected an affine numeric constant')
53
+ }
54
+
55
+ function* affineSteps(expression: Expression, constants: WeakMap<Expression, Exact.Normalized>, signs: WeakMap<Expression, Sign>): Generator<Expression, Affine, Affine> {
12
56
  switch (expression.kind) {
13
57
  case 'literal': return { linear: expression.sort === 'int' || expression.sort === 'real', constant: true }
14
58
  case 'variable': return { linear: true, constant: false }
15
- case 'negate': return affine(expression.value)
59
+ case 'negate': return (yield expression.value)
16
60
  case 'add': {
17
- const parts = expression.operands.map(affine)
18
- return { linear: parts.every(part => part.linear), constant: parts.every(part => part.constant) }
61
+ let linear = true, constant = true
62
+ for (const value of expression.operands) {
63
+ const part = yield value
64
+ linear = linear && part.linear
65
+ constant = constant && part.constant
66
+ }
67
+ return { linear, constant }
19
68
  }
20
69
  case 'subtract': {
21
- const left = affine(expression.left)
22
- const right = affine(expression.right)
70
+ const left = (yield expression.left)
71
+ const right = (yield expression.right)
23
72
  return { linear: left.linear && right.linear, constant: left.constant && right.constant }
24
73
  }
25
74
  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) }
75
+ let linear = true, variableParts = 0
76
+ for (const value of expression.operands) {
77
+ const part = yield value
78
+ linear = linear && part.linear
79
+ if (!part.constant) variableParts++
80
+ }
81
+ return { linear: linear && variableParts <= 1, constant: variableParts === 0 }
28
82
  }
29
83
  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 }
84
+ const left = (yield expression.left)
85
+ const right = (yield expression.right)
86
+ let linear = false
87
+ if (left.linear && right.linear && right.constant) {
88
+ const sign = constantSign(expression.right, signs, value => evaluate(value, constantSteps, constants))
89
+ linear = sign === undefined ? !Exact.isZero(evaluate(expression.right, constantSteps, constants)) : sign !== 0
90
+ }
91
+ return { linear, constant: left.constant && right.constant }
33
92
  }
34
93
  default: return { linear: false, constant: false }
35
94
  }
36
95
  }
37
96
 
38
- const constraintIsLinear = (expression: Expression): boolean => {
97
+ const constraintIsLinear = (expression: Expression, affine: (value: Expression) => Affine): boolean => {
39
98
  if (expression.kind !== 'eq' && expression.kind !== 'lte' && expression.kind !== 'gte') return false
40
99
  return affine(expression.left).linear && affine(expression.right).linear
41
100
  }
42
101
 
43
102
  /** Recognizes the portable LP/MIP subset without consulting a backend AST. */
44
- export const model = (input: Model): Analysis => {
45
- validate(input)
103
+ export const model = (input: Model, limits: Partial<Limits> = {}): Analysis => {
104
+ limits = mergeLimits(limits)
105
+ validate(input, limits)
106
+ input = clone(input)
107
+ validate(input, limits)
46
108
  const problems: string[] = []
109
+ const constants = new WeakMap<Expression, Exact.Normalized>()
110
+ const classifications = new WeakMap<Expression, Affine>()
111
+ const signs = new WeakMap<Expression, Sign>()
112
+ const affine = (value: Expression): Affine => evaluate(value, expression => affineSteps(expression, constants, signs), classifications)
47
113
  if (input.variables.some(variable => variable.sort === 'bool' || variable.sort === 'enum')) {
48
114
  problems.push('Boolean and enum variables are outside the linear subset')
49
115
  }
50
116
  if ((input.softConstraints?.length ?? 0) > 0) problems.push('soft constraints are outside the linear subset')
51
117
  input.constraints.forEach(constraint => {
52
- if (!constraintIsLinear(constraint.expression)) problems.push(`constraint ${constraint.id} is not a non-strict linear comparison`)
118
+ if (!constraintIsLinear(constraint.expression, affine)) problems.push(`constraint ${constraint.id} is not a non-strict linear comparison`)
53
119
  })
54
120
  ;(input.objectives ?? []).forEach(objective => {
55
121
  if (!affine(objective.expression).linear) problems.push(`objective ${objective.id} is not linear`)
@@ -0,0 +1,26 @@
1
+ /** Estimate pre-reduction decimal digits without constructing a BigInt. */
2
+ const integerDigits = (value: unknown): number => {
3
+ if (typeof value === 'number') return Number.isSafeInteger(value) ? String(Math.abs(value)).length : 0
4
+ if (typeof value !== 'string' || !/^[+-]?\d+$/.test(value)) return 0
5
+ return value.length - (/^[+-]/.test(value) ? 1 : 0)
6
+ }
7
+
8
+ export const numericDigits = (value: unknown, sort: 'int' | 'real'): number => {
9
+ if (sort === 'int') return integerDigits(value)
10
+ if (typeof value !== 'string') {
11
+ if (value === null || typeof value !== 'object') return 0
12
+ const pair = value as { numerator?: unknown, denominator?: unknown }
13
+ return integerDigits(pair.numerator) + integerDigits(pair.denominator)
14
+ }
15
+ const match = /^([+-]?)(\d+(?:\.\d*)?|\.\d+)(?:[eE]([+-]?\d+))?$/.exec(value)
16
+ if (match === null) return 0
17
+ const coefficient = match[2]!
18
+ const digits = coefficient.replace('.', '')
19
+ const exponent = match[3] === undefined ? 0 : Number(match[3])
20
+ if (!Number.isSafeInteger(exponent)) return 0 // Exact reports invalid exponent syntax.
21
+ // Zero never constructs a power of ten, but reading its coefficient still counts.
22
+ if (!/[1-9]/.test(digits)) return digits.length + 1
23
+ const dot = coefficient.indexOf('.')
24
+ const scale = (dot === -1 ? 0 : coefficient.length - dot - 1) - exponent
25
+ return digits.length + Math.max(0, -scale) + (scale > 0 ? scale + 1 : 1)
26
+ }
package/src/options.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { diagnosticText } from './diagnostic-text.ts'
2
+ import { mergeLimits } from './validate.ts'
3
+ import type { Options } from './adapter.ts'
4
+
5
+ /** Validate option names and capture supplied fields before applying defaults. */
6
+ export const validateOptions = (options: Options): Options => {
7
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
8
+ throw new TypeError('solver options must be an object')
9
+ }
10
+ for (const key of Object.keys(options)) {
11
+ if (key !== 'limits' && key !== 'unsatCore') throw new TypeError(`unknown solver option ${diagnosticText(key)}`)
12
+ }
13
+ const unsatCore = options.unsatCore
14
+ if (unsatCore !== undefined && typeof unsatCore !== 'boolean') {
15
+ throw new TypeError('solver option unsatCore must be a Boolean')
16
+ }
17
+ return { unsatCore, limits: mergeLimits(options.limits) }
18
+ }
package/src/result.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { Result } from './adapter.ts'
2
+ import { clone } from './clone.ts'
3
+ import { resultMetadata, unknownReason } from './validate.ts'
4
+
5
+ /** Capture the reported outcome before checking the adapter's proof contract. */
6
+ export const captureResult = (input: Result): Result => {
7
+ const result = clone(input)
8
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) {
9
+ throw new TypeError('solver result must be an object')
10
+ }
11
+ switch (result.status) {
12
+ case 'optimal':
13
+ if (result.optimalityProved !== true) throw new TypeError('optimal solver result requires optimalityProved: true')
14
+ break
15
+ case 'unsatisfied':
16
+ if (result.infeasibilityProved !== true) throw new TypeError('unsatisfied solver result requires infeasibilityProved: true')
17
+ break
18
+ case 'satisfied':
19
+ break
20
+ case 'unknown':
21
+ unknownReason(result.reason)
22
+ break
23
+ default:
24
+ throw new TypeError('solver result status must be satisfied, optimal, unsatisfied or unknown')
25
+ }
26
+ resultMetadata(result)
27
+ return result
28
+ }
package/src/solve.ts CHANGED
@@ -1,19 +1,41 @@
1
+ import { captureResult } from './result.ts'
2
+ import { validateContext } from './context.ts'
3
+ import { clone } from './clone.ts'
1
4
  import type { Options, Result, SolverAdapter } from './adapter.ts'
2
5
  import * as Capability from './capability.ts'
6
+ import * as Canonical from './canonical.ts'
3
7
  import * as Explain from './explain.ts'
4
8
  import type { Model } from './model.ts'
5
9
  import * as Validate from './validate.ts'
10
+ import { validateOptions } from './options.ts'
11
+
12
+ const freeze = <T>(value: T): T => {
13
+ const pending: unknown[] = [value]
14
+ const seen = new Set<object>()
15
+ while (pending.length > 0) {
16
+ const next = pending.pop()
17
+ if (next === null || typeof next !== 'object' || seen.has(next)) continue
18
+ seen.add(next)
19
+ for (const child of Object.values(next)) pending.push(child)
20
+ Object.freeze(next)
21
+ }
22
+ return value
23
+ }
6
24
 
7
25
  const prepared = (adapter: SolverAdapter, model: Model, options: Options): {
8
26
  readonly limits: ReturnType<typeof Validate.mergeLimits>
27
+ readonly model: Model
9
28
  readonly run: () => Promise<Result>
10
29
  } => {
11
- const limits = Validate.mergeLimits(options.limits)
30
+ options = validateOptions(options)
31
+ const limits = Object.freeze(Validate.mergeLimits(options.limits))
32
+ model = clone(model)
12
33
  Validate.model(model, limits)
34
+ model = freeze(model)
13
35
  const unsatCore = options.unsatCore ?? false
14
36
  const missing = Capability.missing(adapter, model, unsatCore)
15
37
  if (missing.length > 0) throw new Capability.UnsupportedModelError(adapter.backend.name, missing)
16
- return { limits, run: () => adapter.solve(model, { limits, unsatCore }) }
38
+ return { model, limits, run: async () => captureResult(await adapter.solve(model, { limits, unsatCore })) }
17
39
  }
18
40
 
19
41
  /** Validates and negotiates a model before crossing the adapter boundary. */
@@ -29,5 +51,14 @@ export const runWithExplanation = async (
29
51
  context: Explain.Context = {}
30
52
  ): Promise<Explain.Report> => {
31
53
  const solve = prepared(adapter, model, options)
32
- return Explain.report(model, await solve.run(), solve.limits, context)
54
+ validateContext(context)
55
+ const snapshot = clone(context)
56
+ validateContext(snapshot)
57
+ if (snapshot.modelDigest !== undefined) {
58
+ const digest = Canonical.digest(solve.model, solve.limits)
59
+ if (snapshot.modelDigest !== digest) {
60
+ throw new TypeError(`scenario model digest ${snapshot.modelDigest} does not match ${digest}`)
61
+ }
62
+ }
63
+ return Explain.report(solve.model, await solve.run(), solve.limits, snapshot)
33
64
  }