@cavelang/scenario 0.29.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/Authors.md +5 -0
- package/License.md +6 -0
- package/README.md +146 -0
- package/dist/src/bind.d.ts +6 -0
- package/dist/src/bind.d.ts.map +1 -0
- package/dist/src/bind.js +347 -0
- package/dist/src/bind.js.map +1 -0
- package/dist/src/error.d.ts +7 -0
- package/dist/src/error.d.ts.map +1 -0
- package/dist/src/error.js +11 -0
- package/dist/src/error.js.map +1 -0
- package/dist/src/exact.d.ts +13 -0
- package/dist/src/exact.d.ts.map +1 -0
- package/dist/src/exact.js +65 -0
- package/dist/src/exact.js.map +1 -0
- package/dist/src/explain.d.ts +5 -0
- package/dist/src/explain.d.ts.map +1 -0
- package/dist/src/explain.js +59 -0
- package/dist/src/explain.js.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/model.d.ts +141 -0
- package/dist/src/model.d.ts.map +1 -0
- package/dist/src/model.js +2 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/record.d.ts +100 -0
- package/dist/src/record.d.ts.map +1 -0
- package/dist/src/record.js +189 -0
- package/dist/src/record.js.map +1 -0
- package/package.json +61 -0
- package/src/bind.ts +401 -0
- package/src/error.ts +22 -0
- package/src/exact.ts +93 -0
- package/src/explain.ts +68 -0
- package/src/index.ts +7 -0
- package/src/model.ts +120 -0
- package/src/record.ts +299 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Typed, replayable CAVE snapshot bindings for decision and solver inputs. */
|
|
2
|
+
|
|
3
|
+
export { bind, run } from './bind.ts'
|
|
4
|
+
export { explanationContext } from './explain.ts'
|
|
5
|
+
export { ScenarioInputError } from './error.ts'
|
|
6
|
+
export * as Model from './model.ts'
|
|
7
|
+
export * as Record from './record.ts'
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Model } from '@cavelang/solver'
|
|
2
|
+
|
|
3
|
+
export const schema = 'cave.scenario/inputs@1' as const
|
|
4
|
+
|
|
5
|
+
export type Snapshot = {
|
|
6
|
+
/** Transaction-time boundary. Omit to freeze the current store head. */
|
|
7
|
+
readonly asOf?: string
|
|
8
|
+
/** Valid-time instant passed to CAVE-Q. */
|
|
9
|
+
readonly at?: string
|
|
10
|
+
readonly aliases: 'exact' | 'closure'
|
|
11
|
+
readonly resolution: 'coexisting' | 'winner'
|
|
12
|
+
readonly minimumConfidence: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Conversion = {
|
|
16
|
+
readonly from: string
|
|
17
|
+
readonly to: string
|
|
18
|
+
/** Exact `to` units per one `from` unit. */
|
|
19
|
+
readonly factor: Model.Rational
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Expected =
|
|
23
|
+
| { readonly kind: 'boolean', readonly trueValue?: string, readonly falseValue?: string }
|
|
24
|
+
| { readonly kind: 'integer', readonly unit?: string, readonly conversions?: readonly Conversion[] }
|
|
25
|
+
| { readonly kind: 'number', readonly unit?: string, readonly conversions?: readonly Conversion[] }
|
|
26
|
+
| { readonly kind: 'enum', readonly values: readonly string[] }
|
|
27
|
+
| { readonly kind: 'text' }
|
|
28
|
+
|
|
29
|
+
export type MissingPolicy = 'reject' | 'omit' | 'empty'
|
|
30
|
+
|
|
31
|
+
export type Policies = {
|
|
32
|
+
readonly missing: MissingPolicy
|
|
33
|
+
readonly contested: 'reject' | 'allow'
|
|
34
|
+
readonly retracted: 'reject' | 'exclude' | 'include'
|
|
35
|
+
readonly unresolved: 'reject' | 'allow'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type CommonBinding = {
|
|
39
|
+
readonly id: string
|
|
40
|
+
readonly query: string
|
|
41
|
+
/** CAVE-Q variable to bind. Omit only for Boolean existence inputs. */
|
|
42
|
+
readonly select?: string
|
|
43
|
+
readonly expected: Expected
|
|
44
|
+
readonly scenarioOverride: boolean
|
|
45
|
+
readonly policies: Policies
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type Binding = CommonBinding & (
|
|
49
|
+
| { readonly cardinality: 'one' }
|
|
50
|
+
| { readonly cardinality: 'optional' }
|
|
51
|
+
| { readonly cardinality: 'many', readonly reduce: 'all' | 'min' | 'max' | 'sum' }
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
export type Definition = {
|
|
55
|
+
readonly id: string
|
|
56
|
+
/** Digest of the portable model or evaluator receiving these inputs. */
|
|
57
|
+
readonly modelDigest: string
|
|
58
|
+
readonly snapshot: Snapshot
|
|
59
|
+
readonly overlay?: string
|
|
60
|
+
readonly bindings: readonly Binding[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ExactNumber = {
|
|
64
|
+
readonly numerator: string
|
|
65
|
+
readonly denominator: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type Uncertainty = {
|
|
69
|
+
readonly authored: string
|
|
70
|
+
readonly exact: ExactNumber
|
|
71
|
+
readonly unit?: string
|
|
72
|
+
readonly sigmaLevel: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type Value =
|
|
76
|
+
| { readonly kind: 'boolean', readonly value: boolean }
|
|
77
|
+
| { readonly kind: 'integer', readonly value: string, readonly unit?: string, readonly authored?: string, readonly approximate: boolean, readonly uncertainty?: Uncertainty }
|
|
78
|
+
| { readonly kind: 'number', readonly value: ExactNumber, readonly unit?: string, readonly authored?: string, readonly approximate: boolean, readonly uncertainty?: Uncertainty }
|
|
79
|
+
| { readonly kind: 'enum', readonly value: string }
|
|
80
|
+
| { readonly kind: 'text', readonly value: string }
|
|
81
|
+
|
|
82
|
+
export type Evidence =
|
|
83
|
+
| { readonly origin: 'belief', readonly rowIds: readonly string[] }
|
|
84
|
+
| { readonly origin: 'scenario', readonly claimIds: readonly string[] }
|
|
85
|
+
|
|
86
|
+
export type Candidate = {
|
|
87
|
+
readonly value: Value
|
|
88
|
+
readonly confidence: number
|
|
89
|
+
readonly evidence: readonly Evidence[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type BindingResult = {
|
|
93
|
+
readonly id: string
|
|
94
|
+
readonly candidates: readonly Candidate[]
|
|
95
|
+
readonly value?: Value | readonly Value[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type FrozenSnapshot = Snapshot & {
|
|
99
|
+
/** Exact newest base row visible at `asOf`, or null for an empty snapshot. */
|
|
100
|
+
readonly transactionTime: string | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type InputRecord = {
|
|
104
|
+
readonly schema: typeof schema
|
|
105
|
+
readonly scenarioId: string
|
|
106
|
+
readonly modelDigest: string
|
|
107
|
+
readonly digest: string
|
|
108
|
+
readonly snapshot: FrozenSnapshot
|
|
109
|
+
readonly overlay: {
|
|
110
|
+
readonly digest: string
|
|
111
|
+
readonly source: string
|
|
112
|
+
readonly claimIds: readonly string[]
|
|
113
|
+
}
|
|
114
|
+
readonly values: Readonly<Record<string, Value | readonly Value[]>>
|
|
115
|
+
readonly bindings: readonly BindingResult[]
|
|
116
|
+
readonly supportingRowIds: readonly string[]
|
|
117
|
+
readonly scenarioClaimIds: readonly string[]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type t = InputRecord
|
package/src/record.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import type { Explain } from '@cavelang/solver'
|
|
3
|
+
import { Key } from '@cavelang/core'
|
|
4
|
+
import { canonicalizeText } from '@cavelang/canonical'
|
|
5
|
+
import type { Store } from '@cavelang/store'
|
|
6
|
+
import type { InputRecord } from './model.ts'
|
|
7
|
+
|
|
8
|
+
export const resultSchema = 'cave.scenario/result@1' as const
|
|
9
|
+
export const evaluationSchema = 'cave.scenario/evaluation@1' as const
|
|
10
|
+
export const recommendationSchema = 'cave.scenario/recommendation@1' as const
|
|
11
|
+
export const decisionSchema = 'cave.scenario/decision@1' as const
|
|
12
|
+
export const actionSchema = 'cave.scenario/action@1' as const
|
|
13
|
+
export const externalEffectSchema = 'cave.scenario/external-effect@1' as const
|
|
14
|
+
|
|
15
|
+
export type Result = {
|
|
16
|
+
readonly schema: typeof resultSchema
|
|
17
|
+
/** Stable caller-supplied identity. Reusing it with different content is an error. */
|
|
18
|
+
readonly id: string
|
|
19
|
+
readonly report: Explain.Report
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Ordinary external-evaluator result over one frozen scenario input record. */
|
|
23
|
+
export type Evaluation = {
|
|
24
|
+
readonly schema: typeof evaluationSchema
|
|
25
|
+
readonly id: string
|
|
26
|
+
readonly inputs: InputRecord
|
|
27
|
+
readonly evaluator: {
|
|
28
|
+
readonly name: string
|
|
29
|
+
readonly version: string
|
|
30
|
+
}
|
|
31
|
+
readonly output: Explain.Json
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ResultArtifact = Result | Evaluation
|
|
35
|
+
|
|
36
|
+
export type Recommendation = {
|
|
37
|
+
readonly schema: typeof recommendationSchema
|
|
38
|
+
readonly id: string
|
|
39
|
+
readonly resultId: string
|
|
40
|
+
readonly value: Explain.Json
|
|
41
|
+
readonly rationale?: string
|
|
42
|
+
readonly authoredBy?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Decision = {
|
|
46
|
+
readonly schema: typeof decisionSchema
|
|
47
|
+
readonly id: string
|
|
48
|
+
readonly resultId: string
|
|
49
|
+
readonly recommendationId?: string
|
|
50
|
+
readonly selected: Explain.Json
|
|
51
|
+
readonly decidedBy: string
|
|
52
|
+
readonly rationale?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Audit record only. Creating it never invokes an action or external hook. */
|
|
56
|
+
export type Action = {
|
|
57
|
+
readonly schema: typeof actionSchema
|
|
58
|
+
readonly id: string
|
|
59
|
+
readonly decisionId: string
|
|
60
|
+
readonly name: string
|
|
61
|
+
readonly parameters: Explain.Json
|
|
62
|
+
readonly status: 'validated' | 'executed' | 'failed'
|
|
63
|
+
readonly message?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Audit record only. External effects remain owned by governed action execution. */
|
|
67
|
+
export type ExternalEffect = {
|
|
68
|
+
readonly schema: typeof externalEffectSchema
|
|
69
|
+
readonly id: string
|
|
70
|
+
readonly actionId: string
|
|
71
|
+
readonly kind: string
|
|
72
|
+
readonly status: 'succeeded' | 'failed' | 'unknown'
|
|
73
|
+
readonly details?: Explain.Json
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type Artifact = ResultArtifact | Recommendation | Decision | Action | ExternalEffect
|
|
77
|
+
export type Kind = 'result' | 'recommendation' | 'decision' | 'action' | 'external-effect'
|
|
78
|
+
|
|
79
|
+
export type RecordOutcome = {
|
|
80
|
+
readonly status: 'recorded' | 'existing'
|
|
81
|
+
readonly rowId: string
|
|
82
|
+
readonly artifact: Artifact
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type ReplayExpectation = {
|
|
86
|
+
readonly modelDigest: string
|
|
87
|
+
readonly backend?: {
|
|
88
|
+
readonly name: string
|
|
89
|
+
readonly version: string
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Replay = {
|
|
94
|
+
readonly artifact: Result
|
|
95
|
+
readonly compatible: boolean
|
|
96
|
+
readonly reasons: readonly string[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class RecordConflictError extends Error {
|
|
100
|
+
constructor(kind: Kind, id: string) {
|
|
101
|
+
super(`${kind} ${JSON.stringify(id)} is already recorded with different content`)
|
|
102
|
+
this.name = 'RecordConflictError'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class MissingRecordError extends Error {
|
|
107
|
+
constructor(kind: Kind, id: string) {
|
|
108
|
+
super(`${kind} ${JSON.stringify(id)} is not recorded`)
|
|
109
|
+
this.name = 'MissingRecordError'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const kindOf = (artifact: Artifact): Kind => {
|
|
114
|
+
switch (artifact.schema) {
|
|
115
|
+
case resultSchema: return 'result'
|
|
116
|
+
case evaluationSchema: return 'result'
|
|
117
|
+
case recommendationSchema: return 'recommendation'
|
|
118
|
+
case decisionSchema: return 'decision'
|
|
119
|
+
case actionSchema: return 'action'
|
|
120
|
+
case externalEffectSchema: return 'external-effect'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const prefix: Readonly<Record<Kind, string>> = {
|
|
125
|
+
result: 'scenario-result',
|
|
126
|
+
recommendation: 'scenario-recommendation',
|
|
127
|
+
decision: 'scenario-decision',
|
|
128
|
+
action: 'scenario-action',
|
|
129
|
+
'external-effect': 'scenario-effect'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const source: Readonly<Record<Kind, string>> = {
|
|
133
|
+
result: 'scenario/result',
|
|
134
|
+
recommendation: 'scenario/recommendation',
|
|
135
|
+
decision: 'scenario/decision',
|
|
136
|
+
action: 'scenario/action',
|
|
137
|
+
'external-effect': 'scenario/external-effect'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const entity = (kind: Kind, id: string): string => {
|
|
141
|
+
if (id === '') throw new TypeError(`${kind} id must not be empty`)
|
|
142
|
+
return `${prefix[kind]}/${id}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
|
|
146
|
+
|
|
147
|
+
const canonicalJson = (value: unknown): string => {
|
|
148
|
+
const normalize = (item: unknown, path: string): unknown => {
|
|
149
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean') return item
|
|
150
|
+
if (typeof item === 'number') {
|
|
151
|
+
if (!Number.isFinite(item)) throw new TypeError(`${path} must be finite`)
|
|
152
|
+
return item
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(item)) return item.map((entry, index) => normalize(entry, `${path}[${index}]`))
|
|
155
|
+
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}`)]))
|
|
160
|
+
}
|
|
161
|
+
throw new TypeError(`${path} is not JSON`)
|
|
162
|
+
}
|
|
163
|
+
return JSON.stringify(normalize(value, 'artifact'))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const encode = (artifact: Artifact): string =>
|
|
167
|
+
Buffer.from(canonicalJson(artifact), 'utf8').toString('base64url')
|
|
168
|
+
|
|
169
|
+
const decode = (payload: string): Artifact => {
|
|
170
|
+
let parsed: unknown
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
|
|
173
|
+
} catch {
|
|
174
|
+
throw new TypeError('recorded scenario artifact is not valid base64url JSON')
|
|
175
|
+
}
|
|
176
|
+
if (typeof parsed !== 'object' || parsed === null || typeof (parsed as { schema?: unknown }).schema !== 'string') {
|
|
177
|
+
throw new TypeError('recorded scenario artifact has no schema')
|
|
178
|
+
}
|
|
179
|
+
const schema = (parsed as { schema: string }).schema
|
|
180
|
+
if (![resultSchema, evaluationSchema, recommendationSchema, decisionSchema, actionSchema, externalEffectSchema].includes(schema as never)) {
|
|
181
|
+
throw new TypeError(`unsupported recorded scenario artifact schema ${JSON.stringify(schema)}`)
|
|
182
|
+
}
|
|
183
|
+
return parsed as Artifact
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const line = (kind: Kind, id: string, payload: string): string =>
|
|
187
|
+
`${entity(kind, id)} HAS artifact: \`${payload}\` @src:${source[kind]}`
|
|
188
|
+
|
|
189
|
+
const claimOf = (store: Store, kind: Kind, id: string, payload: string) => {
|
|
190
|
+
const parsed = canonicalizeText(line(kind, id, payload), store.registry())
|
|
191
|
+
const claim = parsed.claims[0]?.claim
|
|
192
|
+
if (parsed.problems.length > 0 || claim === undefined) {
|
|
193
|
+
const detail = parsed.problems.map(problem => `line ${problem.line}: ${problem.message}`).join('; ')
|
|
194
|
+
throw new TypeError(`invalid ${kind} identity ${JSON.stringify(id)}${detail === '' ? '' : `: ${detail}`}`)
|
|
195
|
+
}
|
|
196
|
+
return { parsed, claim }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const currentPayload = (store: Store, kind: Kind, id: string): undefined | { payload: string, rowId: string } => {
|
|
200
|
+
// The payload is excluded from an attribute claim key, so an empty
|
|
201
|
+
// placeholder locates the one append-only series for this artifact ID.
|
|
202
|
+
const { claim } = claimOf(store, kind, id, '')
|
|
203
|
+
const row = store.currentBelief(Key.of(claim))
|
|
204
|
+
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 }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const requireRecord = <T extends Artifact>(store: Store, kind: Kind, id: string): T => {
|
|
213
|
+
const found = currentPayload(store, kind, id)
|
|
214
|
+
if (found === undefined) throw new MissingRecordError(kind, id)
|
|
215
|
+
const artifact = decode(found.payload)
|
|
216
|
+
if (kindOf(artifact) !== kind || artifact.id !== id) {
|
|
217
|
+
throw new TypeError(`${kind} ${JSON.stringify(id)} has mismatched stored identity`)
|
|
218
|
+
}
|
|
219
|
+
return artifact as T
|
|
220
|
+
}
|
|
221
|
+
|
|
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)
|
|
227
|
+
const existing = currentPayload(store, kind, artifact.id)
|
|
228
|
+
if (existing !== undefined) {
|
|
229
|
+
if (existing.payload !== payload) throw new RecordConflictError(kind, artifact.id)
|
|
230
|
+
return { status: 'existing', rowId: existing.rowId, artifact }
|
|
231
|
+
}
|
|
232
|
+
const { parsed } = claimOf(store, kind, artifact.id, payload)
|
|
233
|
+
const inserted = store.insertResult(parsed)
|
|
234
|
+
return { status: 'recorded', rowId: inserted.ids[0]!, artifact }
|
|
235
|
+
})
|
|
236
|
+
|
|
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)}`)
|
|
241
|
+
}
|
|
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
|
+
}
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
export const recommendation = (store: Store, artifact: Recommendation): RecordOutcome =>
|
|
253
|
+
append(store, artifact, () => { requireRecord<ResultArtifact>(store, 'result', artifact.resultId) })
|
|
254
|
+
|
|
255
|
+
export const decision = (store: Store, artifact: Decision): RecordOutcome =>
|
|
256
|
+
append(store, artifact, () => {
|
|
257
|
+
requireRecord<ResultArtifact>(store, 'result', artifact.resultId)
|
|
258
|
+
if (artifact.recommendationId !== undefined) {
|
|
259
|
+
const proposal = requireRecord<Recommendation>(store, 'recommendation', artifact.recommendationId)
|
|
260
|
+
if (proposal.resultId !== artifact.resultId) {
|
|
261
|
+
throw new TypeError(`recommendation ${JSON.stringify(artifact.recommendationId)} belongs to another result`)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
export const action = (store: Store, artifact: Action): RecordOutcome =>
|
|
267
|
+
append(store, artifact, () => { requireRecord<Decision>(store, 'decision', artifact.decisionId) })
|
|
268
|
+
|
|
269
|
+
export const externalEffect = (store: Store, artifact: ExternalEffect): RecordOutcome =>
|
|
270
|
+
append(store, artifact, () => { requireRecord<Action>(store, 'action', artifact.actionId) })
|
|
271
|
+
|
|
272
|
+
export const read = <T extends Artifact>(store: Store, kind: Kind, id: string): T =>
|
|
273
|
+
requireRecord<T>(store, kind, id)
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Reads an immutable result without re-running it. Compatibility is explicit:
|
|
277
|
+
* a different model or backend version is reported, never silently upgraded.
|
|
278
|
+
*/
|
|
279
|
+
export const replay = (store: Store, id: string, expected: ReplayExpectation): Replay => {
|
|
280
|
+
const found = requireRecord<ResultArtifact>(store, 'result', id)
|
|
281
|
+
if (found.schema !== resultSchema) {
|
|
282
|
+
throw new TypeError(`result ${JSON.stringify(id)} is an external evaluation, not a solver result`)
|
|
283
|
+
}
|
|
284
|
+
const artifact = found
|
|
285
|
+
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}`)
|
|
288
|
+
}
|
|
289
|
+
if (expected.backend !== undefined) {
|
|
290
|
+
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}`)
|
|
293
|
+
}
|
|
294
|
+
if (actual.version !== expected.backend.version) {
|
|
295
|
+
reasons.push(`solver version ${actual.version} does not match ${expected.backend.version}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { artifact, compatible: reasons.length === 0, reasons }
|
|
299
|
+
}
|