@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/record.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { Buffer } from 'node:buffer'
2
- import type { Explain } from '@cavelang/solver'
2
+ import { Validate, type Explain } from '@cavelang/solver'
3
3
  import { Key } from '@cavelang/core'
4
4
  import { canonicalizeText } from '@cavelang/canonical'
5
- import type { Store } from '@cavelang/store'
5
+ import type { Row, Store } from '@cavelang/store'
6
6
  import type { InputRecord } from './model.ts'
7
+ import { inputDigest } from './bind.ts'
8
+ import { validateExplanationEntries } from './explanation-entries.ts'
7
9
 
8
10
  export const resultSchema = 'cave.scenario/result@1' as const
9
11
  export const evaluationSchema = 'cave.scenario/evaluation@1' as const
@@ -138,38 +140,117 @@ const source: Readonly<Record<Kind, string>> = {
138
140
  }
139
141
 
140
142
  const entity = (kind: Kind, id: string): string => {
141
- if (id === '') throw new TypeError(`${kind} id must not be empty`)
143
+ if (typeof id !== 'string' || id === '') throw new TypeError(`${kind} id must be a non-empty string`)
142
144
  return `${prefix[kind]}/${id}`
143
145
  }
144
146
 
145
147
  const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
146
148
 
147
149
  const canonicalJson = (value: unknown): string => {
148
- const normalize = (item: unknown, path: string): unknown => {
150
+ const ancestors = new Set<object>()
151
+ type Child = { value: unknown, path: string }
152
+ const normalize = function* (item: unknown, path: string): Generator<Child, unknown, unknown> {
149
153
  if (item === null || typeof item === 'string' || typeof item === 'boolean') return item
150
154
  if (typeof item === 'number') {
151
155
  if (!Number.isFinite(item)) throw new TypeError(`${path} must be finite`)
152
156
  return item
153
157
  }
154
- if (Array.isArray(item)) return item.map((entry, index) => normalize(entry, `${path}[${index}]`))
155
158
  if (typeof item === 'object') {
156
- return Object.fromEntries(Object.entries(item as Record<string, unknown>)
157
- .filter(([, entry]) => entry !== undefined)
158
- .sort(([left], [right]) => compareText(left, right))
159
- .map(([key, entry]) => [key, normalize(entry, `${path}.${key}`)]))
159
+ if (ancestors.has(item)) throw new TypeError(`${path} contains a cycle`)
160
+ const prototype = Object.getPrototypeOf(item)
161
+ if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null) {
162
+ throw new TypeError(`${path} must be a plain JSON object`)
163
+ }
164
+ ancestors.add(item)
165
+ try {
166
+ // Read JSON's indexed array values, ignoring custom iterators, and visit
167
+ // holes so they fail instead of silently becoming null during stringify.
168
+ if (Array.isArray(item)) {
169
+ const length = item.length
170
+ const values: unknown[] = []
171
+ for (let index = 0; index < length; index++) {
172
+ values.push(yield { value: item[index], path: `${path}[${index}]` })
173
+ }
174
+ return values
175
+ }
176
+ const entries = Object.entries(item as Record<string, unknown>)
177
+ .filter(([, entry]) => entry !== undefined)
178
+ .sort(([left], [right]) => compareText(left, right))
179
+ const values: [string, unknown][] = []
180
+ for (const [key, entry] of entries) {
181
+ values.push([key, yield { value: entry, path: `${path}.${key}` }])
182
+ }
183
+ return Object.fromEntries(values)
184
+ } finally {
185
+ ancestors.delete(item)
186
+ }
160
187
  }
161
188
  throw new TypeError(`${path} is not JSON`)
162
189
  }
163
- return JSON.stringify(normalize(value, 'artifact'))
190
+ const stack = [normalize(value, 'artifact')]
191
+ let step = stack[0]!.next()
192
+ while (true) {
193
+ if (step.done) {
194
+ stack.pop()
195
+ if (stack.length === 0) break
196
+ step = stack[stack.length - 1]!.next(step.value)
197
+ } else {
198
+ const child = normalize(step.value.value, step.value.path)
199
+ stack.push(child)
200
+ step = child.next()
201
+ }
202
+ }
203
+ // Serialize the owned tree iteratively too: JSON.stringify recurses through
204
+ // containers. Retain its property order, including numeric-index keys.
205
+ const pending: ({ value: unknown } | { text: string })[] = [{ value: step.value }]
206
+ const output: string[] = []
207
+ while (pending.length > 0) {
208
+ const frame = pending.pop()!
209
+ if ('text' in frame) { output.push(frame.text); continue }
210
+ const item = frame.value
211
+ if (item === null || typeof item !== 'object') {
212
+ output.push(JSON.stringify(item))
213
+ } else if (Array.isArray(item)) {
214
+ output.push('[')
215
+ pending.push({ text: ']' })
216
+ for (let index = item.length - 1; index >= 0; index--) {
217
+ pending.push({ value: item[index] })
218
+ if (index > 0) pending.push({ text: ',' })
219
+ }
220
+ } else {
221
+ const entries = Object.entries(item)
222
+ output.push('{')
223
+ pending.push({ text: '}' })
224
+ for (let index = entries.length - 1; index >= 0; index--) {
225
+ const [key, child] = entries[index]!
226
+ pending.push({ value: child }, { text: `${JSON.stringify(key)}:` })
227
+ if (index > 0) pending.push({ text: ',' })
228
+ }
229
+ }
230
+ }
231
+ return output.join('')
164
232
  }
165
233
 
166
- const encode = (artifact: Artifact): string =>
167
- Buffer.from(canonicalJson(artifact), 'utf8').toString('base64url')
168
-
169
234
  const decode = (payload: string): Artifact => {
170
235
  let parsed: unknown
171
236
  try {
172
- parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
237
+ // Buffer's decoder ignores non-alphabet characters and surplus padding.
238
+ // Accept URL-safe text with either no padding or exactly the required padding.
239
+ const bytes = Buffer.from(payload, 'base64url')
240
+ const unpadded = bytes.toString('base64url')
241
+ const padded = unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=')
242
+ if (payload !== unpadded && payload !== padded) throw new TypeError('invalid base64url encoding')
243
+ parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes))
244
+ // A JSON reviver recurses through the payload and rejects otherwise valid
245
+ // deeply nested imports when the JavaScript call stack is exhausted.
246
+ const pending: unknown[] = [parsed]
247
+ while (pending.length > 0) {
248
+ const value = pending.pop()
249
+ if (typeof value === 'number' && !Number.isFinite(value)) throw new TypeError('non-finite JSON number')
250
+ if (value !== null && typeof value === 'object') {
251
+ for (const child of Object.values(value)) pending.push(child)
252
+ }
253
+ }
173
254
  } catch {
174
255
  throw new TypeError('recorded scenario artifact is not valid base64url JSON')
175
256
  }
@@ -189,24 +270,31 @@ const line = (kind: Kind, id: string, payload: string): string =>
189
270
  const claimOf = (store: Store, kind: Kind, id: string, payload: string) => {
190
271
  const parsed = canonicalizeText(line(kind, id, payload), store.registry())
191
272
  const claim = parsed.claims[0]?.claim
192
- if (parsed.problems.length > 0 || claim === undefined) {
273
+ if (parsed.problems.length > 0 || parsed.claims.length !== 1 || parsed.edges.length !== 0 ||
274
+ claim === undefined || claim.subject.kind !== 'entity' || claim.subject.text !== entity(kind, id) ||
275
+ claim.verb !== 'HAS' || claim.payload.kind !== 'attribute' ||
276
+ claim.payload.attribute !== 'artifact' || claim.payload.value.kind !== 'code' || claim.payload.value.raw !== payload) {
193
277
  const detail = parsed.problems.map(problem => `line ${problem.line}: ${problem.message}`).join('; ')
194
278
  throw new TypeError(`invalid ${kind} identity ${JSON.stringify(id)}${detail === '' ? '' : `: ${detail}`}`)
195
279
  }
196
280
  return { parsed, claim }
197
281
  }
198
282
 
283
+ const payloadOf = (store: Store, kind: Kind, id: string, row: Row.t): string => {
284
+ const stored = store.toClaim(row)
285
+ if (stored.payload.kind !== 'attribute' || stored.payload.attribute !== 'artifact' || stored.payload.value.kind !== 'code') {
286
+ throw new TypeError(`${kind} ${JSON.stringify(id)} has an invalid stored representation`)
287
+ }
288
+ return stored.payload.value.raw
289
+ }
290
+
199
291
  const currentPayload = (store: Store, kind: Kind, id: string): undefined | { payload: string, rowId: string } => {
200
292
  // The payload is excluded from an attribute claim key, so an empty
201
293
  // placeholder locates the one append-only series for this artifact ID.
202
294
  const { claim } = claimOf(store, kind, id, '')
203
295
  const row = store.currentBelief(Key.of(claim))
204
296
  if (row === undefined || row.conf === 0) return undefined
205
- const stored = store.toClaim(row)
206
- if (stored.payload.kind !== 'attribute' || stored.payload.attribute !== 'artifact' || stored.payload.value.kind !== 'code') {
207
- throw new TypeError(`${kind} ${JSON.stringify(id)} has an invalid stored representation`)
208
- }
209
- return { payload: stored.payload.value.raw, rowId: row.id }
297
+ return { payload: payloadOf(store, kind, id, row), rowId: row.id }
210
298
  }
211
299
 
212
300
  const requireRecord = <T extends Artifact>(store: Store, kind: Kind, id: string): T => {
@@ -216,44 +304,158 @@ const requireRecord = <T extends Artifact>(store: Store, kind: Kind, id: string)
216
304
  if (kindOf(artifact) !== kind || artifact.id !== id) {
217
305
  throw new TypeError(`${kind} ${JSON.stringify(id)} has mismatched stored identity`)
218
306
  }
307
+ validateArtifact(artifact)
219
308
  return artifact as T
220
309
  }
221
310
 
222
- const append = (store: Store, artifact: Artifact, validate: () => void): RecordOutcome =>
223
- store.transaction(() => {
224
- validate()
225
- const kind = kindOf(artifact)
226
- const payload = encode(artifact)
311
+ const append = <T extends Artifact>(store: Store, kind: Kind, supplied: T, validate: (artifact: T) => void): RecordOutcome => {
312
+ if (typeof supplied !== 'object' || supplied === null) {
313
+ throw new TypeError(`unsupported ${kind} artifact schema — expected an object`)
314
+ }
315
+ // Validation, predecessor checks and storage must observe the same JSON.
316
+ const json = canonicalJson(supplied)
317
+ const artifact = JSON.parse(json) as T
318
+ if (kindOf(artifact) !== kind) {
319
+ throw new TypeError(`unsupported ${kind} artifact schema ${JSON.stringify(artifact.schema)}`)
320
+ }
321
+ return store.transaction(() => {
322
+ validateArtifact(artifact)
323
+ validate(artifact)
324
+ const payload = Buffer.from(json, 'utf8').toString('base64url')
325
+ const { parsed, claim } = claimOf(store, kind, artifact.id, payload)
326
+ // Retraction changes current belief, not ownership of an immutable ID.
327
+ // Inspect every event, even when the latest event matches or is retracted.
328
+ for (const row of store.history(Key.of(claim))) {
329
+ if (payloadOf(store, kind, artifact.id, row) !== payload) throw new RecordConflictError(kind, artifact.id)
330
+ }
227
331
  const existing = currentPayload(store, kind, artifact.id)
228
332
  if (existing !== undefined) {
229
333
  if (existing.payload !== payload) throw new RecordConflictError(kind, artifact.id)
230
334
  return { status: 'existing', rowId: existing.rowId, artifact }
231
335
  }
232
- const { parsed } = claimOf(store, kind, artifact.id, payload)
233
336
  const inserted = store.insertResult(parsed)
234
337
  return { status: 'recorded', rowId: inserted.ids[0]!, artifact }
235
338
  })
339
+ }
236
340
 
237
- export const result = (store: Store, artifact: ResultArtifact): RecordOutcome =>
238
- append(store, artifact, () => {
239
- if (artifact.schema === resultSchema && artifact.report.schema !== 'cave.solver/explanation@1') {
240
- throw new TypeError(`unsupported solver explanation schema ${JSON.stringify(artifact.report.schema)}`)
341
+ const validateResult = (artifact: ResultArtifact): void => {
342
+ if (artifact.schema === resultSchema && artifact.report.schema !== 'cave.solver/explanation@1') {
343
+ throw new TypeError(`unsupported solver explanation schema ${JSON.stringify(artifact.report.schema)}`)
344
+ }
345
+ if (artifact.schema === resultSchema) {
346
+ requiredText(artifact.report.run?.modelDigest, 'solver model digest')
347
+ requiredText(artifact.report.run?.backend?.name, 'solver backend name')
348
+ requiredText(artifact.report.run?.backend?.version, 'solver backend version')
349
+ const outcome = artifact.report.outcome
350
+ requiredStatus(outcome?.status, ['satisfied', 'optimal', 'unsatisfied', 'unknown'], 'solver explanation status')
351
+ if (outcome.status === 'optimal' && outcome.optimalityProved !== true) {
352
+ throw new TypeError('optimal solver explanation requires optimalityProved: true')
241
353
  }
242
- if (artifact.schema === evaluationSchema) {
243
- if (artifact.inputs.schema !== 'cave.scenario/inputs@1') {
244
- throw new TypeError(`unsupported scenario input schema ${JSON.stringify(artifact.inputs.schema)}`)
245
- }
246
- if (artifact.evaluator.name === '' || artifact.evaluator.version === '') {
247
- throw new TypeError('scenario evaluator name and version must not be empty')
248
- }
354
+ if (outcome.status === 'unsatisfied' && outcome.infeasibilityProved !== true) {
355
+ throw new TypeError('unsatisfied solver explanation requires infeasibilityProved: true')
249
356
  }
250
- })
357
+ if (outcome.status === 'unsatisfied' && outcome.coreMinimal !== false) {
358
+ throw new TypeError('unsatisfied solver explanation requires coreMinimal: false; core minimality is not promised')
359
+ }
360
+ const collection = (value: unknown, field: string): void => {
361
+ if (!Array.isArray(value)) throw new TypeError(`solver explanation ${field} must be an array`)
362
+ }
363
+ collection(artifact.report.run.inputs, 'inputs')
364
+ collection(artifact.report.run.diagnostics, 'diagnostics')
365
+ if (outcome.status === 'satisfied' || outcome.status === 'optimal') {
366
+ collection(outcome.assignments, 'assignments')
367
+ collection(outcome.hardConstraints, 'hardConstraints')
368
+ collection(outcome.softConstraints, 'softConstraints')
369
+ }
370
+ if (outcome.status === 'unknown') Validate.unknownReason(outcome.reason)
371
+ if (outcome.status === 'optimal') collection(outcome.objectives, 'objectives')
372
+ if (outcome.status === 'unsatisfied' && outcome.core !== undefined) collection(outcome.core, 'core')
373
+ Validate.resultMetadata(artifact.report.run)
374
+ validateExplanationEntries(artifact.report)
375
+ Validate.explanationContext(artifact.report.run)
376
+ const limits = artifact.report.run.limits
377
+ if (limits === null || typeof limits !== 'object' || Array.isArray(limits)) {
378
+ throw new TypeError('solver explanation limits must be an object')
379
+ }
380
+ // Validate declared limits, but do not rewrite historical metadata with
381
+ // defaults introduced after the recorded run.
382
+ Validate.mergeLimits(limits)
383
+ }
384
+ if (artifact.schema === evaluationSchema) {
385
+ if (artifact.inputs.schema !== 'cave.scenario/inputs@1') {
386
+ throw new TypeError(`unsupported scenario input schema ${JSON.stringify(artifact.inputs.schema)}`)
387
+ }
388
+ if (typeof artifact.evaluator?.name !== 'string' || artifact.evaluator.name === '' ||
389
+ typeof artifact.evaluator?.version !== 'string' || artifact.evaluator.version === '') {
390
+ throw new TypeError('scenario evaluator name and version must be non-empty strings')
391
+ }
392
+ if (artifact.output === undefined) throw new TypeError('scenario evaluation output is required; use null for no value')
393
+ const { digest, ...contents } = artifact.inputs
394
+ if (digest !== inputDigest(contents)) throw new TypeError('scenario record input digest does not match its contents')
395
+ }
396
+ }
397
+
398
+ export const result = (store: Store, artifact: ResultArtifact): RecordOutcome =>
399
+ append(store, 'result', artifact, () => undefined)
400
+
401
+ const requiredText = (value: unknown, field: string): void => {
402
+ if (typeof value !== 'string' || value === '') throw new TypeError(`${field} must be a non-empty string`)
403
+ }
404
+
405
+ const optionalText = (value: unknown, field: string): void => {
406
+ if (value !== undefined && typeof value !== 'string') throw new TypeError(`${field} must be a string`)
407
+ }
408
+
409
+ const requiredValue = (value: unknown, field: string): void => {
410
+ if (value === undefined) throw new TypeError(`${field} is required; use null for no value`)
411
+ }
412
+
413
+ const requiredStatus = (value: unknown, choices: readonly string[], field: string): void => {
414
+ if (typeof value !== 'string' || !choices.includes(value)) {
415
+ throw new TypeError(`${field} must be one of ${choices.join(', ')}`)
416
+ }
417
+ }
418
+
419
+ const validateArtifact = (artifact: Artifact): void => {
420
+ switch (artifact.schema) {
421
+ case resultSchema: case evaluationSchema:
422
+ validateResult(artifact)
423
+ break
424
+ case recommendationSchema:
425
+ requiredValue(artifact.value, 'recommendation value')
426
+ requiredText(artifact.resultId, 'result id')
427
+ optionalText(artifact.rationale, 'recommendation rationale')
428
+ optionalText(artifact.authoredBy, 'recommendation authoredBy')
429
+ break
430
+ case decisionSchema:
431
+ requiredValue(artifact.selected, 'decision selected value')
432
+ optionalText(artifact.rationale, 'decision rationale')
433
+ requiredText(artifact.decidedBy, 'decision decidedBy')
434
+ requiredText(artifact.resultId, 'result id')
435
+ if (artifact.recommendationId !== undefined) requiredText(artifact.recommendationId, 'recommendation id')
436
+ break
437
+ case actionSchema:
438
+ requiredValue(artifact.parameters, 'action parameters')
439
+ requiredText(artifact.name, 'action name')
440
+ optionalText(artifact.message, 'action message')
441
+ requiredStatus(artifact.status, ['validated', 'executed', 'failed'], 'action status')
442
+ requiredText(artifact.decisionId, 'decision id')
443
+ break
444
+ case externalEffectSchema:
445
+ requiredText(artifact.kind, 'external effect kind')
446
+ requiredStatus(artifact.status, ['succeeded', 'failed', 'unknown'], 'external effect status')
447
+ requiredText(artifact.actionId, 'action id')
448
+ break
449
+ }
450
+ }
251
451
 
252
452
  export const recommendation = (store: Store, artifact: Recommendation): RecordOutcome =>
253
- append(store, artifact, () => { requireRecord<ResultArtifact>(store, 'result', artifact.resultId) })
453
+ append(store, 'recommendation', artifact, artifact => {
454
+ requireRecord<ResultArtifact>(store, 'result', artifact.resultId)
455
+ })
254
456
 
255
457
  export const decision = (store: Store, artifact: Decision): RecordOutcome =>
256
- append(store, artifact, () => {
458
+ append(store, 'decision', artifact, artifact => {
257
459
  requireRecord<ResultArtifact>(store, 'result', artifact.resultId)
258
460
  if (artifact.recommendationId !== undefined) {
259
461
  const proposal = requireRecord<Recommendation>(store, 'recommendation', artifact.recommendationId)
@@ -264,10 +466,14 @@ export const decision = (store: Store, artifact: Decision): RecordOutcome =>
264
466
  })
265
467
 
266
468
  export const action = (store: Store, artifact: Action): RecordOutcome =>
267
- append(store, artifact, () => { requireRecord<Decision>(store, 'decision', artifact.decisionId) })
469
+ append(store, 'action', artifact, artifact => {
470
+ requireRecord<Decision>(store, 'decision', artifact.decisionId)
471
+ })
268
472
 
269
473
  export const externalEffect = (store: Store, artifact: ExternalEffect): RecordOutcome =>
270
- append(store, artifact, () => { requireRecord<Action>(store, 'action', artifact.actionId) })
474
+ append(store, 'external-effect', artifact, artifact => {
475
+ requireRecord<Action>(store, 'action', artifact.actionId)
476
+ })
271
477
 
272
478
  export const read = <T extends Artifact>(store: Store, kind: Kind, id: string): T =>
273
479
  requireRecord<T>(store, kind, id)
@@ -277,22 +483,35 @@ export const read = <T extends Artifact>(store: Store, kind: Kind, id: string):
277
483
  * a different model or backend version is reported, never silently upgraded.
278
484
  */
279
485
  export const replay = (store: Store, id: string, expected: ReplayExpectation): Replay => {
486
+ if (expected === null || typeof expected !== 'object' || Array.isArray(expected)) {
487
+ throw new TypeError('replay expectations must be an object')
488
+ }
489
+ const { modelDigest, backend } = expected
490
+ requiredText(modelDigest, 'expected model digest')
491
+ if (backend !== undefined && (backend === null || typeof backend !== 'object' || Array.isArray(backend))) {
492
+ throw new TypeError('expected backend must be an object')
493
+ }
494
+ const expectedBackend = backend === undefined ? undefined : { name: backend.name, version: backend.version }
495
+ if (expectedBackend !== undefined) {
496
+ requiredText(expectedBackend.name, 'expected backend name')
497
+ requiredText(expectedBackend.version, 'expected backend version')
498
+ }
280
499
  const found = requireRecord<ResultArtifact>(store, 'result', id)
281
500
  if (found.schema !== resultSchema) {
282
501
  throw new TypeError(`result ${JSON.stringify(id)} is an external evaluation, not a solver result`)
283
502
  }
284
503
  const artifact = found
285
504
  const reasons: string[] = []
286
- if (artifact.report.run.modelDigest !== expected.modelDigest) {
287
- reasons.push(`model digest ${artifact.report.run.modelDigest} does not match ${expected.modelDigest}`)
505
+ if (artifact.report.run.modelDigest !== modelDigest) {
506
+ reasons.push(`model digest ${artifact.report.run.modelDigest} does not match ${modelDigest}`)
288
507
  }
289
- if (expected.backend !== undefined) {
508
+ if (expectedBackend !== undefined) {
290
509
  const actual = artifact.report.run.backend
291
- if (actual.name !== expected.backend.name) {
292
- reasons.push(`solver backend ${actual.name} does not match ${expected.backend.name}`)
510
+ if (actual.name !== expectedBackend.name) {
511
+ reasons.push(`solver backend ${actual.name} does not match ${expectedBackend.name}`)
293
512
  }
294
- if (actual.version !== expected.backend.version) {
295
- reasons.push(`solver version ${actual.version} does not match ${expected.backend.version}`)
513
+ if (actual.version !== expectedBackend.version) {
514
+ reasons.push(`solver version ${actual.version} does not match ${expectedBackend.version}`)
296
515
  }
297
516
  }
298
517
  return { artifact, compatible: reasons.length === 0, reasons }