@cavelang/scenario 0.34.0 → 0.36.0
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/record.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
|
-
import type
|
|
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
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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,
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 (
|
|
243
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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 !==
|
|
287
|
-
reasons.push(`model digest ${artifact.report.run.modelDigest} does not match ${
|
|
505
|
+
if (artifact.report.run.modelDigest !== modelDigest) {
|
|
506
|
+
reasons.push(`model digest ${artifact.report.run.modelDigest} does not match ${modelDigest}`)
|
|
288
507
|
}
|
|
289
|
-
if (
|
|
508
|
+
if (expectedBackend !== undefined) {
|
|
290
509
|
const actual = artifact.report.run.backend
|
|
291
|
-
if (actual.name !==
|
|
292
|
-
reasons.push(`solver backend ${actual.name} does not match ${
|
|
510
|
+
if (actual.name !== expectedBackend.name) {
|
|
511
|
+
reasons.push(`solver backend ${actual.name} does not match ${expectedBackend.name}`)
|
|
293
512
|
}
|
|
294
|
-
if (actual.version !==
|
|
295
|
-
reasons.push(`solver version ${actual.version} does not match ${
|
|
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 }
|