@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/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
- if (binding.expected.values.length === 0 || new Set(binding.expected.values).size !== binding.expected.values.length) {
72
- invalid('enum values must be non-empty and unique', binding.id)
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
- for (const conversion of binding.expected.conversions ?? []) {
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
- if (Exact.compare(conversion.factor, '0') <= 0) invalid('unit conversion factor must be positive', binding.id)
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
- if (!identifierPattern.test(definition.id)) invalid('scenario identifier must use letters, numbers, dot, underscore, slash, or dash')
95
- if (!/^sha256:[0-9a-f]{64}$/.test(definition.modelDigest)) invalid('modelDigest must be a lowercase SHA-256 digest')
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 id = asOf.toLowerCase()
119
- return Uuidv7.is(id) ?
120
- (store.db.prepare('SELECT MAX(tx) AS tx FROM cave_claim WHERE tx <= ?').get(id) as { tx: string | null }).tx :
121
- (store.db.prepare('SELECT MAX(tx) AS tx FROM cave_claim WHERE tx < ?').get(boundaryUpper(asOf)) as { tx: string | null }).tx
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 = query(store, pattern, queryOptions(snapshot, false))
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 = snapshot.resolution === 'winner' ? winners : unresolved
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 = candidate.match.at?.text ?? candidate.match.row?.value_text ?? text
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 candidates = selected.map(candidate => publicCandidate(candidate, binding))
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
- snapshot: { ...definition.snapshot, transactionTime: frozenTx },
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 = sha256(stableStringify(withoutDigest as unknown as Json))
393
- return { ...withoutDigest, digest }
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
@@ -1,5 +1,6 @@
1
1
  export type Code =
2
2
  | 'invalid-definition'
3
+ | 'snapshot-changed'
3
4
  | 'invalid-overlay'
4
5
  | 'missing-input'
5
6
  | 'ambiguous-input'
package/src/exact.ts CHANGED
@@ -17,32 +17,34 @@ type Parsed = {
17
17
  readonly approximate: boolean
18
18
  }
19
19
 
20
- const reduce = (numerator: bigint, denominator: bigint): ExactNumber => {
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
- reduce(
39
- BigInt(left.numerator) * BigInt(right.denominator) + BigInt(right.numerator) * BigInt(left.denominator),
40
- BigInt(left.denominator) * BigInt(right.denominator)
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
- BigInt(left.numerator) * BigInt(right.denominator) < BigInt(right.numerator) * BigInt(left.denominator) ? -1 :
45
- BigInt(left.numerator) * BigInt(right.denominator) > BigInt(right.numerator) * BigInt(left.denominator) ? 1 : 0
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: {