@cavelang/solver 0.34.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
package/src/explain.ts CHANGED
@@ -1,5 +1,16 @@
1
+ import { ExplanationBudget } from './explanation-budget.ts'
2
+ import { expressionKey } from './expression-key.ts'
3
+ import { constantSign, type Sign } from './constant-sign.ts'
4
+ import { captureResult } from './result.ts'
5
+ import { balancedReduce } from './balanced-reduce.ts'
6
+ import { sum } from './fraction-sum.ts'
7
+ import { product } from './fraction-product.ts'
8
+ import { gcd } from './integer-gcd.ts'
9
+ import { clone } from './clone.ts'
10
+ import { stringifyInput, validateContext } from './context.ts'
11
+ import { mergeLimits } from './validate.ts'
1
12
  import type { Backend, Diagnostic, Limits, ObjectiveValue, Result, UnknownReason } from './adapter.ts'
2
- import * as Canonical from './canonical.ts'
13
+ import { digestOwned } from './canonical-owned.ts'
3
14
  import * as Exact from './exact.ts'
4
15
  import type {
5
16
  Assignment,
@@ -79,10 +90,14 @@ export type AssignmentValue = Element & {
79
90
 
80
91
  export type Constraint = Element & {
81
92
  readonly evaluation: 'satisfied' | 'violated' | 'indeterminate'
93
+ /** Human-readable local evaluation failure; absent for determinate evaluations. */
94
+ readonly evaluationReason?: string
82
95
  }
83
96
 
84
97
  export type SoftConstraintResult = Element & {
85
98
  readonly evaluation: 'accepted' | 'violated' | 'indeterminate'
99
+ /** Human-readable local evaluation failure; absent for determinate evaluations. */
100
+ readonly evaluationReason?: string
86
101
  readonly weight: ReturnType<typeof Exact.rational>
87
102
  }
88
103
 
@@ -133,13 +148,30 @@ type Evaluated =
133
148
  | NumberValue
134
149
  | { readonly kind: 'enum', readonly domain: string, readonly value: string }
135
150
 
151
+ type EvaluationContext = {
152
+ readonly expressionResults: WeakMap<Expression, Evaluation>
153
+ readonly evaluations: Map<string, Evaluation>
154
+ readonly expressionStrings: Map<string, number>
155
+ readonly assignment: Assignment
156
+ readonly budget: ExplanationBudget
157
+ readonly signs: WeakMap<Expression, Sign>
158
+ readonly assigned: Map<string, { readonly value: Evaluated } | { readonly error: unknown }>
159
+ readonly variables: ReadonlyMap<string, Variable>
160
+ readonly enumDomains: ReadonlyMap<string, ReadonlySet<string>>
161
+ }
162
+
136
163
  const normalize = (numerator: bigint, denominator: bigint): NumberValue => {
137
164
  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) }
165
+ if (denominator < 0n) {
166
+ numerator = -numerator
167
+ denominator = -denominator
168
+ }
169
+ const divisor = gcd(numerator, denominator)
170
+ return { kind: 'number', numerator: numerator / divisor, denominator: denominator / divisor }
140
171
  }
141
172
 
142
- const number = (value: Rational): NumberValue => {
173
+ const number = (value: Rational, budget: ExplanationBudget): NumberValue => {
174
+ budget.input(value)
143
175
  const exact = Exact.rational(value)
144
176
  return { kind: 'number', numerator: BigInt(exact.numerator), denominator: BigInt(exact.denominator) }
145
177
  }
@@ -154,78 +186,214 @@ const bool = (value: Evaluated): boolean => {
154
186
  return value.value
155
187
  }
156
188
 
157
- const add = (left: NumberValue, right: NumberValue): NumberValue => normalize(
158
- left.numerator * right.denominator + right.numerator * left.denominator,
159
- left.denominator * right.denominator
160
- )
189
+ const add = (left: NumberValue, right: NumberValue, budget: ExplanationBudget): NumberValue => {
190
+ if (left.numerator === 0n) return right
191
+ if (right.numerator === 0n) return left
192
+ if (left.denominator === right.denominator) {
193
+ budget.check(budget.sum(left.numerator, right.numerator), budget.bits(left.denominator))
194
+ } else {
195
+ budget.check(Math.max(budget.product(left.numerator, right.denominator),
196
+ budget.product(right.numerator, left.denominator)) + 1,
197
+ budget.product(left.denominator, right.denominator))
198
+ }
199
+ // Adding an integer preserves gcd(numerator, denominator) = 1.
200
+ if (left.denominator === 1n) return { kind: 'number',
201
+ numerator: left.numerator * right.denominator + right.numerator, denominator: right.denominator }
202
+ if (right.denominator === 1n) return { kind: 'number',
203
+ numerator: left.numerator + right.numerator * left.denominator, denominator: left.denominator }
204
+ return normalize(...sum(left.numerator, left.denominator, right.numerator, right.denominator))
205
+ }
161
206
 
162
- const multiply = (left: NumberValue, right: NumberValue): NumberValue =>
163
- normalize(left.numerator * right.numerator, left.denominator * right.denominator)
207
+ const multiply = (left: NumberValue, right: NumberValue, budget: ExplanationBudget): NumberValue => {
208
+ budget.check(budget.product(left.numerator, right.numerator), budget.product(left.denominator, right.denominator))
209
+ // Evaluation values are reduced with positive denominators. Squaring preserves
210
+ // coprimality; otherwise cross-cancellation leaves a reduced product directly.
211
+ const [numerator, denominator] = left.numerator === right.numerator && left.denominator === right.denominator
212
+ ? [left.numerator * right.numerator, left.denominator * right.denominator]
213
+ : product(left.numerator, left.denominator, right.numerator, right.denominator)
214
+ return { kind: 'number', numerator, denominator }
215
+ }
164
216
 
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
217
+ const negate = (value: NumberValue): NumberValue =>
218
+ ({ kind: 'number', numerator: -value.numerator, denominator: value.denominator })
219
+
220
+ const compare = (left: NumberValue, right: NumberValue, budget: ExplanationBudget): -1 | 0 | 1 => {
221
+ // Evaluation values are normalized, so both denominators are positive.
222
+ if (left.denominator === right.denominator || left.numerator === 0n || right.numerator === 0n ||
223
+ (left.numerator < 0n) !== (right.numerator < 0n)) {
224
+ return left.numerator < right.numerator ? -1 : left.numerator > right.numerator ? 1 : 0
225
+ }
226
+ if (left.numerator === right.numerator) {
227
+ return (left.denominator < right.denominator) === (left.numerator > 0n) ? 1 : -1
228
+ }
229
+ if ((left.numerator < right.numerator) ===
230
+ (left.numerator > 0n ? left.denominator > right.denominator : left.denominator < right.denominator)) {
231
+ return left.numerator < right.numerator ? -1 : 1
232
+ }
233
+ budget.check(budget.product(left.numerator, right.denominator), budget.product(right.numerator, left.denominator))
234
+ const a = left.numerator * right.denominator
235
+ const b = right.numerator * left.denominator
236
+ return a < b ? -1 : a > b ? 1 : 0
168
237
  }
169
238
 
170
239
  const equal = (left: Evaluated, right: Evaluated): boolean => {
171
- if (left.kind === 'number' && right.kind === 'number') return compare(left, right) === 0
240
+ if (left.kind === 'number' && right.kind === 'number') {
241
+ // Every evaluated number is reduced with a positive denominator.
242
+ return left.numerator === right.numerator && left.denominator === right.denominator
243
+ }
172
244
  if (left.kind !== right.kind) return false
173
245
  if (left.kind === 'bool' && right.kind === 'bool') return left.value === right.value
174
246
  if (left.kind === 'enum' && right.kind === 'enum') return left.domain === right.domain && left.value === right.value
175
247
  return false
176
248
  }
177
249
 
178
- const assigned = (value: Value): Evaluated => {
250
+ const assigned = (value: Value, budget: ExplanationBudget): Evaluated => {
179
251
  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 }
252
+ case 'bool':
253
+ if (typeof value.value !== 'boolean') throw new TypeError('boolean assignment value must be a boolean')
254
+ return { kind: 'bool', value: value.value }
255
+ case 'int': return number({ numerator: value.value, denominator: '1' }, budget)
256
+ case 'real': return number({ numerator: value.numerator, denominator: value.denominator }, budget)
257
+ case 'enum':
258
+ if (typeof value.domain !== 'string' || typeof value.value !== 'string') throw new TypeError('enum assignment domain and value must be strings')
259
+ return { kind: 'enum', domain: value.domain, value: value.value }
184
260
  }
185
261
  }
186
262
 
187
- const evaluate = (expression: Expression, assignment: Assignment): Evaluated => {
188
- const run = (value: Expression): Evaluated => evaluate(value, assignment)
263
+ /** A captured assignment has one validation result for the whole report. */
264
+ const evaluateAssignment = (id: string, context: EvaluationContext): Evaluated => {
265
+ const cached = context.assigned.get(id)
266
+ if (cached !== undefined) {
267
+ if ('error' in cached) throw cached.error
268
+ return cached.value
269
+ }
270
+ try {
271
+ const value = context.assignment[id]
272
+ if (value === undefined) throw new TypeError(`assignment omits ${JSON.stringify(id)}`)
273
+ const evaluated = assigned(value, context.budget)
274
+ const variable = context.variables.get(id)!
275
+ const kind = variable.sort === 'int' || variable.sort === 'real' ? 'number' : variable.sort
276
+ if (evaluated.kind !== kind) throw new TypeError('assignment kind must match the declared variable')
277
+ if (variable.sort === 'int' && evaluated.kind === 'number' && evaluated.denominator !== 1n) {
278
+ throw new TypeError('integer variable assignment must be integral')
279
+ }
280
+ if (evaluated.kind === 'number' && (variable.sort === 'int' || variable.sort === 'real')) {
281
+ const min = variable.min === undefined ? undefined : variable.sort === 'int'
282
+ ? number({ numerator: variable.min, denominator: '1' }, context.budget) : number(variable.min, context.budget)
283
+ const max = variable.max === undefined ? undefined : variable.sort === 'int'
284
+ ? number({ numerator: variable.max, denominator: '1' }, context.budget) : number(variable.max, context.budget)
285
+ if ((min !== undefined && compare(evaluated, min, context.budget) < 0) || (max !== undefined && compare(evaluated, max, context.budget) > 0)) {
286
+ throw new TypeError('numeric assignment must lie within the declared bounds')
287
+ }
288
+ }
289
+ if (variable.sort === 'enum' && (evaluated.kind !== 'enum' || evaluated.domain !== variable.domain || !context.enumDomains.get(variable.domain)!.has(evaluated.value))) {
290
+ throw new TypeError('enum assignment must belong to the declared domain')
291
+ }
292
+ context.assigned.set(id, { value: evaluated })
293
+ return evaluated
294
+ } catch (error) {
295
+ context.assigned.set(id, { error })
296
+ throw error
297
+ }
298
+ }
299
+
300
+ function* evaluateNode(expression: Expression, context: EvaluationContext): Generator<Expression, Evaluated, Evaluated> {
301
+ const signOf = (value: Expression): Sign => constantSign(value, context.signs, literal => {
302
+ if (literal.kind !== 'literal' || (literal.sort !== 'int' && literal.sort !== 'real')) throw new TypeError('expected a numeric literal')
303
+ const input = literal.sort === 'int' ? { numerator: literal.value, denominator: '1' } : literal.value
304
+ context.budget.input(input)
305
+ return Exact.rational(input)
306
+ })
307
+ if (expression.kind === 'eq' || expression.kind === 'neq' || expression.kind === 'lt' ||
308
+ expression.kind === 'lte' || expression.kind === 'gt' || expression.kind === 'gte') {
309
+ const zero = (value: Expression): boolean => value.kind === 'literal' &&
310
+ (value.sort === 'int' || value.sort === 'real') && signOf(value) === 0
311
+ // A proof reads only captured constants. Unknown signs (including undefined
312
+ // arithmetic or any variable) retain ordinary assignment/error evaluation.
313
+ const sign = zero(expression.right) ? signOf(expression.left)
314
+ : zero(expression.left) ? signOf(expression.right) : undefined
315
+ if (sign !== undefined) {
316
+ const order = zero(expression.right) ? sign : -sign
317
+ return { kind: 'bool', value: expression.kind === 'eq' ? order === 0
318
+ : expression.kind === 'neq' ? order !== 0 : expression.kind === 'lt' ? order < 0
319
+ : expression.kind === 'lte' ? order <= 0 : expression.kind === 'gt' ? order > 0 : order >= 0 }
320
+ }
321
+ }
189
322
  switch (expression.kind) {
190
323
  case 'literal':
191
324
  switch (expression.sort) {
192
325
  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)
326
+ case 'int': return number({ numerator: expression.value, denominator: '1' }, context.budget)
327
+ case 'real': return number(expression.value, context.budget)
195
328
  case 'enum': return { kind: 'enum', domain: expression.domain, value: expression.value }
196
329
  }
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)
330
+ case 'variable': return evaluateAssignment(expression.id, context)
331
+ case 'not': return { kind: 'bool', value: !bool((yield expression.value)) }
332
+ case 'and':
333
+ for (const value of expression.operands) if (!bool(yield value)) return { kind: 'bool', value: false }
334
+ return { kind: 'bool', value: true }
335
+ case 'or':
336
+ for (const value of expression.operands) if (bool(yield value)) return { kind: 'bool', value: true }
337
+ return { kind: 'bool', value: false }
338
+ case 'implies': return { kind: 'bool', value: !bool((yield expression.left)) || bool((yield expression.right)) }
339
+ case 'eq': return { kind: 'bool', value: equal((yield expression.left), (yield expression.right)) }
340
+ case 'neq': return { kind: 'bool', value: !equal((yield expression.left), (yield expression.right)) }
341
+ case 'lt': return { kind: 'bool', value: compare(numeric((yield expression.left)), numeric((yield expression.right)), context.budget) < 0 }
342
+ case 'lte': return { kind: 'bool', value: compare(numeric((yield expression.left)), numeric((yield expression.right)), context.budget) <= 0 }
343
+ case 'gt': return { kind: 'bool', value: compare(numeric((yield expression.left)), numeric((yield expression.right)), context.budget) > 0 }
344
+ case 'gte': return { kind: 'bool', value: compare(numeric((yield expression.left)), numeric((yield expression.right)), context.budget) >= 0 }
345
+ case 'add':
346
+ case 'multiply': {
347
+ const values: Evaluated[] = []
348
+ for (const value of expression.operands) values.push(yield value)
349
+ const numbers = values.map(numeric)
350
+ // Evaluate and type-check every operand before applying the zero identity:
351
+ // zero must not hide undefined arithmetic in another operand.
352
+ if (expression.kind === 'multiply' && numbers.some(value => value.numerator === 0n)) {
353
+ return normalize(0n, 1n)
354
+ }
355
+ return balancedReduce(numbers, (left, right) => expression.kind === 'add'
356
+ ? add(left, right, context.budget) : multiply(left, right, context.budget))
201
357
  }
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
358
  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))
359
+ const left = numeric((yield expression.left))
360
+ const right = numeric((yield expression.right))
361
+ return add(left, negate(right), context.budget)
218
362
  }
219
363
  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)
364
+ const left = numeric((yield expression.left))
365
+ const right = numeric((yield expression.right))
366
+ if (right.numerator === 0n) throw new TypeError('cannot explain division by zero')
367
+ context.budget.check(context.budget.product(left.numerator, right.denominator),
368
+ context.budget.product(left.denominator, right.numerator))
369
+ // Cross-cancellation preserves reduction; only denominator sign can change.
370
+ const [numerator, denominator] = product(left.numerator, left.denominator, right.denominator, right.numerator)
371
+ return denominator < 0n
372
+ ? { kind: 'number', numerator: -numerator, denominator: -denominator }
373
+ : { kind: 'number', numerator, denominator }
223
374
  }
224
375
  case 'negate': {
225
- const value = numeric(run(expression.value))
226
- return normalize(-value.numerator, value.denominator)
376
+ const value = numeric((yield expression.value))
377
+ return negate(value)
378
+ }
379
+ case 'if': return bool((yield expression.condition)) ? (yield expression.then) : (yield expression.else)
380
+ }
381
+ }
382
+
383
+
384
+ const evaluate = (expression: Expression, context: EvaluationContext): Evaluated => {
385
+ const stack = [evaluateNode(expression, context)]
386
+ let step = stack[0]!.next()
387
+ while (true) {
388
+ if (step.done) {
389
+ stack.pop()
390
+ if (stack.length === 0) return step.value
391
+ step = stack[stack.length - 1]!.next(step.value)
392
+ } else {
393
+ const child = evaluateNode(step.value, context)
394
+ stack.push(child)
395
+ step = child.next()
227
396
  }
228
- case 'if': return bool(run(expression.condition)) ? run(expression.then) : run(expression.else)
229
397
  }
230
398
  }
231
399
 
@@ -240,25 +408,50 @@ const element = (value: { readonly id: string } & Provenance): Element => ({
240
408
  scenarioInputIds: sorted(value.scenarioInputIds)
241
409
  })
242
410
 
243
- const constraint = (value: HardConstraint, assignment: Assignment): Constraint => {
244
- let evaluation: Constraint['evaluation'] = 'indeterminate'
411
+ type Evaluation = {
412
+ readonly value?: boolean
413
+ readonly evaluationReason?: string
414
+ }
415
+
416
+ const evaluation = (expression: Expression, context: EvaluationContext): Evaluation => {
417
+ const repeated = context.expressionResults.get(expression)
418
+ if (repeated !== undefined) return repeated
419
+ const key = expressionKey(expression, context.expressionStrings)
420
+ const cached = context.evaluations.get(key)
421
+ if (cached !== undefined) {
422
+ context.expressionResults.set(expression, cached)
423
+ return cached
424
+ }
425
+ let result: Evaluation
245
426
  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
- })
427
+ result = { value: bool(evaluate(expression, context)) }
428
+ } catch (error) {
429
+ // Local arithmetic/assignment failure does not reclassify the backend result.
430
+ result = { evaluationReason: error instanceof Error ? error.message : 'constraint evaluation failed' }
431
+ }
432
+ context.evaluations.set(key, result)
433
+ context.expressionResults.set(expression, result)
434
+ return result
435
+ }
436
+
437
+ const constraint = (value: HardConstraint, context: EvaluationContext): Constraint => {
438
+ const result = evaluation(value.expression, context)
439
+ return {
440
+ ...element(value),
441
+ evaluation: result.value === undefined ? 'indeterminate' : result.value ? 'satisfied' : 'violated',
442
+ ...(result.evaluationReason === undefined ? {} : { evaluationReason: result.evaluationReason })
443
+ }
444
+ }
445
+
446
+ const softConstraint = (value: SoftConstraint, context: EvaluationContext): SoftConstraintResult => {
447
+ const result = evaluation(value.expression, context)
448
+ return {
449
+ ...element(value),
450
+ evaluation: result.value === undefined ? 'indeterminate' : result.value ? 'accepted' : 'violated',
451
+ ...(result.evaluationReason === undefined ? {} : { evaluationReason: result.evaluationReason }),
452
+ weight: Exact.rational(value.weight)
453
+ }
454
+ }
262
455
 
263
456
  const assignments = (model: Model, assignment: Assignment): readonly AssignmentValue[] => {
264
457
  const variables = new Map(model.variables.map(variable => [variable.id, variable]))
@@ -270,11 +463,19 @@ const assignments = (model: Model, assignment: Assignment): readonly AssignmentV
270
463
  })
271
464
  }
272
465
 
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
- })
466
+ const feasible = (model: Model, assignment: Assignment, limits: Limits): Feasible => {
467
+ // The report owns a captured assignment; normalized values live only for this report.
468
+ const context: EvaluationContext = {
469
+ assignment, budget: new ExplanationBudget(limits.maxExplanationBits, limits.maxExplanationWork), assigned: new Map(), signs: new WeakMap(), expressionResults: new WeakMap(), evaluations: new Map(), expressionStrings: new Map(),
470
+ variables: new Map(model.variables.map(variable => [variable.id, variable])),
471
+ enumDomains: new Map((model.enums ?? []).map(domain => [domain.id, new Set(domain.values)]))
472
+ }
473
+ return {
474
+ assignments: assignments(model, assignment),
475
+ hardConstraints: model.constraints.map(value => constraint(value, context)),
476
+ softConstraints: (model.softConstraints ?? []).map(value => softConstraint(value, context))
477
+ }
478
+ }
278
479
 
279
480
  const objective = (declaration: Objective | undefined, result: ObjectiveValue): ObjectiveResult => ({
280
481
  ...element(declaration ?? { id: result.objectiveId }),
@@ -284,14 +485,14 @@ const objective = (declaration: Objective | undefined, result: ObjectiveValue):
284
485
  ...(result.bound === undefined ? {} : { bound: result.bound })
285
486
  })
286
487
 
287
- const outcome = (model: Model, result: Result): Outcome => {
488
+ const outcome = (model: Model, result: Result, limits: Limits): Outcome => {
288
489
  switch (result.status) {
289
- case 'satisfied': return { status: result.status, ...feasible(model, result.assignment) }
490
+ case 'satisfied': return { status: result.status, ...feasible(model, result.assignment, limits) }
290
491
  case 'optimal': {
291
492
  const declarations = new Map((model.objectives ?? []).map(value => [value.id, value]))
292
493
  return {
293
494
  status: result.status,
294
- ...feasible(model, result.assignment),
495
+ ...feasible(model, result.assignment, limits),
295
496
  objectives: result.objectives.map(value => objective(declarations.get(value.objectiveId), value)),
296
497
  optimalityProved: result.optimalityProved
297
498
  }
@@ -316,10 +517,22 @@ const outcome = (model: Model, result: Result): Outcome => {
316
517
 
317
518
  /** Build stable JSON data without mutating the model, scenario, or CAVE store. */
318
519
  export const report = (model: Model, result: Result, limits: Limits, context: Context = {}): Report => {
319
- const modelDigest = Canonical.digest(model)
520
+ limits = mergeLimits(limits)
521
+ model = clone(model)
522
+ result = captureResult(result)
523
+ validateContext(context)
524
+ context = clone({
525
+ modelDigest: context.modelDigest,
526
+ scenario: context.scenario,
527
+ snapshot: context.snapshot,
528
+ inputs: context.inputs,
529
+ })
530
+ validateContext(context)
531
+ const modelDigest = digestOwned(model, limits)
320
532
  if (context.modelDigest !== undefined && context.modelDigest !== modelDigest) {
321
533
  throw new TypeError(`scenario model digest ${context.modelDigest} does not match ${modelDigest}`)
322
534
  }
535
+ // All referenced data is owned by the captures above or constructed in this call.
323
536
  return {
324
537
  schema,
325
538
  run: {
@@ -332,69 +545,104 @@ export const report = (model: Model, result: Result, limits: Limits, context: Co
332
545
  ...(context.snapshot === undefined ? {} : { snapshot: context.snapshot }),
333
546
  inputs: [...(context.inputs ?? [])].sort((left, right) => compareText(left.id, right.id))
334
547
  },
335
- outcome: outcome(model, result)
548
+ outcome: outcome(model, result, limits)
549
+ }
550
+ }
551
+
552
+ const sourceLocation = (declaration: Declaration): string => {
553
+ if (declaration.line !== undefined) {
554
+ return `${inlineText(declaration.uri)}:${declaration.line}${declaration.column === undefined ? '' : `:${declaration.column}`}`
336
555
  }
556
+ return `${inlineText(declaration.uri)}${declaration.column === undefined ? '' : ` (column ${declaration.column})`}`
337
557
  }
338
558
 
339
559
  const evidence = (value: Element): string => {
340
560
  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(', ')}`
561
+ value.declaration === undefined ? undefined : sourceLocation(value.declaration),
562
+ value.evidenceRowIds.length === 0 ? undefined : `rows ${value.evidenceRowIds.map(inlineText).join(', ')}`,
563
+ value.scenarioInputIds.length === 0 ? undefined : `inputs ${value.scenarioInputIds.map(inlineText).join(', ')}`
344
564
  ].filter((part): part is string => part !== undefined)
345
565
  return parts.length === 0 ? '' : ` — ${parts.join('; ')}`
346
566
  }
347
567
 
348
568
  const valueText = (value: Value): string => {
569
+ const invalid = '(invalid backend value)'
570
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return invalid
349
571
  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
572
+ case 'bool': return typeof value.value === 'boolean' ? String(value.value) : invalid
573
+ case 'int': return typeof value.value === 'string' ? inlineText(value.value) : invalid
574
+ case 'real': return typeof value.numerator === 'string' && typeof value.denominator === 'string'
575
+ ? inlineText(`${value.numerator}/${value.denominator}`) : invalid
576
+ case 'enum': return typeof value.domain === 'string' && typeof value.value === 'string' ? inlineText(value.value) : invalid
577
+ default: return invalid
354
578
  }
355
579
  }
356
580
 
581
+ const jsonControlEscapes = new Map(
582
+ [...Array.from({ length: 33 }, (_, index) => index + 0x7f), 0x2028, 0x2029]
583
+ .map(code => [String.fromCharCode(code), `\\u${code.toString(16).padStart(4, '0')}`])
584
+ )
585
+
586
+ const escapeJsonControls = (text: string): string =>
587
+ text.replace(/[\u007f-\u009f\u2028\u2029]/g, character => jsonControlEscapes.get(character)!)
588
+
589
+ const quotedText = (message: string): string => escapeJsonControls(JSON.stringify(message))
590
+
591
+ const inlineText = (message: string): string =>
592
+ /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(message) ? quotedText(message) : message
593
+
357
594
  /** Render the same report as concise, deterministic plain text. */
358
595
  export const render = (value: Report): string => {
359
596
  const lines = [
360
597
  `Solver result: ${value.outcome.status}`,
361
- `Model: ${value.run.modelDigest}`,
362
- `Backend: ${value.run.backend.name} ${value.run.backend.version}`,
598
+ `Model: ${inlineText(value.run.modelDigest)}`,
599
+ `Backend: ${inlineText(value.run.backend.name)} ${inlineText(value.run.backend.version)}`,
363
600
  `Elapsed: ${value.run.elapsedMs} ms`
364
601
  ]
365
602
  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}`}`)
603
+ const snapshot = value.run.snapshot
604
+ const policies: string[] = []
605
+ if (snapshot.aliases !== undefined) policies.push(`aliases ${snapshot.aliases}`)
606
+ if (snapshot.resolution !== undefined) policies.push(`resolution ${snapshot.resolution}`)
607
+ if (snapshot.minimumConfidence !== undefined) policies.push(`minimum confidence ${snapshot.minimumConfidence}`)
608
+ lines.push(`Snapshot: transaction ${inlineText(snapshot.transactionTime ?? 'empty')}${snapshot.validTime === undefined ? '' : `, valid ${inlineText(snapshot.validTime)}`}${policies.length === 0 ? '' : `; ${policies.join('; ')}`}`)
367
609
  }
368
- if (value.run.scenario !== undefined) lines.push(`Scenario: ${value.run.scenario.id} (${value.run.scenario.inputDigest})`)
610
+ if (value.run.scenario !== undefined) lines.push(`Scenario: ${inlineText(value.run.scenario.id)} (input ${inlineText(value.run.scenario.inputDigest)}; overlay ${inlineText(value.run.scenario.overlayDigest)})`)
369
611
  for (const input of value.run.inputs) {
370
- lines.push(`Input ${input.id}: ${JSON.stringify(input.value)}${input.query === undefined ? '' : ` via ${input.query}`}`)
612
+ const values: string[] = []
613
+ if (input.value !== undefined) values.push(escapeJsonControls(stringifyInput(input.value)))
614
+ if (input.authoredValue !== undefined) values.push(`authored ${escapeJsonControls(stringifyInput(input.authoredValue))}`)
615
+ const sources: string[] = []
616
+ if (input.evidenceRowIds.length > 0) sources.push(`rows ${sorted(input.evidenceRowIds).map(inlineText).join(', ')}`)
617
+ if (input.scenarioClaimIds.length > 0) sources.push(`scenario claims ${sorted(input.scenarioClaimIds).map(inlineText).join(', ')}`)
618
+ lines.push(`Input ${inlineText(input.id)}: ${values.length === 0 ? '(no value)' : values.join('; ')}${input.query === undefined ? '' : ` via ${inlineText(input.query)}`}${sources.length === 0 ? '' : ` — ${sources.join('; ')}`}`)
371
619
  }
372
620
  switch (value.outcome.status) {
373
621
  case 'satisfied':
374
622
  case 'optimal':
375
623
  for (const assignment of value.outcome.assignments) {
376
- lines.push(`Assignment ${assignment.id} = ${valueText(assignment.value)}${assignment.declared ? '' : ' (undeclared backend ID)'}${evidence(assignment)}`)
624
+ lines.push(`Assignment ${inlineText(assignment.id)} = ${valueText(assignment.value)}${assignment.declared ? '' : ' (undeclared backend ID)'}${evidence(assignment)}`)
377
625
  }
378
626
  for (const constraint of value.outcome.hardConstraints) {
379
- lines.push(`Hard constraint ${constraint.id}: ${constraint.evaluation}${evidence(constraint)}`)
627
+ lines.push(`Hard constraint ${inlineText(constraint.id)}: ${constraint.evaluation}${constraint.evaluationReason === undefined ? '' : ` (${quotedText(constraint.evaluationReason)})`}${evidence(constraint)}`)
380
628
  }
381
629
  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)}`)
630
+ lines.push(`Soft constraint ${inlineText(constraint.id)}: ${constraint.evaluation}${constraint.evaluationReason === undefined ? '' : ` (${quotedText(constraint.evaluationReason)})`}, weight ${constraint.weight.numerator}/${constraint.weight.denominator}${evidence(constraint)}`)
383
631
  }
384
632
  if (value.outcome.status === 'optimal') {
385
633
  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)}`)
634
+ lines.push(`Objective ${inlineText(objective.id)} (${objective.direction}) = ${valueText(objective.value)}${objective.declared ? '' : ' (undeclared backend ID)'}${evidence(objective)}`)
387
635
  }
388
636
  }
389
637
  break
390
638
  case 'unsatisfied':
391
639
  lines.push('Infeasibility proved. Unsatisfiable cores are not necessarily minimal.')
392
640
  for (const constraint of value.outcome.core ?? []) {
393
- lines.push(`Core constraint ${constraint.id}${constraint.declared ? '' : ' (undeclared backend ID)'}${evidence(constraint)}`)
641
+ lines.push(`Core constraint ${inlineText(constraint.id)}${constraint.declared ? '' : ' (undeclared backend ID)'}${evidence(constraint)}`)
394
642
  }
395
643
  break
396
644
  case 'unknown':
397
- lines.push(`Unknown: ${value.outcome.reason.kind} — ${value.outcome.reason.message}`)
645
+ lines.push(`Unknown: ${value.outcome.reason.kind} — ${inlineText(value.outcome.reason.message)}`)
398
646
  break
399
647
  }
400
648
  return `${lines.join('\n')}\n`
@@ -0,0 +1,47 @@
1
+ import type { Rational } from './model.ts'
2
+ import { numericDigits } from './numeric-size.ts'
3
+
4
+ /** Conservative pre-allocation bounds for local fraction arithmetic, not a time or heap limit. */
5
+ export class ExplanationBudget {
6
+ readonly maximum: number
7
+ readonly maximumWork: number
8
+ private used = 0
9
+ constructor(maximum: number, maximumWork = Number.MAX_SAFE_INTEGER) {
10
+ this.maximum = maximum
11
+ this.maximumWork = maximumWork
12
+ }
13
+
14
+ check(...bounds: number[]): void {
15
+ const actual = Math.max(0, ...bounds)
16
+ if (actual > this.maximum) {
17
+ throw new RangeError(`explanation exceeds maxExplanationBits: estimated ${actual} > ${this.maximum}`)
18
+ }
19
+ if (actual > this.maximumWork - this.used) {
20
+ throw new RangeError(`explanation exceeds maxExplanationWork: estimated ${actual} with ${this.maximumWork - this.used} remaining`)
21
+ }
22
+ this.used += actual
23
+ }
24
+
25
+ input(value: Rational): void {
26
+ // Four bits per decimal digit also bounds either member of an unreduced pair.
27
+ // Charge text before Exact can allocate powers of ten or parse large BigInts.
28
+ this.check(numericDigits(value, 'real') * 4)
29
+ }
30
+
31
+ bits(value: bigint): number {
32
+ return value === 0n ? 0 : (value < 0n ? -value : value).toString(2).length
33
+ }
34
+
35
+ product(left: bigint, right: bigint): number {
36
+ if (left === 0n || right === 0n) return 0
37
+ if (left === 1n || left === -1n) return this.bits(right)
38
+ if (right === 1n || right === -1n) return this.bits(left)
39
+ return this.bits(left) + this.bits(right)
40
+ }
41
+
42
+ sum(left: bigint, right: bigint): number {
43
+ if (left === 0n) return this.bits(right)
44
+ if (right === 0n) return this.bits(left)
45
+ return Math.max(this.bits(left), this.bits(right)) + 1
46
+ }
47
+ }