@cavelang/scenario 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 +447 -6
- package/dist/src/bind.d.ts +3 -0
- package/dist/src/bind.d.ts.map +1 -1
- package/dist/src/bind.js +188 -33
- package/dist/src/bind.js.map +1 -1
- package/dist/src/capture.d.ts +4 -0
- package/dist/src/capture.d.ts.map +1 -0
- package/dist/src/capture.js +33 -0
- package/dist/src/capture.js.map +1 -0
- package/dist/src/error.d.ts +1 -1
- package/dist/src/error.d.ts.map +1 -1
- package/dist/src/error.js.map +1 -1
- package/dist/src/exact.d.ts.map +1 -1
- package/dist/src/exact.js +27 -18
- package/dist/src/exact.js.map +1 -1
- package/dist/src/explain.d.ts.map +1 -1
- package/dist/src/explain.js +23 -0
- package/dist/src/explain.js.map +1 -1
- package/dist/src/explanation-entries.d.ts +4 -0
- package/dist/src/explanation-entries.d.ts.map +1 -0
- package/dist/src/explanation-entries.js +89 -0
- package/dist/src/explanation-entries.js.map +1 -0
- package/dist/src/model.d.ts +2 -0
- package/dist/src/model.d.ts.map +1 -1
- package/dist/src/record.d.ts +1 -1
- package/dist/src/record.d.ts.map +1 -1
- package/dist/src/record.js +286 -46
- package/dist/src/record.js.map +1 -1
- package/package.json +7 -7
- package/src/bind.ts +182 -31
- package/src/capture.ts +33 -0
- package/src/error.ts +1 -0
- package/src/exact.ts +24 -22
- package/src/explain.ts +21 -0
- package/src/explanation-entries.ts +85 -0
- package/src/model.ts +2 -0
- package/src/record.ts +268 -49
package/src/bind.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { captureDefinition } from './capture.ts'
|
|
1
2
|
import { createHash } from 'node:crypto'
|
|
3
|
+
import { Time } from '@cavelang/core'
|
|
2
4
|
import * as Canonical from '@cavelang/canonical'
|
|
3
|
-
import { Uuidv7 } from '@cavelang/core'
|
|
4
5
|
import { Pattern, query, type Match, type Options as QueryOptions } from '@cavelang/query'
|
|
5
6
|
import { Exact } from '@cavelang/solver'
|
|
7
|
+
import { QuerySql } from '@cavelang/store'
|
|
6
8
|
import type { Store } from '@cavelang/store'
|
|
7
9
|
import { ScenarioInputError } from './error.ts'
|
|
8
10
|
import * as Numeric from './exact.ts'
|
|
@@ -43,13 +45,59 @@ const stableStringify = (value: Json): string => {
|
|
|
43
45
|
const sha256 = (text: string): string =>
|
|
44
46
|
`sha256:${createHash('sha256').update(text).digest('hex')}`
|
|
45
47
|
|
|
48
|
+
/** Freeze owned result data, including shared value/evidence arrays. */
|
|
49
|
+
const freezeInputs = (record: InputRecord): InputRecord => {
|
|
50
|
+
const pending: object[] = [record]
|
|
51
|
+
while (pending.length > 0) {
|
|
52
|
+
const value = pending.pop()!
|
|
53
|
+
if (Object.isFrozen(value)) continue
|
|
54
|
+
Object.freeze(value)
|
|
55
|
+
for (const child of Object.values(value)) {
|
|
56
|
+
if (child !== null && typeof child === 'object') pending.push(child)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return record
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** JSON semantics omit undefined optional fields before stable key ordering. */
|
|
63
|
+
export const definitionDigest = (definition: Definition): string =>
|
|
64
|
+
sha256(stableStringify(JSON.parse(JSON.stringify(definition)) as Json))
|
|
65
|
+
|
|
66
|
+
export const inputDigest = (record: Omit<InputRecord, 'digest'>): string =>
|
|
67
|
+
sha256(stableStringify(JSON.parse(JSON.stringify(record)) as Json))
|
|
68
|
+
|
|
46
69
|
const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/
|
|
47
70
|
|
|
48
71
|
const invalid = (message: string, bindingId?: string): never => {
|
|
49
72
|
throw new ScenarioInputError('invalid-definition', message, bindingId)
|
|
50
73
|
}
|
|
51
74
|
|
|
75
|
+
const choice = (value: unknown, choices: readonly string[], name: string, bindingId?: string): void => {
|
|
76
|
+
if (typeof value !== 'string' || !choices.includes(value)) {
|
|
77
|
+
invalid(`${name} must be one of ${choices.join(', ')}`, bindingId)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const requireObject = (value: unknown, name: string, bindingId?: string): void => {
|
|
82
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
83
|
+
invalid(`${name} must be an object`, bindingId)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
52
87
|
const validateBinding = (binding: Binding): void => {
|
|
88
|
+
requireObject(binding, 'binding')
|
|
89
|
+
if (typeof binding.id !== 'string') invalid('binding identifier must be a string')
|
|
90
|
+
requireObject(binding.expected, 'expected', binding.id)
|
|
91
|
+
requireObject(binding.policies, 'policies', binding.id)
|
|
92
|
+
if (typeof binding.query !== 'string') invalid('query must be a string', binding.id)
|
|
93
|
+
choice(binding.cardinality, ['one', 'optional', 'many'], 'cardinality', binding.id)
|
|
94
|
+
choice(binding.expected.kind, ['boolean', 'integer', 'number', 'enum', 'text'], 'expected.kind', binding.id)
|
|
95
|
+
choice(binding.policies.missing, ['reject', 'omit', 'empty'], 'missing policy', binding.id)
|
|
96
|
+
choice(binding.policies.contested, ['reject', 'allow'], 'contested policy', binding.id)
|
|
97
|
+
choice(binding.policies.retracted, ['reject', 'exclude', 'include'], 'retracted policy', binding.id)
|
|
98
|
+
choice(binding.policies.unresolved, ['reject', 'allow'], 'unresolved policy', binding.id)
|
|
99
|
+
if (typeof binding.scenarioOverride !== 'boolean') invalid('scenarioOverride must be a boolean', binding.id)
|
|
100
|
+
if (binding.cardinality === 'many') choice(binding.reduce, ['all', 'min', 'max', 'sum'], 'reduce', binding.id)
|
|
53
101
|
if (!identifierPattern.test(binding.id)) invalid('identifier must use letters, numbers, dot, underscore, slash, or dash', binding.id)
|
|
54
102
|
if (binding.expected.kind !== 'boolean' && binding.select === undefined) {
|
|
55
103
|
invalid('select is required for non-Boolean inputs', binding.id)
|
|
@@ -67,15 +115,54 @@ const validateBinding = (binding: Binding): void => {
|
|
|
67
115
|
binding.expected.kind !== 'number' && binding.expected.kind !== 'integer') {
|
|
68
116
|
invalid(`reduction ${binding.reduce} requires a numeric input`, binding.id)
|
|
69
117
|
}
|
|
118
|
+
if (binding.expected.kind === 'boolean') {
|
|
119
|
+
const { trueValue, falseValue } = binding.expected
|
|
120
|
+
if ((trueValue !== undefined && typeof trueValue !== 'string') ||
|
|
121
|
+
(falseValue !== undefined && typeof falseValue !== 'string')) {
|
|
122
|
+
invalid('Boolean trueValue and falseValue must be strings when supplied', binding.id)
|
|
123
|
+
}
|
|
124
|
+
if ((trueValue ?? 'true') === (falseValue ?? 'false')) {
|
|
125
|
+
invalid('Boolean true and false values must be distinct', binding.id)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
70
128
|
if (binding.expected.kind === 'enum') {
|
|
71
|
-
|
|
72
|
-
|
|
129
|
+
const values = binding.expected.values
|
|
130
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
131
|
+
invalid('enum values must be a non-empty array of unique strings', binding.id)
|
|
132
|
+
}
|
|
133
|
+
const seen = new Set<string>()
|
|
134
|
+
for (const value of values) {
|
|
135
|
+
if (typeof value !== 'string' || seen.has(value)) {
|
|
136
|
+
invalid('enum values must be a non-empty array of unique strings', binding.id)
|
|
137
|
+
}
|
|
138
|
+
seen.add(value)
|
|
73
139
|
}
|
|
74
140
|
}
|
|
75
141
|
if (binding.expected.kind === 'number' || binding.expected.kind === 'integer') {
|
|
76
|
-
|
|
142
|
+
if (binding.expected.unit !== undefined &&
|
|
143
|
+
(typeof binding.expected.unit !== 'string' || binding.expected.unit === '')) {
|
|
144
|
+
invalid('target unit must be a non-empty string when supplied', binding.id)
|
|
145
|
+
}
|
|
146
|
+
const conversions = binding.expected.conversions
|
|
147
|
+
if (conversions !== undefined && !Array.isArray(conversions)) {
|
|
148
|
+
invalid('unit conversions must be an array', binding.id)
|
|
149
|
+
}
|
|
150
|
+
const pairs = new Set<string>()
|
|
151
|
+
for (const conversion of conversions ?? []) {
|
|
152
|
+
if (conversion === null || typeof conversion !== 'object' ||
|
|
153
|
+
typeof conversion.from !== 'string' || conversion.from === '' ||
|
|
154
|
+
typeof conversion.to !== 'string' || conversion.to === '') {
|
|
155
|
+
invalid('unit conversion endpoints must be non-empty strings', binding.id)
|
|
156
|
+
}
|
|
77
157
|
if (conversion.from === conversion.to) invalid('unit conversion must change the unit', binding.id)
|
|
78
|
-
|
|
158
|
+
const pair = JSON.stringify([conversion.from, conversion.to])
|
|
159
|
+
if (pairs.has(pair)) invalid('unit conversion pairs must be unique', binding.id)
|
|
160
|
+
pairs.add(pair)
|
|
161
|
+
let positive = false
|
|
162
|
+
try { positive = Exact.compare(conversion.factor, '0') > 0 } catch {
|
|
163
|
+
invalid('unit conversion factor must be a positive exact rational', binding.id)
|
|
164
|
+
}
|
|
165
|
+
if (!positive) invalid('unit conversion factor must be positive', binding.id)
|
|
79
166
|
}
|
|
80
167
|
}
|
|
81
168
|
const pattern: Pattern.t = (() => {
|
|
@@ -85,40 +172,62 @@ const validateBinding = (binding: Binding): void => {
|
|
|
85
172
|
return invalid(`invalid CAVE-Q pattern: ${error instanceof Error ? error.message : String(error)}`, binding.id)
|
|
86
173
|
}
|
|
87
174
|
})()
|
|
175
|
+
if (binding.select !== undefined) {
|
|
176
|
+
if (typeof binding.select !== 'string') invalid('select must be a variable name string', binding.id)
|
|
177
|
+
const slots = [pattern.subject, pattern.verb,
|
|
178
|
+
...(pattern.payload.kind === 'object' ? [pattern.payload.object] :
|
|
179
|
+
pattern.payload.kind === 'attribute' ? [pattern.payload.value] : [])]
|
|
180
|
+
if (!slots.some(slot => slot.kind === 'var' && slot.name === binding.select)) {
|
|
181
|
+
invalid('select must name a variable bound by the CAVE-Q pattern', binding.id)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
88
184
|
if (pattern.verb.kind === 'verb' && pattern.verb.transitive) {
|
|
89
185
|
invalid('transitive bindings are deferred until snapshot-aware shared query primitives are available', binding.id)
|
|
90
186
|
}
|
|
91
187
|
}
|
|
92
188
|
|
|
93
189
|
const validateDefinition = (definition: Definition): void => {
|
|
94
|
-
|
|
95
|
-
|
|
190
|
+
requireObject(definition, 'definition')
|
|
191
|
+
requireObject(definition.snapshot, 'snapshot')
|
|
192
|
+
if (!Array.isArray(definition.bindings)) invalid('bindings must be an array')
|
|
193
|
+
if (definition.overlay !== undefined && typeof definition.overlay !== 'string') {
|
|
194
|
+
invalid('overlay must be a string when supplied')
|
|
195
|
+
}
|
|
196
|
+
choice(definition.snapshot.aliases, ['exact', 'closure'], 'snapshot.aliases')
|
|
197
|
+
choice(definition.snapshot.resolution, ['coexisting', 'winner'], 'snapshot.resolution')
|
|
198
|
+
const { asOf, at } = definition.snapshot
|
|
199
|
+
if (asOf !== undefined && (typeof asOf !== 'string' || QuerySql.asOfBoundary(asOf) === undefined)) {
|
|
200
|
+
invalid('asOf must be a valid date-like period, timestamp or UUIDv7 string')
|
|
201
|
+
}
|
|
202
|
+
if (at !== undefined && (typeof at !== 'string' || Time.parseInstant(at) === undefined)) {
|
|
203
|
+
invalid('at must be a valid date-like period or timestamp string')
|
|
204
|
+
}
|
|
205
|
+
if (typeof definition.id !== 'string' || !identifierPattern.test(definition.id)) {
|
|
206
|
+
invalid('scenario identifier must be a string using letters, numbers, dot, underscore, slash, or dash')
|
|
207
|
+
}
|
|
208
|
+
if (typeof definition.modelDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(definition.modelDigest)) {
|
|
209
|
+
invalid('modelDigest must be a lowercase SHA-256 digest string')
|
|
210
|
+
}
|
|
96
211
|
if (!Number.isFinite(definition.snapshot.minimumConfidence) ||
|
|
97
212
|
definition.snapshot.minimumConfidence < 0 || definition.snapshot.minimumConfidence > 1) {
|
|
98
213
|
invalid('minimumConfidence must be between 0 and 1')
|
|
99
214
|
}
|
|
100
215
|
const ids = new Set<string>()
|
|
101
216
|
for (const binding of definition.bindings) {
|
|
217
|
+
validateBinding(binding)
|
|
102
218
|
if (ids.has(binding.id)) invalid(`duplicate binding identifier ${JSON.stringify(binding.id)}`)
|
|
103
219
|
ids.add(binding.id)
|
|
104
|
-
validateBinding(binding)
|
|
105
220
|
}
|
|
106
221
|
}
|
|
107
222
|
|
|
108
|
-
const boundaryUpper = (text: string): string => {
|
|
109
|
-
const start = Date.parse(text.includes('T') ? text : `${text}T00:00:00Z`)
|
|
110
|
-
if (Number.isNaN(start)) throw new ScenarioInputError('invalid-definition', `cannot parse as-of boundary ${JSON.stringify(text)}`)
|
|
111
|
-
return Uuidv7.at(start + (text.includes('T') ? 1_000 : 86_400_000), 0, new Uint8Array(8))
|
|
112
|
-
}
|
|
113
|
-
|
|
114
223
|
const transactionTime = (store: Store, asOf: string | undefined): string | null => {
|
|
115
224
|
if (asOf === undefined) {
|
|
116
225
|
return (store.db.prepare('SELECT MAX(tx) AS tx FROM cave_claim').get() as { tx: string | null }).tx
|
|
117
226
|
}
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
(
|
|
227
|
+
const boundary = QuerySql.asOfBoundary(asOf)
|
|
228
|
+
if (boundary === undefined) throw new ScenarioInputError('invalid-definition', `cannot parse as-of boundary ${JSON.stringify(asOf)}`)
|
|
229
|
+
return (store.db.prepare(`SELECT MAX(tx) AS tx FROM cave_claim WHERE tx ${boundary.operator} ?`)
|
|
230
|
+
.get(boundary.tx) as { tx: string | null }).tx
|
|
122
231
|
}
|
|
123
232
|
|
|
124
233
|
const queryOptions = (snapshot: Snapshot, resolve: boolean): QueryOptions => ({
|
|
@@ -137,10 +246,11 @@ const supportIds = (match: Match): readonly string[] =>
|
|
|
137
246
|
|
|
138
247
|
const rawBaseCandidates = (store: Store, binding: Binding, snapshot: Snapshot): RawCandidate[] => {
|
|
139
248
|
const pattern = includingRetractions(binding.query)
|
|
140
|
-
const unresolved =
|
|
249
|
+
const unresolved = snapshot.resolution === 'coexisting'
|
|
250
|
+
? query(store, pattern, queryOptions(snapshot, false)) : undefined
|
|
141
251
|
const winners = query(store, pattern, queryOptions(snapshot, true))
|
|
142
252
|
const winnerIds = new Set(winners.flatMap(supportIds))
|
|
143
|
-
const selected =
|
|
253
|
+
const selected = unresolved ?? winners
|
|
144
254
|
return selected.map(match => {
|
|
145
255
|
const ids = supportIds(match)
|
|
146
256
|
return {
|
|
@@ -218,7 +328,7 @@ const uncertainty = (candidate: RawCandidate, expected: Extract<Expected, { kind
|
|
|
218
328
|
}
|
|
219
329
|
}
|
|
220
330
|
|
|
221
|
-
const valueOf = (candidate: RawCandidate, binding: Binding): Value => {
|
|
331
|
+
const valueOf = (candidate: RawCandidate, binding: Binding, selectsAttributeValue: boolean): Value => {
|
|
222
332
|
const expected = binding.expected
|
|
223
333
|
const text = selectedText(candidate, binding)
|
|
224
334
|
switch (expected.kind) {
|
|
@@ -246,10 +356,10 @@ const valueOf = (candidate: RawCandidate, binding: Binding): Value => {
|
|
|
246
356
|
return { kind: 'text', value: plain(text) }
|
|
247
357
|
case 'integer':
|
|
248
358
|
case 'number': {
|
|
249
|
-
const authored =
|
|
359
|
+
const authored = text
|
|
250
360
|
if (authored === undefined) throw new ScenarioInputError('invalid-value', 'matched row has no numeric value', binding.id)
|
|
251
361
|
const parsed = Numeric.convert(Numeric.parse(authored, binding.id), expected.unit, expected.conversions, binding.id)
|
|
252
|
-
const uncertainty_ = uncertainty(candidate, expected, binding.id)
|
|
362
|
+
const uncertainty_ = selectsAttributeValue ? uncertainty(candidate, expected, binding.id) : undefined
|
|
253
363
|
const common = {
|
|
254
364
|
...(parsed.unit === undefined ? {} : { unit: parsed.unit }),
|
|
255
365
|
authored,
|
|
@@ -271,8 +381,8 @@ const evidenceOf = (candidate: RawCandidate): readonly Evidence[] => candidate.o
|
|
|
271
381
|
[{ origin: 'scenario', claimIds: [candidate.scenarioClaimId!] }] :
|
|
272
382
|
[{ origin: 'belief', rowIds: supportIds(candidate.match) }]
|
|
273
383
|
|
|
274
|
-
const publicCandidate = (candidate: RawCandidate, binding: Binding): Candidate => ({
|
|
275
|
-
value: valueOf(candidate, binding),
|
|
384
|
+
const publicCandidate = (candidate: RawCandidate, binding: Binding, selectsAttributeValue: boolean): Candidate => ({
|
|
385
|
+
value: valueOf(candidate, binding, selectsAttributeValue),
|
|
276
386
|
confidence: candidate.match.row?.conf ?? 1,
|
|
277
387
|
evidence: evidenceOf(candidate)
|
|
278
388
|
})
|
|
@@ -283,12 +393,16 @@ const evidenceKey = (candidate: RawCandidate): string => candidate.origin === 's
|
|
|
283
393
|
const reduce = (values: readonly Value[], binding: Binding): Value | readonly Value[] => {
|
|
284
394
|
if (binding.cardinality !== 'many' || binding.reduce === 'all') return values
|
|
285
395
|
const numeric = values as readonly Extract<Value, { kind: 'number' | 'integer' }>[]
|
|
396
|
+
const unit = numeric[0]?.unit
|
|
397
|
+
if (numeric.some(value => value.unit !== unit)) {
|
|
398
|
+
throw new ScenarioInputError('incompatible-unit',
|
|
399
|
+
'numeric reduction requires a common unit; declare a target unit and explicit conversions', binding.id)
|
|
400
|
+
}
|
|
286
401
|
if (binding.reduce === 'sum') {
|
|
287
402
|
const exact = numeric.reduce(
|
|
288
403
|
(total, value) => Numeric.add(total, value.kind === 'integer' ? { numerator: value.value, denominator: '1' } : value.value),
|
|
289
404
|
{ numerator: '0', denominator: '1' }
|
|
290
405
|
)
|
|
291
|
-
const unit = numeric[0]?.unit
|
|
292
406
|
const approximate = numeric.some(value => value.approximate)
|
|
293
407
|
return binding.expected.kind === 'integer' ?
|
|
294
408
|
{ kind: 'integer', value: exact.numerator, ...(unit === undefined ? {} : { unit }), approximate } :
|
|
@@ -334,7 +448,10 @@ const bindOne = (
|
|
|
334
448
|
throw new ScenarioInputError('missing-input', 'query matched no eligible value', binding.id)
|
|
335
449
|
}
|
|
336
450
|
|
|
337
|
-
const
|
|
451
|
+
const { payload } = Pattern.parse(binding.query)
|
|
452
|
+
const selectsAttributeValue = payload.kind === 'attribute' && payload.value.kind === 'var' &&
|
|
453
|
+
payload.value.name === binding.select
|
|
454
|
+
const candidates = selected.map(candidate => publicCandidate(candidate, binding, selectsAttributeValue))
|
|
338
455
|
if (selected.length === 0 && binding.policies.missing === 'omit') return { id: binding.id, candidates }
|
|
339
456
|
if (selected.length === 0) return { id: binding.id, candidates, value: [] }
|
|
340
457
|
const values = candidates.map(candidate => candidate.value)
|
|
@@ -342,8 +459,26 @@ const bindOne = (
|
|
|
342
459
|
return { id: binding.id, candidates, ...(values[0] === undefined ? {} : { value: values[0] }) }
|
|
343
460
|
}
|
|
344
461
|
|
|
462
|
+
const revisionOf = (store: Store): { external: string, local: string } => ({
|
|
463
|
+
external: String(store.db.prepare('PRAGMA data_version').get()!['data_version']),
|
|
464
|
+
local: String(store.db.prepare('SELECT total_changes() AS changes').get()!['changes'])
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
const snapshotChanged = (): never => {
|
|
468
|
+
throw new ScenarioInputError('snapshot-changed', 'store changed while binding scenario inputs; retry binding the scenario')
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
345
472
|
export const bind = (store: Store, definition: Definition): InputRecord => {
|
|
473
|
+
definition = captureDefinition(definition)
|
|
346
474
|
validateDefinition(definition)
|
|
475
|
+
let authoredDigest: string
|
|
476
|
+
try {
|
|
477
|
+
authoredDigest = definitionDigest(definition)
|
|
478
|
+
} catch {
|
|
479
|
+
return invalid('definition must be JSON serializable within the supported serialization depth')
|
|
480
|
+
}
|
|
481
|
+
const initialRevision = revisionOf(store)
|
|
347
482
|
|
|
348
483
|
const frozenTx = transactionTime(store, definition.snapshot.asOf)
|
|
349
484
|
const effectiveSnapshot: Snapshot = definition.snapshot.asOf === undefined && frozenTx !== null ?
|
|
@@ -363,11 +498,17 @@ export const bind = (store: Store, definition: Definition): InputRecord => {
|
|
|
363
498
|
binding.id,
|
|
364
499
|
rawBaseCandidates(store, binding, effectiveSnapshot)
|
|
365
500
|
])) as Readonly<Record<string, readonly RawCandidate[]>>
|
|
501
|
+
const baseRevision = revisionOf(store)
|
|
502
|
+
if (baseRevision.external !== initialRevision.external || baseRevision.local !== initialRevision.local) snapshotChanged()
|
|
366
503
|
const overlay = overlayResult(store, { ...definition, snapshot: effectiveSnapshot }, canonical, claimIds)
|
|
504
|
+
// Rolled-back overlay inserts advance this connection's total_changes.
|
|
505
|
+
// Peer commits still invalidate the read, including commits below frozenTx.
|
|
506
|
+
const materializedRevision = revisionOf(store)
|
|
507
|
+
if (materializedRevision.external !== initialRevision.external) snapshotChanged()
|
|
367
508
|
const bindings = definition.bindings.map(binding => bindOne(
|
|
368
509
|
binding,
|
|
369
510
|
base[binding.id] ?? [],
|
|
370
|
-
overlay[binding.id]
|
|
511
|
+
Object.hasOwn(overlay, binding.id) ? overlay[binding.id]! : [],
|
|
371
512
|
definition.snapshot.minimumConfidence
|
|
372
513
|
))
|
|
373
514
|
const values: Record<string, Value | readonly Value[]> = {}
|
|
@@ -382,15 +523,25 @@ export const bind = (store: Store, definition: Definition): InputRecord => {
|
|
|
382
523
|
schema,
|
|
383
524
|
scenarioId: definition.id,
|
|
384
525
|
modelDigest: definition.modelDigest,
|
|
385
|
-
|
|
526
|
+
definitionDigest: authoredDigest,
|
|
527
|
+
snapshot: {
|
|
528
|
+
aliases: definition.snapshot.aliases,
|
|
529
|
+
resolution: definition.snapshot.resolution,
|
|
530
|
+
minimumConfidence: definition.snapshot.minimumConfidence,
|
|
531
|
+
...definition.snapshot.asOf === undefined ? {} : { asOf: definition.snapshot.asOf },
|
|
532
|
+
...definition.snapshot.at === undefined ? {} : { at: definition.snapshot.at },
|
|
533
|
+
transactionTime: frozenTx
|
|
534
|
+
},
|
|
386
535
|
overlay: { digest: overlayDigest, source: `scenario/${definition.id}`, claimIds },
|
|
387
536
|
values,
|
|
388
537
|
bindings,
|
|
389
538
|
supportingRowIds,
|
|
390
539
|
scenarioClaimIds
|
|
391
540
|
}
|
|
392
|
-
const digest =
|
|
393
|
-
|
|
541
|
+
const digest = inputDigest(withoutDigest)
|
|
542
|
+
const finalRevision = revisionOf(store)
|
|
543
|
+
if (finalRevision.external !== materializedRevision.external || finalRevision.local !== materializedRevision.local) snapshotChanged()
|
|
544
|
+
return freezeInputs({ ...withoutDigest, digest })
|
|
394
545
|
}
|
|
395
546
|
|
|
396
547
|
/** Materializes and rolls back inputs before `evaluate` is invoked. */
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Definition } from './model.ts'
|
|
2
|
+
|
|
3
|
+
/** Capture own data once while leaving invalid scalar values for field diagnostics. */
|
|
4
|
+
export const captureDefinition = (supplied: Definition): Definition => {
|
|
5
|
+
const copies = new Map<object, object>()
|
|
6
|
+
type Frame = { target: object, entries: [string, unknown][], index: number }
|
|
7
|
+
const pending: Frame[] = []
|
|
8
|
+
const copy = (value: unknown): unknown => {
|
|
9
|
+
if (value === null || typeof value !== 'object') return value
|
|
10
|
+
const existing = copies.get(value)
|
|
11
|
+
if (existing !== undefined) return existing
|
|
12
|
+
const captured = Array.isArray(value) ? new Array(value.length) : Object.create(null) as object
|
|
13
|
+
copies.set(value, captured)
|
|
14
|
+
// Capture sibling getters before descending, matching Object.entries in
|
|
15
|
+
// the recursive implementation. The explicit stack preserves depth-first
|
|
16
|
+
// traversal without spending one JavaScript call frame per nested value.
|
|
17
|
+
pending.push({ target: captured, entries: Object.entries(value), index: 0 })
|
|
18
|
+
return captured
|
|
19
|
+
}
|
|
20
|
+
const captured = copy(supplied)
|
|
21
|
+
while (pending.length > 0) {
|
|
22
|
+
const frame = pending[pending.length - 1]!
|
|
23
|
+
if (frame.index === frame.entries.length) {
|
|
24
|
+
pending.pop()
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
const [key, child] = frame.entries[frame.index++]!
|
|
28
|
+
Object.defineProperty(frame.target, key, {
|
|
29
|
+
value: copy(child), enumerable: true, writable: true, configurable: true
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
return captured as Definition
|
|
33
|
+
}
|
package/src/error.ts
CHANGED
package/src/exact.ts
CHANGED
|
@@ -17,32 +17,34 @@ type Parsed = {
|
|
|
17
17
|
readonly approximate: boolean
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const reduce =
|
|
21
|
-
const divisor = (() => {
|
|
22
|
-
let left = numerator < 0n ? -numerator : numerator
|
|
23
|
-
let right = denominator < 0n ? -denominator : denominator
|
|
24
|
-
while (right !== 0n) [left, right] = [right, left % right]
|
|
25
|
-
return left
|
|
26
|
-
})()
|
|
27
|
-
const sign = denominator < 0n ? -1n : 1n
|
|
28
|
-
return {
|
|
29
|
-
numerator: String((numerator / divisor) * sign),
|
|
30
|
-
denominator: String((denominator / divisor) * sign)
|
|
31
|
-
}
|
|
32
|
-
}
|
|
20
|
+
const reduce = Exact.fromBigInts
|
|
33
21
|
|
|
34
22
|
const multiply = (left: ExactNumber, right: ExactNumber): ExactNumber =>
|
|
35
23
|
reduce(BigInt(left.numerator) * BigInt(right.numerator), BigInt(left.denominator) * BigInt(right.denominator))
|
|
36
24
|
|
|
37
|
-
export const add = (left: ExactNumber, right: ExactNumber): ExactNumber =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
25
|
+
export const add = (left: ExactNumber, right: ExactNumber): ExactNumber => {
|
|
26
|
+
const a = BigInt(left.numerator), b = BigInt(left.denominator)
|
|
27
|
+
const c = BigInt(right.numerator), d = BigInt(right.denominator)
|
|
28
|
+
return b === d ? reduce(a + c, b) : reduce(a * d + c * b, b * d)
|
|
29
|
+
}
|
|
42
30
|
|
|
43
|
-
export const compare = (left: ExactNumber, right: ExactNumber): number =>
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
export const compare = (left: ExactNumber, right: ExactNumber): number => {
|
|
32
|
+
const a = BigInt(left.numerator), b = BigInt(left.denominator)
|
|
33
|
+
const c = BigInt(right.numerator), d = BigInt(right.denominator)
|
|
34
|
+
if (b > 0n && d > 0n) {
|
|
35
|
+
if (b === d || a === 0n || c === 0n || (a < 0n) !== (c < 0n)) {
|
|
36
|
+
return a < c ? -1 : a > c ? 1 : 0
|
|
37
|
+
}
|
|
38
|
+
if (a === c) return (b < d) === (a > 0n) ? 1 : -1
|
|
39
|
+
if ((a < c) === (a > 0n ? b > d : b < d)) return a < c ? -1 : 1
|
|
40
|
+
}
|
|
41
|
+
// Scenario values are normalized with positive denominators. Other fraction
|
|
42
|
+
// forms need the sign of the cross-products' common denominator as well.
|
|
43
|
+
const difference = b === d && b > 0n ? a - c : a * d - c * b
|
|
44
|
+
if (difference === 0n) return 0
|
|
45
|
+
const order = difference < 0n ? -1 : 1
|
|
46
|
+
return (b < 0n) !== (d < 0n) ? -order : order
|
|
47
|
+
}
|
|
46
48
|
|
|
47
49
|
const rational = (value: Model.Rational): ExactNumber => Exact.rational(value)
|
|
48
50
|
|
|
@@ -65,7 +67,7 @@ export const parse = (authored: string, bindingId: string): Parsed => {
|
|
|
65
67
|
}
|
|
66
68
|
const base = rational(digits)
|
|
67
69
|
return {
|
|
68
|
-
exact: reduce(BigInt(base.numerator) * factor, BigInt(base.denominator)),
|
|
70
|
+
exact: factor === 1n ? base : reduce(BigInt(base.numerator) * factor, BigInt(base.denominator)),
|
|
69
71
|
...(unit === undefined ? {} : { unit }),
|
|
70
72
|
approximate
|
|
71
73
|
}
|
package/src/explain.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Explain } from '@cavelang/solver'
|
|
2
2
|
import type { Binding, Definition, InputRecord, Value } from './model.ts'
|
|
3
|
+
import { schema } from './model.ts'
|
|
4
|
+
import { definitionDigest, inputDigest } from './bind.ts'
|
|
5
|
+
import { captureDefinition } from './capture.ts'
|
|
3
6
|
|
|
4
7
|
const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
|
|
5
8
|
|
|
@@ -37,18 +40,36 @@ const bindingInput = (
|
|
|
37
40
|
|
|
38
41
|
/** Convert a frozen scenario binding into solver-neutral run provenance. */
|
|
39
42
|
export const explanationContext = (definition: Definition, record: InputRecord): Explain.Context => {
|
|
43
|
+
definition = captureDefinition(definition)
|
|
44
|
+
record = structuredClone(record)
|
|
45
|
+
if (record.schema !== schema) {
|
|
46
|
+
throw new TypeError(`unsupported scenario input schema ${JSON.stringify(record.schema)}`)
|
|
47
|
+
}
|
|
40
48
|
if (record.scenarioId !== definition.id) {
|
|
41
49
|
throw new TypeError(`scenario record ${JSON.stringify(record.scenarioId)} does not match ${JSON.stringify(definition.id)}`)
|
|
42
50
|
}
|
|
43
51
|
if (record.modelDigest !== definition.modelDigest) {
|
|
44
52
|
throw new TypeError(`scenario record model digest does not match its definition`)
|
|
45
53
|
}
|
|
54
|
+
if (record.definitionDigest !== definitionDigest(definition)) {
|
|
55
|
+
throw new TypeError('scenario record definition digest is missing or does not match; bind the intended definition again')
|
|
56
|
+
}
|
|
57
|
+
const { digest, ...contents } = record
|
|
58
|
+
if (digest !== inputDigest(contents)) {
|
|
59
|
+
throw new TypeError('scenario record input digest does not match its contents')
|
|
60
|
+
}
|
|
46
61
|
const definitions = new Map(definition.bindings.map(binding => [binding.id, binding]))
|
|
62
|
+
const seen = new Set<string>()
|
|
47
63
|
const inputs = record.bindings.map(result => {
|
|
48
64
|
const binding = definitions.get(result.id)
|
|
49
65
|
if (binding === undefined) throw new TypeError(`scenario record contains unknown binding ${JSON.stringify(result.id)}`)
|
|
66
|
+
if (seen.has(result.id)) throw new TypeError(`scenario record contains duplicate binding ${JSON.stringify(result.id)}`)
|
|
67
|
+
seen.add(result.id)
|
|
50
68
|
return bindingInput(binding, result)
|
|
51
69
|
})
|
|
70
|
+
for (const id of definitions.keys()) {
|
|
71
|
+
if (!seen.has(id)) throw new TypeError(`scenario record is missing binding ${JSON.stringify(id)}`)
|
|
72
|
+
}
|
|
52
73
|
return {
|
|
53
74
|
modelDigest: record.modelDigest,
|
|
54
75
|
scenario: {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Explain } from '@cavelang/solver'
|
|
2
|
+
|
|
3
|
+
const object = (value: unknown, path: string): Record<string, unknown> => {
|
|
4
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${path} must be an object`)
|
|
5
|
+
return value as Record<string, unknown>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const text = (value: unknown, path: string): void => {
|
|
9
|
+
if (typeof value !== 'string') throw new TypeError(`${path} must be a string`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const strings = (value: unknown, path: string): void => {
|
|
13
|
+
if (!Array.isArray(value)) throw new TypeError(`${path} must be an array of strings`)
|
|
14
|
+
for (let index = 0; index < value.length; index++) text(value[index], `${path}[${index}]`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const choice = (value: unknown, choices: readonly string[], path: string): void => {
|
|
18
|
+
if (typeof value !== 'string' || !choices.includes(value)) throw new TypeError(`${path} must be one of ${choices.join(', ')}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const element = (value: unknown, path: string): Record<string, unknown> => {
|
|
22
|
+
const entry = object(value, path)
|
|
23
|
+
text(entry.id, `${path}.id`)
|
|
24
|
+
strings(entry.evidenceRowIds, `${path}.evidenceRowIds`)
|
|
25
|
+
strings(entry.scenarioInputIds, `${path}.scenarioInputIds`)
|
|
26
|
+
if (entry.description !== undefined) text(entry.description, `${path}.description`)
|
|
27
|
+
if (entry.declaration !== undefined) {
|
|
28
|
+
const declaration = object(entry.declaration, `${path}.declaration`)
|
|
29
|
+
text(declaration.uri, `${path}.declaration.uri`)
|
|
30
|
+
for (const field of ['line', 'column']) {
|
|
31
|
+
const position = declaration[field]
|
|
32
|
+
if (position !== undefined && (typeof position !== 'number' || !Number.isSafeInteger(position) || position <= 0)) {
|
|
33
|
+
throw new TypeError(`${path}.declaration.${field} must be a positive safe integer`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return entry
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const declared = (entry: Record<string, unknown>, path: string): void => {
|
|
41
|
+
if (typeof entry.declared !== 'boolean') throw new TypeError(`${path}.declared must be a boolean`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Called after collection validation, over captured or decoded JSON only. */
|
|
45
|
+
export const validateExplanationEntries = (report: Explain.Report): void => {
|
|
46
|
+
report.run.inputs.forEach((value, index) => {
|
|
47
|
+
const path = `solver explanation inputs[${index}]`
|
|
48
|
+
const entry = object(value, path)
|
|
49
|
+
text(entry.id, `${path}.id`)
|
|
50
|
+
strings(entry.evidenceRowIds, `${path}.evidenceRowIds`)
|
|
51
|
+
strings(entry.scenarioClaimIds, `${path}.scenarioClaimIds`)
|
|
52
|
+
if (entry.query !== undefined) text(entry.query, `${path}.query`)
|
|
53
|
+
})
|
|
54
|
+
const outcome = report.outcome
|
|
55
|
+
if (outcome.status === 'satisfied' || outcome.status === 'optimal') {
|
|
56
|
+
outcome.assignments.forEach((value, index) => {
|
|
57
|
+
const path = `solver explanation assignments[${index}]`
|
|
58
|
+
declared(element(value, path), path)
|
|
59
|
+
// Backend values are intentionally retained, including malformed shapes.
|
|
60
|
+
})
|
|
61
|
+
for (const field of ['hardConstraints', 'softConstraints'] as const) {
|
|
62
|
+
outcome[field].forEach((value, index) => {
|
|
63
|
+
const path = `solver explanation ${field}[${index}]`
|
|
64
|
+
const entry = element(value, path)
|
|
65
|
+
choice(entry.evaluation, field === 'hardConstraints' ? ['satisfied', 'violated', 'indeterminate'] : ['accepted', 'violated', 'indeterminate'], `${path}.evaluation`)
|
|
66
|
+
if (entry.evaluationReason !== undefined) text(entry.evaluationReason, `${path}.evaluationReason`)
|
|
67
|
+
if (field === 'softConstraints') {
|
|
68
|
+
const weight = object(entry.weight, `${path}.weight`)
|
|
69
|
+
text(weight.numerator, `${path}.weight.numerator`)
|
|
70
|
+
text(weight.denominator, `${path}.weight.denominator`)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (outcome.status === 'optimal') outcome.objectives.forEach((value, index) => {
|
|
76
|
+
const path = `solver explanation objectives[${index}]`
|
|
77
|
+
const entry = element(value, path)
|
|
78
|
+
declared(entry, path)
|
|
79
|
+
choice(entry.direction, ['minimize', 'maximize', 'unknown'], `${path}.direction`)
|
|
80
|
+
})
|
|
81
|
+
if (outcome.status === 'unsatisfied') outcome.core?.forEach((value, index) => {
|
|
82
|
+
const path = `solver explanation core[${index}]`
|
|
83
|
+
declared(element(value, path), path)
|
|
84
|
+
})
|
|
85
|
+
}
|
package/src/model.ts
CHANGED
|
@@ -104,6 +104,8 @@ export type InputRecord = {
|
|
|
104
104
|
readonly schema: typeof schema
|
|
105
105
|
readonly scenarioId: string
|
|
106
106
|
readonly modelDigest: string
|
|
107
|
+
/** Digest of the complete authored scenario definition, including binding queries and policies. */
|
|
108
|
+
readonly definitionDigest: string
|
|
107
109
|
readonly digest: string
|
|
108
110
|
readonly snapshot: FrozenSnapshot
|
|
109
111
|
readonly overlay: {
|