@cavelang/solver 0.27.14 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/explain.ts ADDED
@@ -0,0 +1,401 @@
1
+ import type { Backend, Diagnostic, Limits, ObjectiveValue, Result, UnknownReason } from './adapter.ts'
2
+ import * as Canonical from './canonical.ts'
3
+ import * as Exact from './exact.ts'
4
+ import type {
5
+ Assignment,
6
+ Declaration,
7
+ Expression,
8
+ HardConstraint,
9
+ Model,
10
+ Objective,
11
+ Provenance,
12
+ Rational,
13
+ SoftConstraint,
14
+ Value,
15
+ Variable
16
+ } from './model.ts'
17
+
18
+ export const schema = 'cave.solver/explanation@1' as const
19
+
20
+ const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
21
+
22
+ export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json | undefined }
23
+
24
+ export type Snapshot = {
25
+ readonly transactionTime: string | null
26
+ readonly validTime?: string
27
+ readonly aliases?: 'exact' | 'closure'
28
+ readonly resolution?: 'coexisting' | 'winner'
29
+ readonly minimumConfidence?: number
30
+ }
31
+
32
+ export type Input = {
33
+ readonly id: string
34
+ readonly query?: string
35
+ readonly value?: Json
36
+ readonly authoredValue?: Json
37
+ readonly evidenceRowIds: readonly string[]
38
+ readonly scenarioClaimIds: readonly string[]
39
+ }
40
+
41
+ export type Scenario = {
42
+ readonly id: string
43
+ readonly inputDigest: string
44
+ readonly overlayDigest: string
45
+ }
46
+
47
+ export type Context = {
48
+ /** Reject accidentally replaying bindings against a different model. */
49
+ readonly modelDigest?: string
50
+ readonly scenario?: Scenario
51
+ readonly snapshot?: Snapshot
52
+ readonly inputs?: readonly Input[]
53
+ }
54
+
55
+ export type Run = {
56
+ readonly modelDigest: string
57
+ readonly backend: Backend
58
+ readonly elapsedMs: number
59
+ readonly limits: Limits
60
+ readonly diagnostics: readonly Diagnostic[]
61
+ readonly scenario?: Scenario
62
+ readonly snapshot?: Snapshot
63
+ readonly inputs: readonly Input[]
64
+ }
65
+
66
+ export type Element = {
67
+ readonly id: string
68
+ readonly description?: string
69
+ readonly declaration?: Declaration
70
+ readonly evidenceRowIds: readonly string[]
71
+ readonly scenarioInputIds: readonly string[]
72
+ }
73
+
74
+ export type AssignmentValue = Element & {
75
+ /** False exposes an adapter value that has no matching model declaration. */
76
+ readonly declared: boolean
77
+ readonly value: Value
78
+ }
79
+
80
+ export type Constraint = Element & {
81
+ readonly evaluation: 'satisfied' | 'violated' | 'indeterminate'
82
+ }
83
+
84
+ export type SoftConstraintResult = Element & {
85
+ readonly evaluation: 'accepted' | 'violated' | 'indeterminate'
86
+ readonly weight: ReturnType<typeof Exact.rational>
87
+ }
88
+
89
+ export type ObjectiveResult = Element & {
90
+ /** False exposes an adapter value that has no matching model declaration. */
91
+ readonly declared: boolean
92
+ readonly direction: 'minimize' | 'maximize' | 'unknown'
93
+ readonly value: Value
94
+ readonly bound?: Value
95
+ }
96
+
97
+ export type CoreConstraint = Element & {
98
+ /** False means the backend returned an ID absent from the submitted model. */
99
+ readonly declared: boolean
100
+ }
101
+
102
+ type Feasible = {
103
+ readonly assignments: readonly AssignmentValue[]
104
+ readonly hardConstraints: readonly Constraint[]
105
+ readonly softConstraints: readonly SoftConstraintResult[]
106
+ }
107
+
108
+ export type Outcome =
109
+ | ({ readonly status: 'satisfied' } & Feasible)
110
+ | ({
111
+ readonly status: 'optimal'
112
+ readonly objectives: readonly ObjectiveResult[]
113
+ readonly optimalityProved: true
114
+ } & Feasible)
115
+ | {
116
+ readonly status: 'unsatisfied'
117
+ readonly core?: readonly CoreConstraint[]
118
+ /** Solver cores explain one contradiction, but are not promised minimal. */
119
+ readonly coreMinimal: false
120
+ readonly infeasibilityProved: true
121
+ }
122
+ | { readonly status: 'unknown', readonly reason: UnknownReason }
123
+
124
+ export type Report = {
125
+ readonly schema: typeof schema
126
+ readonly run: Run
127
+ readonly outcome: Outcome
128
+ }
129
+
130
+ type NumberValue = { readonly kind: 'number', readonly numerator: bigint, readonly denominator: bigint }
131
+ type Evaluated =
132
+ | { readonly kind: 'bool', readonly value: boolean }
133
+ | NumberValue
134
+ | { readonly kind: 'enum', readonly domain: string, readonly value: string }
135
+
136
+ const normalize = (numerator: bigint, denominator: bigint): NumberValue => {
137
+ 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) }
140
+ }
141
+
142
+ const number = (value: Rational): NumberValue => {
143
+ const exact = Exact.rational(value)
144
+ return { kind: 'number', numerator: BigInt(exact.numerator), denominator: BigInt(exact.denominator) }
145
+ }
146
+
147
+ const numeric = (value: Evaluated): NumberValue => {
148
+ if (value.kind !== 'number') throw new TypeError(`expected a numeric explanation value, received ${value.kind}`)
149
+ return value
150
+ }
151
+
152
+ const bool = (value: Evaluated): boolean => {
153
+ if (value.kind !== 'bool') throw new TypeError(`expected a Boolean explanation value, received ${value.kind}`)
154
+ return value.value
155
+ }
156
+
157
+ const add = (left: NumberValue, right: NumberValue): NumberValue => normalize(
158
+ left.numerator * right.denominator + right.numerator * left.denominator,
159
+ left.denominator * right.denominator
160
+ )
161
+
162
+ const multiply = (left: NumberValue, right: NumberValue): NumberValue =>
163
+ normalize(left.numerator * right.numerator, left.denominator * right.denominator)
164
+
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
168
+ }
169
+
170
+ const equal = (left: Evaluated, right: Evaluated): boolean => {
171
+ if (left.kind === 'number' && right.kind === 'number') return compare(left, right) === 0
172
+ if (left.kind !== right.kind) return false
173
+ if (left.kind === 'bool' && right.kind === 'bool') return left.value === right.value
174
+ if (left.kind === 'enum' && right.kind === 'enum') return left.domain === right.domain && left.value === right.value
175
+ return false
176
+ }
177
+
178
+ const assigned = (value: Value): Evaluated => {
179
+ 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 }
184
+ }
185
+ }
186
+
187
+ const evaluate = (expression: Expression, assignment: Assignment): Evaluated => {
188
+ const run = (value: Expression): Evaluated => evaluate(value, assignment)
189
+ switch (expression.kind) {
190
+ case 'literal':
191
+ switch (expression.sort) {
192
+ 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)
195
+ case 'enum': return { kind: 'enum', domain: expression.domain, value: expression.value }
196
+ }
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)
201
+ }
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
+ 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))
218
+ }
219
+ 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)
223
+ }
224
+ case 'negate': {
225
+ const value = numeric(run(expression.value))
226
+ return normalize(-value.numerator, value.denominator)
227
+ }
228
+ case 'if': return bool(run(expression.condition)) ? run(expression.then) : run(expression.else)
229
+ }
230
+ }
231
+
232
+ const sorted = (values: readonly string[] | undefined): readonly string[] =>
233
+ [...new Set(values ?? [])].sort()
234
+
235
+ const element = (value: { readonly id: string } & Provenance): Element => ({
236
+ id: value.id,
237
+ ...(value.description === undefined ? {} : { description: value.description }),
238
+ ...(value.declaration === undefined ? {} : { declaration: value.declaration }),
239
+ evidenceRowIds: sorted(value.evidenceRowIds),
240
+ scenarioInputIds: sorted(value.scenarioInputIds)
241
+ })
242
+
243
+ const constraint = (value: HardConstraint, assignment: Assignment): Constraint => {
244
+ let evaluation: Constraint['evaluation'] = 'indeterminate'
245
+ 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
+ })
262
+
263
+ const assignments = (model: Model, assignment: Assignment): readonly AssignmentValue[] => {
264
+ const variables = new Map(model.variables.map(variable => [variable.id, variable]))
265
+ return Object.entries(assignment)
266
+ .sort(([left], [right]) => compareText(left, right))
267
+ .map(([id, value]) => {
268
+ const variable: Variable | undefined = variables.get(id)
269
+ return { ...element(variable ?? { id }), declared: variable !== undefined, value }
270
+ })
271
+ }
272
+
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
+ })
278
+
279
+ const objective = (declaration: Objective | undefined, result: ObjectiveValue): ObjectiveResult => ({
280
+ ...element(declaration ?? { id: result.objectiveId }),
281
+ declared: declaration !== undefined,
282
+ direction: declaration?.direction ?? 'unknown',
283
+ value: result.value,
284
+ ...(result.bound === undefined ? {} : { bound: result.bound })
285
+ })
286
+
287
+ const outcome = (model: Model, result: Result): Outcome => {
288
+ switch (result.status) {
289
+ case 'satisfied': return { status: result.status, ...feasible(model, result.assignment) }
290
+ case 'optimal': {
291
+ const declarations = new Map((model.objectives ?? []).map(value => [value.id, value]))
292
+ return {
293
+ status: result.status,
294
+ ...feasible(model, result.assignment),
295
+ objectives: result.objectives.map(value => objective(declarations.get(value.objectiveId), value)),
296
+ optimalityProved: result.optimalityProved
297
+ }
298
+ }
299
+ case 'unsatisfied': {
300
+ const declarations = new Map(model.constraints.map(value => [value.id, value]))
301
+ return {
302
+ status: result.status,
303
+ ...(result.core === undefined ? {} : {
304
+ core: result.core.map(id => {
305
+ const declaration = declarations.get(id)
306
+ return { ...element(declaration ?? { id }), declared: declaration !== undefined }
307
+ })
308
+ }),
309
+ coreMinimal: false,
310
+ infeasibilityProved: result.infeasibilityProved
311
+ }
312
+ }
313
+ case 'unknown': return { status: result.status, reason: result.reason }
314
+ }
315
+ }
316
+
317
+ /** Build stable JSON data without mutating the model, scenario, or CAVE store. */
318
+ export const report = (model: Model, result: Result, limits: Limits, context: Context = {}): Report => {
319
+ const modelDigest = Canonical.digest(model)
320
+ if (context.modelDigest !== undefined && context.modelDigest !== modelDigest) {
321
+ throw new TypeError(`scenario model digest ${context.modelDigest} does not match ${modelDigest}`)
322
+ }
323
+ return {
324
+ schema,
325
+ run: {
326
+ modelDigest,
327
+ backend: result.backend,
328
+ elapsedMs: result.elapsedMs,
329
+ limits,
330
+ diagnostics: result.diagnostics,
331
+ ...(context.scenario === undefined ? {} : { scenario: context.scenario }),
332
+ ...(context.snapshot === undefined ? {} : { snapshot: context.snapshot }),
333
+ inputs: [...(context.inputs ?? [])].sort((left, right) => compareText(left.id, right.id))
334
+ },
335
+ outcome: outcome(model, result)
336
+ }
337
+ }
338
+
339
+ const evidence = (value: Element): string => {
340
+ 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(', ')}`
344
+ ].filter((part): part is string => part !== undefined)
345
+ return parts.length === 0 ? '' : ` — ${parts.join('; ')}`
346
+ }
347
+
348
+ const valueText = (value: Value): string => {
349
+ 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
354
+ }
355
+ }
356
+
357
+ /** Render the same report as concise, deterministic plain text. */
358
+ export const render = (value: Report): string => {
359
+ const lines = [
360
+ `Solver result: ${value.outcome.status}`,
361
+ `Model: ${value.run.modelDigest}`,
362
+ `Backend: ${value.run.backend.name} ${value.run.backend.version}`,
363
+ `Elapsed: ${value.run.elapsedMs} ms`
364
+ ]
365
+ 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}`}`)
367
+ }
368
+ if (value.run.scenario !== undefined) lines.push(`Scenario: ${value.run.scenario.id} (${value.run.scenario.inputDigest})`)
369
+ for (const input of value.run.inputs) {
370
+ lines.push(`Input ${input.id}: ${JSON.stringify(input.value)}${input.query === undefined ? '' : ` via ${input.query}`}`)
371
+ }
372
+ switch (value.outcome.status) {
373
+ case 'satisfied':
374
+ case 'optimal':
375
+ for (const assignment of value.outcome.assignments) {
376
+ lines.push(`Assignment ${assignment.id} = ${valueText(assignment.value)}${assignment.declared ? '' : ' (undeclared backend ID)'}${evidence(assignment)}`)
377
+ }
378
+ for (const constraint of value.outcome.hardConstraints) {
379
+ lines.push(`Hard constraint ${constraint.id}: ${constraint.evaluation}${evidence(constraint)}`)
380
+ }
381
+ 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)}`)
383
+ }
384
+ if (value.outcome.status === 'optimal') {
385
+ 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)}`)
387
+ }
388
+ }
389
+ break
390
+ case 'unsatisfied':
391
+ lines.push('Infeasibility proved. Unsatisfiable cores are not necessarily minimal.')
392
+ for (const constraint of value.outcome.core ?? []) {
393
+ lines.push(`Core constraint ${constraint.id}${constraint.declared ? '' : ' (undeclared backend ID)'}${evidence(constraint)}`)
394
+ }
395
+ break
396
+ case 'unknown':
397
+ lines.push(`Unknown: ${value.outcome.reason.kind} — ${value.outcome.reason.message}`)
398
+ break
399
+ }
400
+ return `${lines.join('\n')}\n`
401
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Solver-neutral formal reasoning contracts for CAVE.
2
+ * Solver-neutral formal reasoning contracts and bounded workflows for CAVE.
3
3
  *
4
4
  * This package contains no solver implementation and never imports Z3 or
5
5
  * another backend. Adapters receive only validated portable model data.
@@ -9,7 +9,9 @@ export * as Adapter from './adapter.ts'
9
9
  export * as Canonical from './canonical.ts'
10
10
  export * as Capability from './capability.ts'
11
11
  export * as Exact from './exact.ts'
12
+ export * as Explain from './explain.ts'
12
13
  export * as Linear from './linear.ts'
13
14
  export * as Model from './model.ts'
14
15
  export * as Solve from './solve.ts'
15
16
  export * as Validate from './validate.ts'
17
+ export * as Workflow from './workflow.ts'
package/src/model.ts CHANGED
@@ -13,13 +13,31 @@ export type EnumDomain = {
13
13
  readonly id: string
14
14
  readonly values: readonly string[]
15
15
  readonly description?: string
16
+ readonly declaration?: Declaration
16
17
  }
17
18
 
18
- export type Variable =
19
- | { readonly id: string, readonly sort: 'bool', readonly description?: string }
20
- | { readonly id: string, readonly sort: 'int', readonly min: Integer, readonly max: Integer, readonly description?: string }
21
- | { readonly id: string, readonly sort: 'real', readonly min?: Rational, readonly max?: Rational, readonly description?: string }
22
- | { readonly id: string, readonly sort: 'enum', readonly domain: string, readonly description?: string }
19
+ export type Declaration = {
20
+ /** Stable model source. Prefer a repository-relative URI over a machine-local path. */
21
+ readonly uri: string
22
+ readonly line?: number
23
+ readonly column?: number
24
+ }
25
+
26
+ export type Provenance = {
27
+ readonly description?: string
28
+ readonly declaration?: Declaration
29
+ readonly evidenceRowIds?: readonly string[]
30
+ readonly scenarioInputIds?: readonly string[]
31
+ }
32
+
33
+ type VariableDeclaration = { readonly id: string } & Provenance
34
+
35
+ export type Variable = VariableDeclaration & (
36
+ | { readonly sort: 'bool' }
37
+ | { readonly sort: 'int', readonly min: Integer, readonly max: Integer }
38
+ | { readonly sort: 'real', readonly min?: Rational, readonly max?: Rational }
39
+ | { readonly sort: 'enum', readonly domain: string }
40
+ )
23
41
 
24
42
  export type Literal =
25
43
  | { readonly kind: 'literal', readonly sort: 'bool', readonly value: boolean }
@@ -39,11 +57,9 @@ export type Expression =
39
57
  | { readonly kind: 'negate', readonly value: Expression }
40
58
  | { readonly kind: 'if', readonly condition: Expression, readonly then: Expression, readonly else: Expression }
41
59
 
42
- export type HardConstraint = {
60
+ export type HardConstraint = Provenance & {
43
61
  readonly id: string
44
62
  readonly expression: Expression
45
- readonly description?: string
46
- readonly evidenceRowIds?: readonly string[]
47
63
  }
48
64
 
49
65
  export type SoftConstraint = HardConstraint & {
@@ -51,12 +67,10 @@ export type SoftConstraint = HardConstraint & {
51
67
  readonly weight: Rational
52
68
  }
53
69
 
54
- export type Objective = {
70
+ export type Objective = Provenance & {
55
71
  readonly id: string
56
72
  readonly direction: 'minimize' | 'maximize'
57
73
  readonly expression: Expression
58
- readonly description?: string
59
- readonly evidenceRowIds?: readonly string[]
60
74
  }
61
75
 
62
76
  export type Model = {
package/src/solve.ts CHANGED
@@ -1,13 +1,33 @@
1
1
  import type { Options, Result, SolverAdapter } from './adapter.ts'
2
2
  import * as Capability from './capability.ts'
3
+ import * as Explain from './explain.ts'
3
4
  import type { Model } from './model.ts'
4
5
  import * as Validate from './validate.ts'
5
6
 
6
- /** Validates and negotiates a model before crossing the adapter boundary. */
7
- export const run = async (adapter: SolverAdapter, model: Model, options: Options = {}): Promise<Result> => {
7
+ const prepared = (adapter: SolverAdapter, model: Model, options: Options): {
8
+ readonly limits: ReturnType<typeof Validate.mergeLimits>
9
+ readonly run: () => Promise<Result>
10
+ } => {
8
11
  const limits = Validate.mergeLimits(options.limits)
9
12
  Validate.model(model, limits)
10
- const missing = Capability.missing(adapter, model, options.unsatCore ?? false)
13
+ const unsatCore = options.unsatCore ?? false
14
+ const missing = Capability.missing(adapter, model, unsatCore)
11
15
  if (missing.length > 0) throw new Capability.UnsupportedModelError(adapter.backend.name, missing)
12
- return adapter.solve(model, { limits, unsatCore: options.unsatCore ?? false })
16
+ return { limits, run: () => adapter.solve(model, { limits, unsatCore }) }
17
+ }
18
+
19
+ /** Validates and negotiates a model before crossing the adapter boundary. */
20
+ export const run = async (adapter: SolverAdapter, model: Model, options: Options = {}): Promise<Result> => {
21
+ return prepared(adapter, model, options).run()
22
+ }
23
+
24
+ /** Solve and return a traceable JSON explanation envelope. */
25
+ export const runWithExplanation = async (
26
+ adapter: SolverAdapter,
27
+ model: Model,
28
+ options: Options = {},
29
+ context: Explain.Context = {}
30
+ ): Promise<Explain.Report> => {
31
+ const solve = prepared(adapter, model, options)
32
+ return Explain.report(model, await solve.run(), solve.limits, context)
13
33
  }
package/src/validate.ts CHANGED
@@ -62,6 +62,32 @@ const requireId = (id: string, path: string, problems: string[]): void => {
62
62
  }
63
63
  }
64
64
 
65
+ const provenance = (
66
+ value: { readonly declaration?: { readonly uri: string, readonly line?: number, readonly column?: number }, readonly evidenceRowIds?: readonly string[], readonly scenarioInputIds?: readonly string[] },
67
+ path: string,
68
+ problems: string[]
69
+ ): void => {
70
+ if (value.declaration !== undefined) {
71
+ if (value.declaration.uri.trim() === '') problems.push(`${path}.declaration.uri must not be empty`)
72
+ for (const position of ['line', 'column'] as const) {
73
+ const at = value.declaration[position]
74
+ if (at !== undefined && (!Number.isSafeInteger(at) || at <= 0)) {
75
+ problems.push(`${path}.declaration.${position} must be a positive safe integer`)
76
+ }
77
+ }
78
+ }
79
+ for (const [name, ids] of [
80
+ ['evidenceRowIds', value.evidenceRowIds],
81
+ ['scenarioInputIds', value.scenarioInputIds]
82
+ ] as const) {
83
+ if (ids === undefined) continue
84
+ if (new Set(ids).size !== ids.length) problems.push(`${path}.${name} contains duplicate identifiers`)
85
+ ids.forEach((id, index) => {
86
+ if (id.trim() === '') problems.push(`${path}.${name}[${index}] must not be empty`)
87
+ })
88
+ }
89
+ }
90
+
65
91
  const duplicates = (ids: readonly string[], path: string, problems: string[]): void => {
66
92
  const seen = new Set<string>()
67
93
  for (const id of ids) {
@@ -229,6 +255,7 @@ export const model = (input: Model, limitInput: Partial<Limits> = {}): Stats =>
229
255
  duplicates(enumDeclarations.map(domain => domain.id), 'enums', problems)
230
256
  enumDeclarations.forEach((domain, index) => {
231
257
  requireId(domain.id, `enums[${index}].id`, problems)
258
+ provenance(domain, `enums[${index}]`, problems)
232
259
  if (domain.values.length === 0) problems.push(`enums[${index}] must contain at least one value`)
233
260
  if (new Set(domain.values).size !== domain.values.length) problems.push(`enums[${index}] contains duplicate values`)
234
261
  domains.set(domain.id, domain.values)
@@ -238,6 +265,7 @@ export const model = (input: Model, limitInput: Partial<Limits> = {}): Stats =>
238
265
  duplicates(input.variables.map(variable => variable.id), 'variables', problems)
239
266
  input.variables.forEach((variable, index) => {
240
267
  requireId(variable.id, `variables[${index}].id`, problems)
268
+ provenance(variable, `variables[${index}]`, problems)
241
269
  variables.set(variable.id, variable)
242
270
  if (variable.sort === 'enum' && !domains.has(variable.domain)) {
243
271
  problems.push(`variables[${index}] references unknown enum domain ${JSON.stringify(variable.domain)}`)
@@ -271,11 +299,13 @@ export const model = (input: Model, limitInput: Partial<Limits> = {}): Stats =>
271
299
  const walk: Walk = { nodes: 0, depth: 0 }
272
300
  input.constraints.forEach((constraint, index) => {
273
301
  requireId(constraint.id, `constraints[${index}].id`, problems)
302
+ provenance(constraint, `constraints[${index}]`, problems)
274
303
  const sort = infer(constraint.expression, `constraints[${index}].expression`, variables, domains, problems, walk, 1)
275
304
  if (sort.kind !== 'bool') problems.push(`constraints[${index}].expression must be boolean, received ${sort.kind}`)
276
305
  })
277
306
  ;(input.softConstraints ?? []).forEach((constraint, index) => {
278
307
  requireId(constraint.id, `softConstraints[${index}].id`, problems)
308
+ provenance(constraint, `softConstraints[${index}]`, problems)
279
309
  const sort = infer(constraint.expression, `softConstraints[${index}].expression`, variables, domains, problems, walk, 1)
280
310
  if (sort.kind !== 'bool') problems.push(`softConstraints[${index}].expression must be boolean, received ${sort.kind}`)
281
311
  pushExactProblem(problems, `softConstraints[${index}].weight`, () => {
@@ -284,6 +314,7 @@ export const model = (input: Model, limitInput: Partial<Limits> = {}): Stats =>
284
314
  })
285
315
  ;(input.objectives ?? []).forEach((objective, index) => {
286
316
  requireId(objective.id, `objectives[${index}].id`, problems)
317
+ provenance(objective, `objectives[${index}]`, problems)
287
318
  const sort = infer(objective.expression, `objectives[${index}].expression`, variables, domains, problems, walk, 1)
288
319
  if (!numeric(sort)) problems.push(`objectives[${index}].expression must be numeric, received ${sort.kind}`)
289
320
  })