@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/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
+ }