@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.
Files changed (119) hide show
  1. package/README.md +2155 -3
  2. package/dist/src/adapter.d.ts +6 -0
  3. package/dist/src/adapter.d.ts.map +1 -1
  4. package/dist/src/adapter.js +3 -0
  5. package/dist/src/adapter.js.map +1 -1
  6. package/dist/src/balanced-reduce.d.ts +3 -0
  7. package/dist/src/balanced-reduce.d.ts.map +1 -0
  8. package/dist/src/balanced-reduce.js +10 -0
  9. package/dist/src/balanced-reduce.js.map +1 -0
  10. package/dist/src/canonical-owned.d.ts +5 -0
  11. package/dist/src/canonical-owned.d.ts.map +1 -0
  12. package/dist/src/canonical-owned.js +206 -0
  13. package/dist/src/canonical-owned.js.map +1 -0
  14. package/dist/src/canonical.d.ts +3 -2
  15. package/dist/src/canonical.d.ts.map +1 -1
  16. package/dist/src/canonical.js +12 -85
  17. package/dist/src/canonical.js.map +1 -1
  18. package/dist/src/capability.d.ts.map +1 -1
  19. package/dist/src/capability.js +43 -33
  20. package/dist/src/capability.js.map +1 -1
  21. package/dist/src/clone.d.ts +3 -0
  22. package/dist/src/clone.d.ts.map +1 -0
  23. package/dist/src/clone.js +54 -0
  24. package/dist/src/clone.js.map +1 -0
  25. package/dist/src/constant-sign.d.ts +5 -0
  26. package/dist/src/constant-sign.d.ts.map +1 -0
  27. package/dist/src/constant-sign.js +54 -0
  28. package/dist/src/constant-sign.js.map +1 -0
  29. package/dist/src/context.d.ts +6 -0
  30. package/dist/src/context.d.ts.map +1 -0
  31. package/dist/src/context.js +151 -0
  32. package/dist/src/context.js.map +1 -0
  33. package/dist/src/diagnostic-text.d.ts +3 -0
  34. package/dist/src/diagnostic-text.d.ts.map +1 -0
  35. package/dist/src/diagnostic-text.js +5 -0
  36. package/dist/src/diagnostic-text.js.map +1 -0
  37. package/dist/src/evaluate-expression.d.ts +4 -0
  38. package/dist/src/evaluate-expression.d.ts.map +1 -0
  39. package/dist/src/evaluate-expression.js +24 -0
  40. package/dist/src/evaluate-expression.js.map +1 -0
  41. package/dist/src/exact.d.ts +2 -0
  42. package/dist/src/exact.d.ts.map +1 -1
  43. package/dist/src/exact.js +125 -28
  44. package/dist/src/exact.js.map +1 -1
  45. package/dist/src/explain.d.ts +4 -0
  46. package/dist/src/explain.d.ts.map +1 -1
  47. package/dist/src/explain.js +330 -91
  48. package/dist/src/explain.js.map +1 -1
  49. package/dist/src/explanation-budget.d.ts +14 -0
  50. package/dist/src/explanation-budget.d.ts.map +1 -0
  51. package/dist/src/explanation-budget.js +46 -0
  52. package/dist/src/explanation-budget.js.map +1 -0
  53. package/dist/src/expression-key.d.ts +8 -0
  54. package/dist/src/expression-key.d.ts.map +1 -0
  55. package/dist/src/expression-key.js +54 -0
  56. package/dist/src/expression-key.js.map +1 -0
  57. package/dist/src/fraction-product.d.ts +3 -0
  58. package/dist/src/fraction-product.d.ts.map +1 -0
  59. package/dist/src/fraction-product.js +13 -0
  60. package/dist/src/fraction-product.js.map +1 -0
  61. package/dist/src/fraction-sum.d.ts +3 -0
  62. package/dist/src/fraction-sum.d.ts.map +1 -0
  63. package/dist/src/fraction-sum.js +23 -0
  64. package/dist/src/fraction-sum.js.map +1 -0
  65. package/dist/src/integer-gcd.d.ts +3 -0
  66. package/dist/src/integer-gcd.d.ts.map +1 -0
  67. package/dist/src/integer-gcd.js +50 -0
  68. package/dist/src/integer-gcd.js.map +1 -0
  69. package/dist/src/linear.d.ts +2 -1
  70. package/dist/src/linear.d.ts.map +1 -1
  71. package/dist/src/linear.js +85 -17
  72. package/dist/src/linear.js.map +1 -1
  73. package/dist/src/numeric-size.d.ts +2 -0
  74. package/dist/src/numeric-size.d.ts.map +1 -0
  75. package/dist/src/numeric-size.js +33 -0
  76. package/dist/src/numeric-size.js.map +1 -0
  77. package/dist/src/options.d.ts +4 -0
  78. package/dist/src/options.d.ts.map +1 -0
  79. package/dist/src/options.js +18 -0
  80. package/dist/src/options.js.map +1 -0
  81. package/dist/src/result.d.ts +4 -0
  82. package/dist/src/result.d.ts.map +1 -0
  83. package/dist/src/result.js +29 -0
  84. package/dist/src/result.js.map +1 -0
  85. package/dist/src/solve.d.ts.map +1 -1
  86. package/dist/src/solve.js +34 -3
  87. package/dist/src/solve.js.map +1 -1
  88. package/dist/src/validate.d.ts +5 -0
  89. package/dist/src/validate.d.ts.map +1 -1
  90. package/dist/src/validate.js +323 -65
  91. package/dist/src/validate.js.map +1 -1
  92. package/dist/src/workflow.d.ts.map +1 -1
  93. package/dist/src/workflow.js +98 -26
  94. package/dist/src/workflow.js.map +1 -1
  95. package/package.json +2 -2
  96. package/src/adapter.ts +9 -0
  97. package/src/balanced-reduce.ts +9 -0
  98. package/src/canonical-owned.ts +186 -0
  99. package/src/canonical.ts +14 -90
  100. package/src/capability.ts +41 -30
  101. package/src/clone.ts +47 -0
  102. package/src/constant-sign.ts +59 -0
  103. package/src/context.ts +114 -0
  104. package/src/diagnostic-text.ts +4 -0
  105. package/src/evaluate-expression.ts +21 -0
  106. package/src/exact.ts +122 -27
  107. package/src/explain.ts +342 -94
  108. package/src/explanation-budget.ts +47 -0
  109. package/src/expression-key.ts +45 -0
  110. package/src/fraction-product.ts +10 -0
  111. package/src/fraction-sum.ts +20 -0
  112. package/src/integer-gcd.ts +47 -0
  113. package/src/linear.ts +82 -16
  114. package/src/numeric-size.ts +26 -0
  115. package/src/options.ts +18 -0
  116. package/src/result.ts +28 -0
  117. package/src/solve.ts +34 -3
  118. package/src/validate.ts +312 -66
  119. package/src/workflow.ts +92 -26
@@ -0,0 +1,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 { createHash } from 'node:crypto'
2
- import * as Exact from './exact.ts'
3
- import type { Expression, Model, Rational } from './model.ts'
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
- validate(input)
62
- const canonical: Json = {
63
- schema: input.schema,
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
- `sha256:${createHash('sha256').update(serialize(input)).digest('hex')}`
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 walk = (expression: Expression, visit: (expression: Expression) => void): void => {
17
- visit(expression)
16
+ const children = (expression: Expression): readonly Expression[] => {
18
17
  switch (expression.kind) {
19
- case 'literal':
20
- case 'variable': return
21
- case 'not':
22
- case 'negate': walk(expression.value, visit); return
23
- case 'and':
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 hasNonlinearArithmetic = (expression: Expression): boolean => {
39
- let nonlinear = false
40
- const containsVariable = (candidate: Expression): boolean => {
41
- let found = false
42
- walk(candidate, node => { if (node.kind === 'variable') found = true })
43
- return found
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 (hasNonlinearArithmetic(expression)) result.add('nonlinear-arithmetic')
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
+ }