@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
package/src/workflow.ts CHANGED
@@ -1,5 +1,10 @@
1
+ import { validateContext } from './context.ts'
2
+ import { clone } from './clone.ts'
3
+ import { numericDigits } from './numeric-size.ts'
4
+ import { defaultLimits } from './adapter.ts'
1
5
  import type { Limits, Options, Result, SolverAdapter } from './adapter.ts'
2
6
  import * as Canonical from './canonical.ts'
7
+ import * as Capability from './capability.ts'
3
8
  import * as Exact from './exact.ts'
4
9
  import * as Explain from './explain.ts'
5
10
  import type {
@@ -13,6 +18,7 @@ import type {
13
18
  } from './model.ts'
14
19
  import * as Solve from './solve.ts'
15
20
  import * as Validate from './validate.ts'
21
+ import { validateOptions } from './options.ts'
16
22
 
17
23
  export const schema = 'cave.solver/workflow@1' as const
18
24
 
@@ -158,11 +164,11 @@ const boundedScope = (model: Model, limits: Partial<Limits> = {}): Scope => {
158
164
  }
159
165
  })
160
166
  .sort((left, right) => compareText(left.variableId, right.variableId))
161
- const sorts = new Set(model.variables.map(variable => variable.sort))
167
+ const required = Capability.required(model)
162
168
  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')
169
+ if (required.has('integers')) theories.push('bounded-integers')
170
+ if (required.has('rationals')) theories.push('exact-rationals')
171
+ if (required.has('finite-enums')) theories.push('finite-enums')
166
172
  return {
167
173
  domains,
168
174
  assumptions: model.constraints.map(constraint => constraint.id),
@@ -174,13 +180,20 @@ const variableMap = (model: Model): ReadonlyMap<string, Variable> =>
174
180
  new Map(model.variables.map(variable => [variable.id, variable]))
175
181
 
176
182
  const normalizedValue = (variable: Variable, value: Value, domains: ReadonlyMap<string, readonly string[]>): Value => {
183
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
184
+ throw new WorkflowValidationError(`value for variable ${JSON.stringify(variable.id)} must be an object`)
185
+ }
177
186
  if (variable.sort !== value.sort) {
178
187
  throw new WorkflowValidationError(
179
188
  `variable ${JSON.stringify(variable.id)} is ${variable.sort}, received ${value.sort}`
180
189
  )
181
190
  }
182
191
  switch (value.sort) {
183
- case 'bool': return value
192
+ case 'bool':
193
+ if (typeof value.value !== 'boolean') {
194
+ throw new WorkflowValidationError(`value for variable ${JSON.stringify(variable.id)} must be a boolean`)
195
+ }
196
+ return { sort: value.sort, value: value.value }
184
197
  case 'int': {
185
198
  const normalized = String(Exact.integer(value.value))
186
199
  if (Exact.compare(normalized, String(Exact.integer((variable as Variable & { sort: 'int' }).min))) < 0 ||
@@ -208,7 +221,7 @@ const normalizedValue = (variable: Variable, value: Value, domains: ReadonlyMap<
208
221
  if (!(domains.get(value.domain) ?? []).includes(value.value)) {
209
222
  throw new WorkflowValidationError(`value ${JSON.stringify(value.value)} is outside enum ${JSON.stringify(value.domain)}`)
210
223
  }
211
- return value
224
+ return { sort: value.sort, domain: value.domain, value: value.value }
212
225
  }
213
226
  }
214
227
  }
@@ -353,7 +366,14 @@ const run = async (
353
366
  options: Options,
354
367
  context: Explain.Context
355
368
  ): Promise<{ readonly explanation: Explain.Report, readonly tieBreak: readonly TieBreak[] }> => {
369
+ validateContext(context)
356
370
  const limits = Validate.mergeLimits(options.limits)
371
+ if (context.modelDigest !== undefined) {
372
+ const digest = Canonical.digest(sourceModel, limits)
373
+ if (context.modelDigest !== digest) {
374
+ throw new TypeError(`scenario model digest ${context.modelDigest} does not match ${digest}`)
375
+ }
376
+ }
357
377
  const prepared = preparedModel(solveModel, kind)
358
378
  const result = await Solve.run(adapter, prepared.model, {
359
379
  limits,
@@ -369,7 +389,6 @@ const run = async (
369
389
  }
370
390
 
371
391
  const report = (
372
- model: Model,
373
392
  operation: Operation,
374
393
  scope: Scope,
375
394
  tie: readonly TieBreak[],
@@ -377,7 +396,7 @@ const report = (
377
396
  ): Report => ({
378
397
  schema,
379
398
  operation,
380
- modelDigest: Canonical.digest(model),
399
+ modelDigest: explanation.run.modelDigest,
381
400
  scope,
382
401
  tieBreak: tie,
383
402
  explanation
@@ -389,9 +408,12 @@ export const feasibility = async (
389
408
  options: Options = {},
390
409
  context: Explain.Context = {}
391
410
  ): Promise<Report> => {
411
+ options = validateOptions(options)
412
+ validateContext(context)
413
+ ;({ model, options, context } = clone({ model, options, context }))
392
414
  const scope = boundedScope(model, options.limits)
393
415
  const result = await run(adapter, model, model, 'feasibility', options, context)
394
- return report(model, { kind: 'feasibility' }, scope, result.tieBreak, result.explanation)
416
+ return report({ kind: 'feasibility' }, scope, result.tieBreak, result.explanation)
395
417
  }
396
418
 
397
419
  export const optimization = async (
@@ -400,9 +422,12 @@ export const optimization = async (
400
422
  options: Options = {},
401
423
  context: Explain.Context = {}
402
424
  ): Promise<Report> => {
425
+ options = validateOptions(options)
426
+ validateContext(context)
427
+ ;({ model, options, context } = clone({ model, options, context }))
403
428
  const scope = boundedScope(model, options.limits)
404
429
  const result = await run(adapter, model, model, 'optimization', options, context)
405
- return report(model, { kind: 'optimization' }, scope, result.tieBreak, result.explanation)
430
+ return report({ kind: 'optimization' }, scope, result.tieBreak, result.explanation)
406
431
  }
407
432
 
408
433
  export const counterexample = async (
@@ -412,6 +437,9 @@ export const counterexample = async (
412
437
  options: Options = {},
413
438
  context: Explain.Context = {}
414
439
  ): Promise<Report> => {
440
+ options = validateOptions(options)
441
+ validateContext(context)
442
+ ;({ model, options, context } = clone({ model, options, context }))
415
443
  const baseScope = boundedScope(model, options.limits)
416
444
  const invariant = model.constraints.find(constraint => constraint.id === invariantId)
417
445
  if (invariant === undefined) {
@@ -432,7 +460,7 @@ export const counterexample = async (
432
460
  ...baseScope,
433
461
  assumptions: baseScope.assumptions.filter(id => id !== invariantId)
434
462
  }
435
- return report(model, { kind: 'counterexample', invariantId, verdict }, scope, result.tieBreak, result.explanation)
463
+ return report({ kind: 'counterexample', invariantId, verdict }, scope, result.tieBreak, result.explanation)
436
464
  }
437
465
 
438
466
  const bindingConstraint = (binding: Binding, id: string): HardConstraint => ({
@@ -446,10 +474,14 @@ const bindingConstraint = (binding: Binding, id: string): HardConstraint => ({
446
474
  })
447
475
 
448
476
  const normalizeBindings = (model: Model, bindings: readonly Binding[]): readonly Binding[] => {
477
+ if (!Array.isArray(bindings)) throw new WorkflowValidationError('fixed bindings must be an array')
449
478
  const variables = variableMap(model)
450
479
  const domains = new Map((model.enums ?? []).map(domain => [domain.id, domain.values]))
451
480
  const seen = new Set<string>()
452
- return bindings.map(binding => {
481
+ return Array.from(bindings, (binding, index) => {
482
+ if (binding === null || typeof binding !== 'object' || Array.isArray(binding)) {
483
+ throw new WorkflowValidationError(`fixed binding at index ${index} must be an object`)
484
+ }
453
485
  if (seen.has(binding.variableId)) {
454
486
  throw new WorkflowValidationError(`variable ${JSON.stringify(binding.variableId)} is fixed more than once`)
455
487
  }
@@ -517,20 +549,49 @@ export const sensitivity = async (
517
549
  options: Options = {},
518
550
  context: Explain.Context = {}
519
551
  ): Promise<SensitivityReport> => {
552
+ options = validateOptions(options)
553
+ validateContext(context)
554
+ ;({ model, options, context, request } = clone({ model, options, context, request }))
520
555
  const scope = boundedScope(model, options.limits)
556
+ if (request === null || typeof request !== 'object' || Array.isArray(request)) {
557
+ throw new WorkflowValidationError('sensitivity request must be an object')
558
+ }
559
+ if (!Array.isArray(request.samples)) {
560
+ throw new WorkflowValidationError('sensitivity samples must be an array')
561
+ }
562
+ for (const field of ['fixed', 'observe'] as const) {
563
+ if (request[field] !== undefined && !Array.isArray(request[field])) {
564
+ throw new WorkflowValidationError(`sensitivity ${field} must be an array`)
565
+ }
566
+ }
521
567
  const maxRuns = request.maxRuns ?? defaultMaxSensitivityRuns
522
568
  if (!Number.isSafeInteger(maxRuns) || maxRuns <= 0) {
523
- throw new WorkflowValidationError(`maxRuns must be a positive safe integer, received ${String(maxRuns)}`)
569
+ const received = typeof maxRuns === 'object' || typeof maxRuns === 'function' ? typeof maxRuns : String(maxRuns)
570
+ throw new WorkflowValidationError(`maxRuns must be a positive safe integer, received ${received}`)
524
571
  }
525
572
  if (request.samples.length === 0) throw new WorkflowValidationError('sensitivity needs at least one sample')
526
573
  if (request.samples.length > maxRuns) {
527
574
  throw new WorkflowValidationError(`sensitivity samples exceed maxRuns: ${request.samples.length} > ${maxRuns}`)
528
575
  }
576
+ const maximumDigits = options.limits?.maxNumericDigits ?? defaultLimits.maxNumericDigits
577
+ let requestDigits = 0
578
+ const charge = (value: Value): void => {
579
+ if (value === null || typeof value !== 'object') return // Shape validation below supplies context.
580
+ if (value.sort === 'int') requestDigits += numericDigits(value.value, 'int')
581
+ else if (value.sort === 'real') requestDigits += numericDigits(value, 'real')
582
+ if (requestDigits > maximumDigits) {
583
+ throw new WorkflowValidationError(`sensitivity request exceeds maxNumericDigits: ${requestDigits} > ${maximumDigits}`)
584
+ }
585
+ }
586
+ for (const value of request.samples) charge(value)
587
+ for (const binding of request.fixed ?? []) {
588
+ if (binding !== null && typeof binding === 'object') charge(binding.value)
589
+ }
529
590
  const variables = variableMap(model)
530
591
  const selected = variables.get(request.variableId)
531
592
  if (selected === undefined) throw new WorkflowValidationError(`unknown sensitivity variable ${JSON.stringify(request.variableId)}`)
532
593
  const domains = new Map((model.enums ?? []).map(domain => [domain.id, domain.values]))
533
- const samples = request.samples.map(value => normalizedValue(selected, value, domains))
594
+ const samples = Array.from(request.samples, value => normalizedValue(selected, value, domains))
534
595
  if (new Set(samples.map(value => JSON.stringify(value))).size !== samples.length) {
535
596
  throw new WorkflowValidationError('sensitivity samples contain duplicate values')
536
597
  }
@@ -543,31 +604,36 @@ export const sensitivity = async (
543
604
  if (!variables.has(id)) throw new WorkflowValidationError(`unknown observed variable ${JSON.stringify(id)}`)
544
605
  }
545
606
  const kind = request.operation ?? 'optimization'
607
+ if (request.operation !== undefined && request.operation !== 'feasibility' && request.operation !== 'optimization') {
608
+ throw new WorkflowValidationError('sensitivity operation must be feasibility or optimization')
609
+ }
546
610
  const used = new Set(model.constraints.map(constraint => constraint.id))
547
611
  const fixedConstraints = fixed.map((binding, index) =>
548
612
  bindingConstraint(binding, freshId(used, `fixed/${index}`)))
613
+ const sampleConstraints = samples.map((value, index) =>
614
+ bindingConstraint({ variableId: request.variableId, value }, freshId(used, `sample/${index}`)))
615
+ const modelForSample = (constraint: HardConstraint): Model => ({
616
+ ...model,
617
+ constraints: [...model.constraints, ...fixedConstraints, constraint]
618
+ })
619
+ // Validate the complete batch before any backend call without retaining a
620
+ // separate copy of all model constraints for every sample.
621
+ for (const constraint of sampleConstraints) {
622
+ Validate.model(preparedModel(modelForSample(constraint), kind).model, options.limits)
623
+ }
549
624
  const points: SensitivityPoint[] = []
550
625
  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)
626
+ const solved = await run(adapter, model, modelForSample(sampleConstraints[index]!), kind, options, context)
561
627
  points.push({
562
628
  index,
563
629
  value,
564
- report: report(model, { kind }, scope, solved.tieBreak, solved.explanation)
630
+ report: report({ kind }, scope, solved.tieBreak, solved.explanation)
565
631
  })
566
632
  }
567
633
  return {
568
634
  schema,
569
635
  operation: 'sensitivity',
570
- modelDigest: Canonical.digest(model),
636
+ modelDigest: points[0]!.report.modelDigest,
571
637
  variableId: request.variableId,
572
638
  fixed,
573
639
  observe,