@cavelang/solver 0.35.0 → 0.36.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/README.md +2155 -3
- package/dist/src/adapter.d.ts +6 -0
- package/dist/src/adapter.d.ts.map +1 -1
- package/dist/src/adapter.js +3 -0
- package/dist/src/adapter.js.map +1 -1
- package/dist/src/balanced-reduce.d.ts +3 -0
- package/dist/src/balanced-reduce.d.ts.map +1 -0
- package/dist/src/balanced-reduce.js +10 -0
- package/dist/src/balanced-reduce.js.map +1 -0
- package/dist/src/canonical-owned.d.ts +5 -0
- package/dist/src/canonical-owned.d.ts.map +1 -0
- package/dist/src/canonical-owned.js +206 -0
- package/dist/src/canonical-owned.js.map +1 -0
- package/dist/src/canonical.d.ts +3 -2
- package/dist/src/canonical.d.ts.map +1 -1
- package/dist/src/canonical.js +12 -85
- package/dist/src/canonical.js.map +1 -1
- package/dist/src/capability.d.ts.map +1 -1
- package/dist/src/capability.js +43 -33
- package/dist/src/capability.js.map +1 -1
- package/dist/src/clone.d.ts +3 -0
- package/dist/src/clone.d.ts.map +1 -0
- package/dist/src/clone.js +54 -0
- package/dist/src/clone.js.map +1 -0
- package/dist/src/constant-sign.d.ts +5 -0
- package/dist/src/constant-sign.d.ts.map +1 -0
- package/dist/src/constant-sign.js +54 -0
- package/dist/src/constant-sign.js.map +1 -0
- package/dist/src/context.d.ts +6 -0
- package/dist/src/context.d.ts.map +1 -0
- package/dist/src/context.js +151 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/diagnostic-text.d.ts +3 -0
- package/dist/src/diagnostic-text.d.ts.map +1 -0
- package/dist/src/diagnostic-text.js +5 -0
- package/dist/src/diagnostic-text.js.map +1 -0
- package/dist/src/evaluate-expression.d.ts +4 -0
- package/dist/src/evaluate-expression.d.ts.map +1 -0
- package/dist/src/evaluate-expression.js +24 -0
- package/dist/src/evaluate-expression.js.map +1 -0
- package/dist/src/exact.d.ts +2 -0
- package/dist/src/exact.d.ts.map +1 -1
- package/dist/src/exact.js +125 -28
- package/dist/src/exact.js.map +1 -1
- package/dist/src/explain.d.ts +4 -0
- package/dist/src/explain.d.ts.map +1 -1
- package/dist/src/explain.js +330 -91
- package/dist/src/explain.js.map +1 -1
- package/dist/src/explanation-budget.d.ts +14 -0
- package/dist/src/explanation-budget.d.ts.map +1 -0
- package/dist/src/explanation-budget.js +46 -0
- package/dist/src/explanation-budget.js.map +1 -0
- package/dist/src/expression-key.d.ts +8 -0
- package/dist/src/expression-key.d.ts.map +1 -0
- package/dist/src/expression-key.js +54 -0
- package/dist/src/expression-key.js.map +1 -0
- package/dist/src/fraction-product.d.ts +3 -0
- package/dist/src/fraction-product.d.ts.map +1 -0
- package/dist/src/fraction-product.js +13 -0
- package/dist/src/fraction-product.js.map +1 -0
- package/dist/src/fraction-sum.d.ts +3 -0
- package/dist/src/fraction-sum.d.ts.map +1 -0
- package/dist/src/fraction-sum.js +23 -0
- package/dist/src/fraction-sum.js.map +1 -0
- package/dist/src/integer-gcd.d.ts +3 -0
- package/dist/src/integer-gcd.d.ts.map +1 -0
- package/dist/src/integer-gcd.js +50 -0
- package/dist/src/integer-gcd.js.map +1 -0
- package/dist/src/linear.d.ts +2 -1
- package/dist/src/linear.d.ts.map +1 -1
- package/dist/src/linear.js +85 -17
- package/dist/src/linear.js.map +1 -1
- package/dist/src/numeric-size.d.ts +2 -0
- package/dist/src/numeric-size.d.ts.map +1 -0
- package/dist/src/numeric-size.js +33 -0
- package/dist/src/numeric-size.js.map +1 -0
- package/dist/src/options.d.ts +4 -0
- package/dist/src/options.d.ts.map +1 -0
- package/dist/src/options.js +18 -0
- package/dist/src/options.js.map +1 -0
- package/dist/src/result.d.ts +4 -0
- package/dist/src/result.d.ts.map +1 -0
- package/dist/src/result.js +29 -0
- package/dist/src/result.js.map +1 -0
- package/dist/src/solve.d.ts.map +1 -1
- package/dist/src/solve.js +34 -3
- package/dist/src/solve.js.map +1 -1
- package/dist/src/validate.d.ts +5 -0
- package/dist/src/validate.d.ts.map +1 -1
- package/dist/src/validate.js +323 -65
- package/dist/src/validate.js.map +1 -1
- package/dist/src/workflow.d.ts.map +1 -1
- package/dist/src/workflow.js +98 -26
- package/dist/src/workflow.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +9 -0
- package/src/balanced-reduce.ts +9 -0
- package/src/canonical-owned.ts +186 -0
- package/src/canonical.ts +14 -90
- package/src/capability.ts +41 -30
- package/src/clone.ts +47 -0
- package/src/constant-sign.ts +59 -0
- package/src/context.ts +114 -0
- package/src/diagnostic-text.ts +4 -0
- package/src/evaluate-expression.ts +21 -0
- package/src/exact.ts +122 -27
- package/src/explain.ts +342 -94
- package/src/explanation-budget.ts +47 -0
- package/src/expression-key.ts +45 -0
- package/src/fraction-product.ts +10 -0
- package/src/fraction-sum.ts +20 -0
- package/src/integer-gcd.ts +47 -0
- package/src/linear.ts +82 -16
- package/src/numeric-size.ts +26 -0
- package/src/options.ts +18 -0
- package/src/result.ts +28 -0
- package/src/solve.ts +34 -3
- package/src/validate.ts +312 -66
- package/src/workflow.ts +92 -26
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { Limits } from './adapter.ts'
|
|
3
|
+
import * as Exact from './exact.ts'
|
|
4
|
+
import type { Expression, Model, Rational } from './model.ts'
|
|
5
|
+
import { model as validate } from './validate.ts'
|
|
6
|
+
|
|
7
|
+
type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json }
|
|
8
|
+
|
|
9
|
+
const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
|
|
10
|
+
|
|
11
|
+
const exact = (value: Rational): Json => Exact.rational(value)
|
|
12
|
+
|
|
13
|
+
const canonicalNode = (input: Expression, resolved: WeakMap<Expression, Json>): Json => {
|
|
14
|
+
switch (input.kind) {
|
|
15
|
+
case 'literal':
|
|
16
|
+
if (input.sort === 'int') return { kind: input.kind, sort: input.sort, value: String(Exact.integer(input.value)) }
|
|
17
|
+
if (input.sort === 'real') return { kind: input.kind, sort: input.sort, value: exact(input.value) }
|
|
18
|
+
if (input.sort === 'enum') return { kind: input.kind, sort: input.sort, domain: input.domain, value: input.value }
|
|
19
|
+
return { kind: input.kind, sort: input.sort, value: input.value }
|
|
20
|
+
case 'variable': return { kind: input.kind, id: input.id }
|
|
21
|
+
case 'not':
|
|
22
|
+
case 'negate': return { kind: input.kind, value: resolved.get(input.value)! }
|
|
23
|
+
case 'and':
|
|
24
|
+
case 'or':
|
|
25
|
+
case 'add':
|
|
26
|
+
case 'multiply': {
|
|
27
|
+
const operands = input.operands.map(value => resolved.get(value)!)
|
|
28
|
+
.sort(compareCanonical)
|
|
29
|
+
return { kind: input.kind, operands }
|
|
30
|
+
}
|
|
31
|
+
case 'implies':
|
|
32
|
+
case 'subtract':
|
|
33
|
+
case 'divide':
|
|
34
|
+
case 'lt':
|
|
35
|
+
case 'lte':
|
|
36
|
+
case 'gt':
|
|
37
|
+
case 'gte':
|
|
38
|
+
return { kind: input.kind, left: resolved.get(input.left)!, right: resolved.get(input.right)! }
|
|
39
|
+
case 'eq':
|
|
40
|
+
case 'neq': {
|
|
41
|
+
const operands = [resolved.get(input.left)!, resolved.get(input.right)!]
|
|
42
|
+
.sort(compareCanonical)
|
|
43
|
+
return { kind: input.kind, left: operands[0]!, right: operands[1]! }
|
|
44
|
+
}
|
|
45
|
+
case 'if':
|
|
46
|
+
return { kind: input.kind, condition: resolved.get(input.condition)!, then: resolved.get(input.then)!, else: resolved.get(input.else)! }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const expression = (input: Expression, resolved: WeakMap<Expression, Json>): Json => {
|
|
51
|
+
const stack: { node: Expression, after: boolean }[] = [{ node: input, after: false }]
|
|
52
|
+
while (stack.length > 0) {
|
|
53
|
+
const { node, after } = stack.pop()!
|
|
54
|
+
if (resolved.has(node)) continue
|
|
55
|
+
if (after) {
|
|
56
|
+
resolved.set(node, canonicalNode(node, resolved))
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
stack.push({ node, after: true })
|
|
60
|
+
const push = (value: Expression): void => { stack.push({ node: value, after: false }) }
|
|
61
|
+
switch (node.kind) {
|
|
62
|
+
case 'literal': case 'variable': break
|
|
63
|
+
case 'not': case 'negate': push(node.value); break
|
|
64
|
+
case 'and': case 'or': case 'add': case 'multiply':
|
|
65
|
+
for (let index = node.operands.length - 1; index >= 0; index--) push(node.operands[index]!)
|
|
66
|
+
break
|
|
67
|
+
case 'if': push(node.else); push(node.then); push(node.condition); break
|
|
68
|
+
default: push(node.right); push(node.left)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return resolved.get(input)!
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function* tokens(value: Json): Generator<string> {
|
|
75
|
+
const stack: ({ value: Json } | { text: string })[] = [{ value }]
|
|
76
|
+
while (stack.length > 0) {
|
|
77
|
+
const item = stack.pop()!
|
|
78
|
+
if ('text' in item) { yield item.text; continue }
|
|
79
|
+
const current = item.value
|
|
80
|
+
if (current === null || typeof current !== 'object') { yield JSON.stringify(current); continue }
|
|
81
|
+
if (Array.isArray(current)) {
|
|
82
|
+
yield '['
|
|
83
|
+
stack.push({ text: ']' })
|
|
84
|
+
for (let index = current.length - 1; index >= 0; index--) {
|
|
85
|
+
stack.push({ value: current[index]! })
|
|
86
|
+
if (index > 0) stack.push({ text: ',' })
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
const record = current as Readonly<Record<string, Json>>
|
|
90
|
+
const keys = Object.keys(record).sort()
|
|
91
|
+
yield '{'
|
|
92
|
+
stack.push({ text: '}' })
|
|
93
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
94
|
+
const key = keys[index]!
|
|
95
|
+
stack.push({ value: record[key]! }, { text: `${JSON.stringify(key)}:` })
|
|
96
|
+
if (index > 0) stack.push({ text: ',' })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Keep complete JSON tokens together so UTF-8 encoding never splits a surrogate pair. */
|
|
103
|
+
function* chunks(value: Json): Generator<string> {
|
|
104
|
+
let pending: string[] = []
|
|
105
|
+
let length = 0
|
|
106
|
+
for (const token of tokens(value)) {
|
|
107
|
+
pending.push(token)
|
|
108
|
+
length += token.length
|
|
109
|
+
if (length >= 65536) {
|
|
110
|
+
yield pending.join('')
|
|
111
|
+
pending = []
|
|
112
|
+
length = 0
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (pending.length > 0) yield pending.join('')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const stableStringify = (value: Json): string => [...chunks(value)].join('')
|
|
119
|
+
|
|
120
|
+
/** Compare canonical text lazily instead of materializing each entire subtree. */
|
|
121
|
+
const compareCanonical = (left: Json, right: Json): number => {
|
|
122
|
+
if (left === right) return 0
|
|
123
|
+
const leftTokens = tokens(left)
|
|
124
|
+
const rightTokens = tokens(right)
|
|
125
|
+
let a = leftTokens.next()
|
|
126
|
+
let b = rightTokens.next()
|
|
127
|
+
let at = 0
|
|
128
|
+
let bt = 0
|
|
129
|
+
while (!a.done && !b.done) {
|
|
130
|
+
const length = Math.min(a.value.length - at, b.value.length - bt)
|
|
131
|
+
const order = compareText(a.value.slice(at, at + length), b.value.slice(bt, bt + length))
|
|
132
|
+
if (order !== 0) return order
|
|
133
|
+
at += length
|
|
134
|
+
bt += length
|
|
135
|
+
if (at === a.value.length) { a = leftTokens.next(); at = 0 }
|
|
136
|
+
if (bt === b.value.length) { b = rightTokens.next(); bt = 0 }
|
|
137
|
+
}
|
|
138
|
+
return a.done ? (b.done ? 0 : -1) : 1
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const byId = <T extends { readonly id: string }>(values: readonly T[]): readonly T[] =>
|
|
142
|
+
[...values].sort((left, right) => compareText(left.id, right.id))
|
|
143
|
+
|
|
144
|
+
/** Private entrypoint: the caller owns the captured model and resolved limits. */
|
|
145
|
+
const canonicalOwned = (input: Model, limits: Limits): Json => {
|
|
146
|
+
validate(input, limits)
|
|
147
|
+
const resolved = new WeakMap<Expression, Json>()
|
|
148
|
+
const canonical: Json = {
|
|
149
|
+
schema: input.schema,
|
|
150
|
+
enums: byId(input.enums ?? []).map(domain => ({ id: domain.id, values: [...domain.values].sort() })),
|
|
151
|
+
variables: byId(input.variables).map((variable): Json => {
|
|
152
|
+
switch (variable.sort) {
|
|
153
|
+
case 'bool': return { id: variable.id, sort: variable.sort }
|
|
154
|
+
case 'int': return { id: variable.id, sort: variable.sort, min: String(Exact.integer(variable.min)), max: String(Exact.integer(variable.max)) }
|
|
155
|
+
case 'real': return {
|
|
156
|
+
id: variable.id,
|
|
157
|
+
sort: variable.sort,
|
|
158
|
+
...(variable.min === undefined ? {} : { min: exact(variable.min) }),
|
|
159
|
+
...(variable.max === undefined ? {} : { max: exact(variable.max) })
|
|
160
|
+
}
|
|
161
|
+
case 'enum': return { id: variable.id, sort: variable.sort, domain: variable.domain }
|
|
162
|
+
}
|
|
163
|
+
}),
|
|
164
|
+
constraints: byId(input.constraints).map(constraint => ({ id: constraint.id, expression: expression(constraint.expression, resolved) })),
|
|
165
|
+
softConstraints: byId(input.softConstraints ?? []).map(constraint => ({
|
|
166
|
+
id: constraint.id,
|
|
167
|
+
expression: expression(constraint.expression, resolved),
|
|
168
|
+
weight: exact(constraint.weight)
|
|
169
|
+
})),
|
|
170
|
+
objectives: (input.objectives ?? []).map(objective => ({
|
|
171
|
+
id: objective.id,
|
|
172
|
+
direction: objective.direction,
|
|
173
|
+
expression: expression(objective.expression, resolved)
|
|
174
|
+
}))
|
|
175
|
+
}
|
|
176
|
+
return canonical
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const serializeOwned = (input: Model, limits: Limits): string =>
|
|
180
|
+
stableStringify(canonicalOwned(input, limits))
|
|
181
|
+
|
|
182
|
+
export const digestOwned = (input: Model, limits: Limits): string => {
|
|
183
|
+
const hash = createHash('sha256')
|
|
184
|
+
for (const chunk of chunks(canonicalOwned(input, limits))) hash.update(chunk)
|
|
185
|
+
return `sha256:${hash.digest('hex')}`
|
|
186
|
+
}
|
package/src/canonical.ts
CHANGED
|
@@ -1,94 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
4
|
-
import { model as validate } from './validate.ts'
|
|
5
|
-
|
|
6
|
-
type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json }
|
|
7
|
-
|
|
8
|
-
const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
|
|
9
|
-
|
|
10
|
-
const exact = (value: Rational): Json => Exact.rational(value)
|
|
11
|
-
|
|
12
|
-
const expression = (input: Expression): Json => {
|
|
13
|
-
switch (input.kind) {
|
|
14
|
-
case 'literal':
|
|
15
|
-
if (input.sort === 'int') return { kind: input.kind, sort: input.sort, value: String(Exact.integer(input.value)) }
|
|
16
|
-
if (input.sort === 'real') return { kind: input.kind, sort: input.sort, value: exact(input.value) }
|
|
17
|
-
if (input.sort === 'enum') return { kind: input.kind, sort: input.sort, domain: input.domain, value: input.value }
|
|
18
|
-
return { kind: input.kind, sort: input.sort, value: input.value }
|
|
19
|
-
case 'variable': return { kind: input.kind, id: input.id }
|
|
20
|
-
case 'not':
|
|
21
|
-
case 'negate': return { kind: input.kind, value: expression(input.value) }
|
|
22
|
-
case 'and':
|
|
23
|
-
case 'or':
|
|
24
|
-
case 'add':
|
|
25
|
-
case 'multiply': {
|
|
26
|
-
const operands = input.operands.map(expression)
|
|
27
|
-
.sort((left, right) => compareText(stableStringify(left), stableStringify(right)))
|
|
28
|
-
return { kind: input.kind, operands }
|
|
29
|
-
}
|
|
30
|
-
case 'implies':
|
|
31
|
-
case 'subtract':
|
|
32
|
-
case 'divide':
|
|
33
|
-
case 'lt':
|
|
34
|
-
case 'lte':
|
|
35
|
-
case 'gt':
|
|
36
|
-
case 'gte':
|
|
37
|
-
return { kind: input.kind, left: expression(input.left), right: expression(input.right) }
|
|
38
|
-
case 'eq':
|
|
39
|
-
case 'neq': {
|
|
40
|
-
const operands = [expression(input.left), expression(input.right)]
|
|
41
|
-
.sort((left, right) => compareText(stableStringify(left), stableStringify(right)))
|
|
42
|
-
return { kind: input.kind, left: operands[0]!, right: operands[1]! }
|
|
43
|
-
}
|
|
44
|
-
case 'if':
|
|
45
|
-
return { kind: input.kind, condition: expression(input.condition), then: expression(input.then), else: expression(input.else) }
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const stableStringify = (value: Json): string => {
|
|
50
|
-
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
51
|
-
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
52
|
-
const record = value as Readonly<Record<string, Json>>
|
|
53
|
-
return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${stableStringify(record[key]!)}`).join(',')}}`
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const byId = <T extends { readonly id: string }>(values: readonly T[]): readonly T[] =>
|
|
57
|
-
[...values].sort((left, right) => compareText(left.id, right.id))
|
|
1
|
+
import { clone } from './clone.ts'
|
|
2
|
+
import type { Limits } from './adapter.ts'
|
|
3
|
+
import type { Model } from './model.ts'
|
|
4
|
+
import { mergeLimits, model as validate } from './validate.ts'
|
|
5
|
+
import { digestOwned, serializeOwned } from './canonical-owned.ts'
|
|
58
6
|
|
|
59
7
|
/** Canonical semantic JSON. Descriptions and evidence links are deliberately not identity. */
|
|
60
|
-
export const serialize = (input: Model): string => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
enums: byId(input.enums ?? []).map(domain => ({ id: domain.id, values: [...domain.values].sort() })),
|
|
65
|
-
variables: byId(input.variables).map((variable): Json => {
|
|
66
|
-
switch (variable.sort) {
|
|
67
|
-
case 'bool': return { id: variable.id, sort: variable.sort }
|
|
68
|
-
case 'int': return { id: variable.id, sort: variable.sort, min: String(Exact.integer(variable.min)), max: String(Exact.integer(variable.max)) }
|
|
69
|
-
case 'real': return {
|
|
70
|
-
id: variable.id,
|
|
71
|
-
sort: variable.sort,
|
|
72
|
-
...(variable.min === undefined ? {} : { min: exact(variable.min) }),
|
|
73
|
-
...(variable.max === undefined ? {} : { max: exact(variable.max) })
|
|
74
|
-
}
|
|
75
|
-
case 'enum': return { id: variable.id, sort: variable.sort, domain: variable.domain }
|
|
76
|
-
}
|
|
77
|
-
}),
|
|
78
|
-
constraints: byId(input.constraints).map(constraint => ({ id: constraint.id, expression: expression(constraint.expression) })),
|
|
79
|
-
softConstraints: byId(input.softConstraints ?? []).map(constraint => ({
|
|
80
|
-
id: constraint.id,
|
|
81
|
-
expression: expression(constraint.expression),
|
|
82
|
-
weight: exact(constraint.weight)
|
|
83
|
-
})),
|
|
84
|
-
objectives: (input.objectives ?? []).map(objective => ({
|
|
85
|
-
id: objective.id,
|
|
86
|
-
direction: objective.direction,
|
|
87
|
-
expression: expression(objective.expression)
|
|
88
|
-
}))
|
|
89
|
-
}
|
|
90
|
-
return stableStringify(canonical)
|
|
8
|
+
export const serialize = (input: Model, limits: Partial<Limits> = {}): string => {
|
|
9
|
+
const resolved = mergeLimits(limits)
|
|
10
|
+
validate(input, resolved)
|
|
11
|
+
return serializeOwned(clone(input), resolved)
|
|
91
12
|
}
|
|
92
13
|
|
|
93
|
-
export const digest = (input: Model): string =>
|
|
94
|
-
|
|
14
|
+
export const digest = (input: Model, limits: Partial<Limits> = {}): string => {
|
|
15
|
+
const resolved = mergeLimits(limits)
|
|
16
|
+
validate(input, resolved)
|
|
17
|
+
return digestOwned(clone(input), resolved)
|
|
18
|
+
}
|
package/src/capability.ts
CHANGED
|
@@ -13,40 +13,42 @@ export class UnsupportedModelError extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
visit(expression)
|
|
16
|
+
const children = (expression: Expression): readonly Expression[] => {
|
|
18
17
|
switch (expression.kind) {
|
|
19
|
-
case 'literal':
|
|
20
|
-
case '
|
|
21
|
-
case '
|
|
22
|
-
case '
|
|
23
|
-
|
|
24
|
-
case 'or':
|
|
25
|
-
case 'add':
|
|
26
|
-
case 'multiply': expression.operands.forEach(operand => walk(operand, visit)); return
|
|
27
|
-
case 'if':
|
|
28
|
-
walk(expression.condition, visit)
|
|
29
|
-
walk(expression.then, visit)
|
|
30
|
-
walk(expression.else, visit)
|
|
31
|
-
return
|
|
32
|
-
default:
|
|
33
|
-
walk(expression.left, visit)
|
|
34
|
-
walk(expression.right, visit)
|
|
18
|
+
case 'literal': case 'variable': return []
|
|
19
|
+
case 'not': case 'negate': return [expression.value]
|
|
20
|
+
case 'and': case 'or': case 'add': case 'multiply': return expression.operands
|
|
21
|
+
case 'if': return [expression.condition, expression.then, expression.else]
|
|
22
|
+
default: return [expression.left, expression.right]
|
|
35
23
|
}
|
|
36
24
|
}
|
|
37
25
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
26
|
+
const walk = (
|
|
27
|
+
expression: Expression,
|
|
28
|
+
visit: (expression: Expression) => void,
|
|
29
|
+
finish: (expression: Expression) => void
|
|
30
|
+
): void => {
|
|
31
|
+
const active = new WeakSet<Expression>()
|
|
32
|
+
const complete = new WeakSet<Expression>()
|
|
33
|
+
const stack: { node: Expression, after: boolean }[] = [{ node: expression, after: false }]
|
|
34
|
+
while (stack.length > 0) {
|
|
35
|
+
const { node, after } = stack.pop()!
|
|
36
|
+
if (after) {
|
|
37
|
+
finish(node)
|
|
38
|
+
active.delete(node)
|
|
39
|
+
complete.add(node)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (active.has(node)) throw new TypeError('capability discovery received a cyclic expression')
|
|
43
|
+
if (complete.has(node)) continue
|
|
44
|
+
active.add(node)
|
|
45
|
+
visit(node)
|
|
46
|
+
stack.push({ node, after: true })
|
|
47
|
+
const operands = children(node)
|
|
48
|
+
for (let index = operands.length - 1; index >= 0; index--) {
|
|
49
|
+
stack.push({ node: operands[index]!, after: false })
|
|
50
|
+
}
|
|
44
51
|
}
|
|
45
|
-
walk(expression, node => {
|
|
46
|
-
if (node.kind === 'multiply' && node.operands.filter(containsVariable).length > 1) nonlinear = true
|
|
47
|
-
if (node.kind === 'divide' && containsVariable(node.right)) nonlinear = true
|
|
48
|
-
})
|
|
49
|
-
return nonlinear
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export const required = (model: Model, unsatCore = false): ReadonlySet<Capability> => {
|
|
@@ -67,13 +69,22 @@ export const required = (model: Model, unsatCore = false): ReadonlySet<Capabilit
|
|
|
67
69
|
...(model.objectives ?? []).map(objective => objective.expression)
|
|
68
70
|
]
|
|
69
71
|
for (const expression of expressions) {
|
|
72
|
+
const containsVariable = new WeakMap<Expression, boolean>()
|
|
73
|
+
let nonlinear = false
|
|
70
74
|
walk(expression, node => {
|
|
71
75
|
if (node.kind === 'literal' && node.sort === 'int') result.add('integers')
|
|
72
76
|
if (node.kind === 'literal' && node.sort === 'real') result.add('rationals')
|
|
77
|
+
// Portable division returns an exact real even when both operands are integers.
|
|
78
|
+
if (node.kind === 'divide') result.add('rationals')
|
|
73
79
|
if (node.kind === 'literal' && node.sort === 'enum') result.add('finite-enums')
|
|
74
80
|
if (node.kind === 'if') result.add('conditionals')
|
|
81
|
+
}, node => {
|
|
82
|
+
const operands = children(node)
|
|
83
|
+
if (node.kind === 'multiply' && operands.filter(value => containsVariable.get(value)).length > 1) nonlinear = true
|
|
84
|
+
if (node.kind === 'divide' && containsVariable.get(node.right)) nonlinear = true
|
|
85
|
+
containsVariable.set(node, node.kind === 'variable' || operands.some(value => containsVariable.get(value)))
|
|
75
86
|
})
|
|
76
|
-
if (
|
|
87
|
+
if (nonlinear) result.add('nonlinear-arithmetic')
|
|
77
88
|
}
|
|
78
89
|
return result
|
|
79
90
|
}
|
package/src/clone.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { types } from 'node:util'
|
|
2
|
+
|
|
3
|
+
/** Inspect descriptors without evaluating accessors or touching proxy traps. */
|
|
4
|
+
const portable = (input: unknown): boolean => {
|
|
5
|
+
const pending: unknown[] = [input]
|
|
6
|
+
const seen = new WeakSet<object>()
|
|
7
|
+
while (pending.length > 0) {
|
|
8
|
+
const value = pending.pop()
|
|
9
|
+
if (typeof value === 'function' || typeof value === 'symbol') return false
|
|
10
|
+
if (value === null || typeof value !== 'object' || seen.has(value)) continue
|
|
11
|
+
if (types.isProxy(value)) return false
|
|
12
|
+
seen.add(value)
|
|
13
|
+
const prototype = Object.getPrototypeOf(value)
|
|
14
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return false
|
|
15
|
+
for (const key of Object.keys(value)) {
|
|
16
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)!
|
|
17
|
+
if (!Object.hasOwn(descriptor, 'value')) return false
|
|
18
|
+
pending.push(descriptor.value)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Copy portable records/arrays without native structured-clone recursion. */
|
|
25
|
+
export const clone = <T>(input: T): T => {
|
|
26
|
+
if (!portable(input)) return structuredClone(input)
|
|
27
|
+
const copies = new WeakMap<object, object>()
|
|
28
|
+
const pending: { source: object, target: object }[] = []
|
|
29
|
+
const allocate = (value: unknown): unknown => {
|
|
30
|
+
if (value === null || typeof value !== 'object') return value
|
|
31
|
+
const previous = copies.get(value)
|
|
32
|
+
if (previous !== undefined) return previous
|
|
33
|
+
const target: object = Array.isArray(value) ? new Array(value.length) : {}
|
|
34
|
+
copies.set(value, target)
|
|
35
|
+
pending.push({ source: value, target })
|
|
36
|
+
return target
|
|
37
|
+
}
|
|
38
|
+
const result = allocate(input)
|
|
39
|
+
while (pending.length > 0) {
|
|
40
|
+
const { source, target } = pending.pop()!
|
|
41
|
+
for (const key of Object.keys(source)) {
|
|
42
|
+
const copied = allocate((source as Record<string, unknown>)[key])
|
|
43
|
+
Object.defineProperty(target, key, { value: copied, enumerable: true, writable: true, configurable: true })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result as T
|
|
47
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Expression } from './model.ts'
|
|
2
|
+
import * as Exact from './exact.ts'
|
|
3
|
+
import { evaluate } from './evaluate-expression.ts'
|
|
4
|
+
|
|
5
|
+
export type Sign = -1 | 0 | 1 | undefined
|
|
6
|
+
const addSigns = (left: Sign, right: Sign): Sign =>
|
|
7
|
+
left === undefined || right === undefined ? undefined : left === 0 ? right : right === 0 || left === right ? left : undefined
|
|
8
|
+
|
|
9
|
+
/** Prove a constant's sign without constructing large rational intermediates. */
|
|
10
|
+
function* signSteps(expression: Expression, literal: (value: Expression) => Exact.Normalized): Generator<Expression, Sign, Sign> {
|
|
11
|
+
switch (expression.kind) {
|
|
12
|
+
case 'literal': {
|
|
13
|
+
if (expression.sort !== 'int' && expression.sort !== 'real') return undefined
|
|
14
|
+
const value = literal(expression)
|
|
15
|
+
return value.numerator === '0' ? 0 : value.numerator.startsWith('-') ? -1 : 1
|
|
16
|
+
}
|
|
17
|
+
case 'negate': {
|
|
18
|
+
const sign = yield expression.value
|
|
19
|
+
return sign === undefined ? undefined : -sign as Sign
|
|
20
|
+
}
|
|
21
|
+
case 'add': {
|
|
22
|
+
let sign: Sign = 0
|
|
23
|
+
for (const operand of expression.operands) {
|
|
24
|
+
sign = addSigns(sign, yield operand)
|
|
25
|
+
if (sign === undefined) return undefined
|
|
26
|
+
}
|
|
27
|
+
return sign
|
|
28
|
+
}
|
|
29
|
+
case 'subtract': {
|
|
30
|
+
const left = yield expression.left
|
|
31
|
+
const right = yield expression.right
|
|
32
|
+
return addSigns(left, right === undefined ? undefined : -right as Sign)
|
|
33
|
+
}
|
|
34
|
+
case 'multiply': {
|
|
35
|
+
let sign: Sign = 1
|
|
36
|
+
for (const operand of expression.operands) {
|
|
37
|
+
const part = yield operand
|
|
38
|
+
sign = sign === undefined || part === undefined ? undefined : sign * part as Sign
|
|
39
|
+
}
|
|
40
|
+
return sign
|
|
41
|
+
}
|
|
42
|
+
case 'divide': {
|
|
43
|
+
const left = yield expression.left
|
|
44
|
+
const right = yield expression.right
|
|
45
|
+
return left === undefined || right === undefined || right === 0 ? undefined : left * right as Sign
|
|
46
|
+
}
|
|
47
|
+
default: return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const constantSign = (
|
|
52
|
+
expression: Expression,
|
|
53
|
+
cache: WeakMap<Expression, Sign>,
|
|
54
|
+
literal: (value: Expression) => Exact.Normalized = value => {
|
|
55
|
+
if (value.kind === 'literal' && value.sort === 'int') return Exact.rational(String(Exact.integer(value.value)))
|
|
56
|
+
if (value.kind === 'literal' && value.sort === 'real') return Exact.rational(value.value)
|
|
57
|
+
throw new TypeError('expected a numeric literal')
|
|
58
|
+
}
|
|
59
|
+
): Sign => evaluate(expression, value => signSteps(value, literal), cache)
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Context } from './explain.ts'
|
|
2
|
+
import { clone } from './clone.ts'
|
|
3
|
+
|
|
4
|
+
const validateJson = (root: unknown, path: string): void => {
|
|
5
|
+
const active = new WeakSet<object>()
|
|
6
|
+
const completed = new WeakSet<object>()
|
|
7
|
+
const stack: { value: unknown, path: string, leave?: boolean }[] = [{ value: root, path }]
|
|
8
|
+
while (stack.length > 0) {
|
|
9
|
+
const frame = stack.pop()!
|
|
10
|
+
const value = frame.value
|
|
11
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') continue
|
|
12
|
+
if (typeof value === 'number' && Number.isFinite(value)) continue
|
|
13
|
+
if (typeof value !== 'object' || value === null) throw new TypeError(`${frame.path} must be a finite JSON value`)
|
|
14
|
+
if (frame.leave) { active.delete(value); completed.add(value); continue }
|
|
15
|
+
if (active.has(value)) throw new TypeError(`${frame.path} contains a JSON cycle`)
|
|
16
|
+
if (completed.has(value)) continue
|
|
17
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError(`${frame.path} must be a plain JSON object or array`)
|
|
18
|
+
if (typeof (value as { toJSON?: unknown }).toJSON === 'function') throw new TypeError(`${frame.path} must not use a custom JSON serializer`)
|
|
19
|
+
active.add(value)
|
|
20
|
+
stack.push({ ...frame, leave: true })
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
23
|
+
if (!Object.hasOwn(value, index)) throw new TypeError(`${frame.path}[${index}] must not be a sparse JSON array entry`)
|
|
24
|
+
stack.push({ value: value[index], path: `${frame.path}[${index}]` })
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
for (const [key, child] of Object.entries(value)) {
|
|
28
|
+
if (child !== undefined) stack.push({ value: child, path: `${frame.path}.${key}` })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Serialize validated input values without depending on the engine's stack limit. */
|
|
35
|
+
export const stringifyInput = (value: unknown): string => {
|
|
36
|
+
validateJson(value, 'explanation input value')
|
|
37
|
+
value = clone(value)
|
|
38
|
+
validateJson(value, 'explanation input value')
|
|
39
|
+
const output: string[] = []
|
|
40
|
+
const stack: ({ value: unknown } | { text: string })[] = [{ value }]
|
|
41
|
+
while (stack.length > 0) {
|
|
42
|
+
const frame = stack.pop()!
|
|
43
|
+
if ('text' in frame) { output.push(frame.text); continue }
|
|
44
|
+
const current = frame.value
|
|
45
|
+
if (current === null || typeof current !== 'object') {
|
|
46
|
+
output.push(JSON.stringify(current))
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(current)) {
|
|
50
|
+
output.push('[')
|
|
51
|
+
stack.push({ text: ']' })
|
|
52
|
+
for (let index = current.length - 1; index >= 0; index--) {
|
|
53
|
+
stack.push({ value: current[index] })
|
|
54
|
+
if (index > 0) stack.push({ text: ',' })
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const entries = Object.entries(current).filter(([, child]) => child !== undefined)
|
|
58
|
+
output.push('{')
|
|
59
|
+
stack.push({ text: '}' })
|
|
60
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
61
|
+
const [key, child] = entries[index]!
|
|
62
|
+
stack.push({ value: child }, { text: `${JSON.stringify(key)}:` })
|
|
63
|
+
if (index > 0) stack.push({ text: ',' })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return output.join('')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Validate explanation metadata and bindings without resolving external IDs. */
|
|
71
|
+
export const validateContext = (context: Context): void => {
|
|
72
|
+
if (context === null || typeof context !== 'object' || Array.isArray(context)) throw new TypeError('explanation context must be an object')
|
|
73
|
+
const nonempty = (value: unknown, path: string): void => {
|
|
74
|
+
if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${path} must be a nonempty string`)
|
|
75
|
+
}
|
|
76
|
+
if (context.modelDigest !== undefined) nonempty(context.modelDigest, 'explanation modelDigest')
|
|
77
|
+
if (context.scenario !== undefined) {
|
|
78
|
+
const scenario = context.scenario
|
|
79
|
+
if (scenario === null || typeof scenario !== 'object' || Array.isArray(scenario)) throw new TypeError('explanation scenario must be an object')
|
|
80
|
+
for (const key of ['id', 'inputDigest', 'overlayDigest'] as const) nonempty(scenario[key], `explanation scenario.${key}`)
|
|
81
|
+
}
|
|
82
|
+
if (context.snapshot !== undefined) {
|
|
83
|
+
const snapshot = context.snapshot
|
|
84
|
+
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) throw new TypeError('explanation snapshot must be an object')
|
|
85
|
+
if (snapshot.transactionTime !== null) nonempty(snapshot.transactionTime, 'explanation snapshot.transactionTime')
|
|
86
|
+
if (snapshot.validTime !== undefined) nonempty(snapshot.validTime, 'explanation snapshot.validTime')
|
|
87
|
+
if (snapshot.aliases !== undefined && snapshot.aliases !== 'exact' && snapshot.aliases !== 'closure') throw new TypeError('explanation snapshot.aliases must be exact or closure')
|
|
88
|
+
if (snapshot.resolution !== undefined && snapshot.resolution !== 'coexisting' && snapshot.resolution !== 'winner') throw new TypeError('explanation snapshot.resolution must be coexisting or winner')
|
|
89
|
+
const confidence = snapshot.minimumConfidence
|
|
90
|
+
if (confidence !== undefined && (typeof confidence !== 'number' || !Number.isFinite(confidence) || confidence < 0 || confidence > 1)) throw new TypeError('explanation snapshot.minimumConfidence must be a finite number from 0 to 1')
|
|
91
|
+
}
|
|
92
|
+
if (context.inputs === undefined) return
|
|
93
|
+
if (!Array.isArray(context.inputs)) throw new TypeError('explanation inputs must be an array')
|
|
94
|
+
const ids = new Set<string>()
|
|
95
|
+
for (let index = 0; index < context.inputs.length; index++) {
|
|
96
|
+
const input = context.inputs[index]
|
|
97
|
+
const path = `explanation inputs[${index}]`
|
|
98
|
+
if (!Object.hasOwn(context.inputs, index) || input === null || typeof input !== 'object' || Array.isArray(input)) throw new TypeError(`${path} must be an input object`)
|
|
99
|
+
if (typeof input.id !== 'string' || input.id.length === 0) throw new TypeError(`${path}.id must be a nonempty string`)
|
|
100
|
+
if (ids.has(input.id)) throw new TypeError(`${path}.id duplicates ${JSON.stringify(input.id)}`)
|
|
101
|
+
ids.add(input.id)
|
|
102
|
+
for (const key of ['value', 'authoredValue'] as const) {
|
|
103
|
+
if (input[key] !== undefined) validateJson(input[key], `${path}.${key}`)
|
|
104
|
+
}
|
|
105
|
+
if (input.query !== undefined && typeof input.query !== 'string') throw new TypeError(`${path}.query must be a string`)
|
|
106
|
+
for (const key of ['evidenceRowIds', 'scenarioClaimIds'] as const) {
|
|
107
|
+
const values = input[key]
|
|
108
|
+
if (!Array.isArray(values)) throw new TypeError(`${path}.${key} must be an array`)
|
|
109
|
+
for (let item = 0; item < values.length; item++) {
|
|
110
|
+
if (!Object.hasOwn(values, item) || typeof values[item] !== 'string' || values[item].length === 0) throw new TypeError(`${path}.${key}[${item}] must be a nonempty string`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Keep malformed-input diagnostics small while retaining both ends and exact size. */
|
|
2
|
+
export const diagnosticText = (input: string, quoteShort = true): string => input.length <= 160
|
|
3
|
+
? (quoteShort ? JSON.stringify(input) : input)
|
|
4
|
+
: `${JSON.stringify(input.slice(0, 80))}…${JSON.stringify(input.slice(-80))} (${input.length} UTF-16 code units)`
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Expression } from './model.ts'
|
|
2
|
+
|
|
3
|
+
/** Evaluate a captured expression with an explicit stack and caller-owned cache. */
|
|
4
|
+
export const evaluate = <T>(expression: Expression, steps: (value: Expression) => Generator<Expression, T, T>, cache: WeakMap<Expression, T>): T => {
|
|
5
|
+
if (cache.has(expression)) return cache.get(expression)!
|
|
6
|
+
const stack = [{ expression, iterator: steps(expression) }]
|
|
7
|
+
let step = stack[0]!.iterator.next()
|
|
8
|
+
while (true) {
|
|
9
|
+
if (step.done) {
|
|
10
|
+
cache.set(stack.pop()!.expression, step.value)
|
|
11
|
+
if (stack.length === 0) return step.value
|
|
12
|
+
step = stack[stack.length - 1]!.iterator.next(step.value)
|
|
13
|
+
} else if (cache.has(step.value)) {
|
|
14
|
+
step = stack[stack.length - 1]!.iterator.next(cache.get(step.value)!)
|
|
15
|
+
} else {
|
|
16
|
+
const child = { expression: step.value, iterator: steps(step.value) }
|
|
17
|
+
stack.push(child)
|
|
18
|
+
step = child.iterator.next()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|