dsh-logicprobe 0.5.6 → 0.6.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/engine.ts CHANGED
@@ -1,1778 +1,2568 @@
1
- import { createHash } from 'node:crypto'
2
-
3
- export const ENGINE_SCHEMA_VERSION = 1
4
-
5
- export type VarValue = number | boolean
6
-
7
- export type GuardOp = '==' | '!=' | '<' | '<=' | '>' | '>='
8
-
9
- export interface LeafGuard {
10
- variable: string
11
- op: GuardOp
12
- value: VarValue
13
- }
14
-
15
- export interface AllGuard {
16
- all: GuardNode[]
17
- }
18
-
19
- export interface AnyGuard {
20
- any: GuardNode[]
21
- }
22
-
23
- export interface NotGuard {
24
- not: GuardNode
25
- }
26
-
27
- export type GuardNode = LeafGuard | AllGuard | AnyGuard | NotGuard
28
-
29
- export interface StateSpec {
30
- id: string
31
- terminal?: boolean
32
- }
33
-
34
- export interface UpdateSpec {
35
- variable: string
36
- op: 'set' | 'inc' | 'dec'
37
- value?: number
38
- }
39
-
40
- export interface TransitionSpec {
41
- from: string
42
- event: string
43
- to: string
44
- /** Absent guard is the else/default branch for the same (from, event) group. */
45
- guard?: GuardNode
46
- updates?: UpdateSpec[]
47
- }
48
-
49
- export interface TransitionScenarioSpec {
50
- from: string
51
- event: string
52
- /** Natural language: what this (state, event) combination represents in the real scenario. */
53
- scenario: string
54
- }
55
-
56
- export interface ModelNarrative {
57
- /** Natural-language meaning of each state id. */
58
- states?: Record<string, string>
59
- /** Natural-language meaning of each event id. */
60
- events?: Record<string, string>
61
- /** Natural-language scenario for each distinct (from, event) combination. */
62
- scenarios?: TransitionScenarioSpec[]
63
- }
64
-
65
- export interface VariableSpec {
66
- name: string
67
- kind: 'integer' | 'boolean'
68
- init: number | boolean
69
- min?: number
70
- max?: number
71
- monotonic?: 'inc' | 'dec'
72
- }
73
-
74
- export type InvariantSpec =
75
- | { id: string; description: string; kind: 'never-states'; states: string[] }
76
- | { id: string; description: string; kind: 'var-in-range'; variable: string; min?: number; max?: number }
77
- | { id: string; description: string; kind: 'event-before-state'; event: string; state: string }
78
- | { id: string; description: string; kind: 'leads-to'; from: string; to: string }
79
- | { id: string; description: string; kind: 'sequence'; events: string[] }
80
- | { id: string; description: string; kind: 'atomicity'; events: string[]; commit: string; rollback?: string }
81
-
82
- export interface ResourcePairSpec {
83
- resource: string
84
- acquireEvent: string
85
- releaseEvent: string
86
- failEvent?: string
87
- }
88
-
89
- export interface BoundaryCheckSpec {
90
- variable: string
91
- values: number[]
92
- }
93
-
94
- export interface LogicModelV1 {
95
- schemaVersion: 1
96
- init: string
97
- states: StateSpec[]
98
- transitions: TransitionSpec[]
99
- variables?: VariableSpec[]
100
- invariants?: InvariantSpec[]
101
- concurrentPairs?: [string, string][]
102
- boundaryChecks?: BoundaryCheckSpec[]
103
- resourcePairs?: ResourcePairSpec[]
104
- idempotentEvents?: string[]
105
- /** Natural-language descriptions of states, events, and (state, event) scenarios. */
106
- narrative?: ModelNarrative
107
- }
108
-
109
- export interface VerificationOptions {
110
- maxStates?: number
111
- maxPermutationEvents?: number
112
- beforeModel?: unknown
113
- stateMapping?: Record<string, string>
114
- }
115
-
116
- export interface PathStep {
117
- from: string
118
- event: string
119
- to: string
120
- }
121
-
122
- export interface Finding {
123
- code: string
124
- severity: 'error' | 'warning'
125
- message: string
126
- path?: PathStep[]
127
- evidence?: Record<string, unknown>
128
- }
129
-
130
- export interface CheckResult {
131
- id: string
132
- name: string
133
- status: 'pass' | 'fail' | 'skip'
134
- detail: string
135
- findings: Finding[]
136
- }
137
-
138
- export interface VerificationReport {
139
- ok: boolean
140
- schemaVersion: 1
141
- modelHash: string
142
- /** Echo of the model's natural-language narrative, when present. */
143
- narrative?: ModelNarrative
144
- summary: {
145
- states: number
146
- transitions: number
147
- errors: number
148
- warnings: number
149
- checksRun: number
150
- truncated?: boolean
151
- }
152
- checks: CheckResult[]
153
- comparison?: ComparisonSummary
154
- }
155
-
156
- export interface ComparisonSummary {
157
- beforeModelHash: string
158
- afterModelHash: string
159
- stateMapping: Record<string, string>
160
- beforeStates: number
161
- beforeTransitions: number
162
- afterStates: number
163
- afterTransitions: number
164
- addedStates: string[]
165
- removedStates: string[]
166
- addedEvents: string[]
167
- removedEvents: string[]
168
- addedTransitions: TransitionSpec[]
169
- removedTransitions: TransitionSpec[]
170
- }
171
-
172
- export interface RuntimeState {
173
- state: string
174
- vars: Record<string, VarValue>
175
- }
176
-
177
- type NormalizedOptions = Required<Pick<VerificationOptions, 'maxStates' | 'maxPermutationEvents'>>
178
-
179
- const DEFAULT_MAX_STATES = 10_000
180
- const DEFAULT_MAX_PERMUTATION_EVENTS = 5
181
-
182
- function stableStringify(value: unknown): string {
183
- if (value === null || typeof value !== 'object') return JSON.stringify(value)
184
- if (Array.isArray(value)) return '[' + value.map((item) => stableStringify(item)).join(',') + ']'
185
- const record = value as Record<string, unknown>
186
- return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}'
187
- }
188
-
189
- export function modelHash(model: LogicModelV1): string {
190
- return createHash('sha256').update(stableStringify(model)).digest('hex')
191
- }
192
-
193
- export function validateModel(input: unknown): { ok: true; model: LogicModelV1 } | { ok: false; errors: string[] } {
194
- const errors: string[] = []
195
- const bad = (path: string, message: string): void => {
196
- errors.push(path + ': ' + message)
197
- }
198
- if (typeof input !== 'object' || input === null || Array.isArray(input)) {
199
- return { ok: false, errors: ['model: must be an object'] }
200
- }
201
- const root = input as Record<string, unknown>
202
- if (root.schemaVersion !== 1) bad('schemaVersion', 'must be 1')
203
- if (typeof root.init !== 'string' || root.init.length === 0) bad('init', 'must be a non-empty string')
204
- if (!Array.isArray(root.states) || root.states.length === 0) {
205
- bad('states', 'must be a non-empty array')
206
- } else {
207
- const seen = new Set<string>()
208
- root.states.forEach((entry, index) => {
209
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
210
- bad('states[' + index + ']', 'must be an object')
211
- return
212
- }
213
- const state = entry as Record<string, unknown>
214
- if (typeof state.id !== 'string' || state.id.length === 0) bad('states[' + index + '].id', 'must be a non-empty string')
215
- else if (seen.has(state.id)) bad('states[' + index + '].id', 'duplicate state id ' + state.id)
216
- else seen.add(state.id)
217
- if (state.terminal !== undefined && typeof state.terminal !== 'boolean') bad('states[' + index + '].terminal', 'must be a boolean')
218
- })
219
- if (typeof root.init === 'string' && root.init.length > 0 && !seen.has(root.init)) bad('init', 'must name a declared state')
220
- }
221
- if (!Array.isArray(root.transitions)) {
222
- bad('transitions', 'must be an array')
223
- } else {
224
- const stateIds = new Set<string>(Array.isArray(root.states) ? (root.states as StateSpec[]).map((state) => state.id) : [])
225
- root.transitions.forEach((entry, index) => {
226
- const path = 'transitions[' + index + ']'
227
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
228
- bad(path, 'must be an object')
229
- return
230
- }
231
- const transition = entry as Record<string, unknown>
232
- if (typeof transition.from !== 'string' || transition.from.length === 0) bad(path + '.from', 'must be a non-empty string')
233
- else if (!stateIds.has(transition.from)) bad(path + '.from', 'unknown state ' + transition.from)
234
- if (typeof transition.event !== 'string' || transition.event.length === 0) bad(path + '.event', 'must be a non-empty string')
235
- if (typeof transition.to !== 'string' || transition.to.length === 0) bad(path + '.to', 'must be a non-empty string')
236
- else if (!stateIds.has(transition.to)) bad(path + '.to', 'unknown state ' + transition.to)
237
- if (transition.guard !== undefined) validateGuard(transition.guard, path + '.guard', errors, bad)
238
- if (transition.updates !== undefined) {
239
- if (!Array.isArray(transition.updates)) bad(path + '.updates', 'must be an array')
240
- else transition.updates.forEach((update, updateIndex) => {
241
- const updatePath = path + '.updates[' + updateIndex + ']'
242
- if (typeof update !== 'object' || update === null || Array.isArray(update)) {
243
- bad(updatePath, 'must be an object')
244
- return
245
- }
246
- const record = update as Record<string, unknown>
247
- if (typeof record.variable !== 'string' || record.variable.length === 0) bad(updatePath + '.variable', 'must be a non-empty string')
248
- if (record.op !== 'set' && record.op !== 'inc' && record.op !== 'dec') bad(updatePath + '.op', "must be 'set', 'inc', or 'dec'")
249
- if (record.value !== undefined && typeof record.value !== 'number') bad(updatePath + '.value', 'must be a number')
250
- })
251
- }
252
- })
253
- }
254
- const variableNames = new Set<string>()
255
- if (root.variables !== undefined) {
256
- if (!Array.isArray(root.variables)) bad('variables', 'must be an array')
257
- else root.variables.forEach((entry, index) => {
258
- const path = 'variables[' + index + ']'
259
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
260
- bad(path, 'must be an object')
261
- return
262
- }
263
- const variable = entry as Record<string, unknown>
264
- if (typeof variable.name !== 'string' || variable.name.length === 0) bad(path + '.name', 'must be a non-empty string')
265
- else if (variableNames.has(variable.name)) bad(path + '.name', 'duplicate variable ' + variable.name)
266
- else variableNames.add(variable.name)
267
- if (variable.kind !== 'integer' && variable.kind !== 'boolean') bad(path + '.kind', "must be 'integer' or 'boolean'")
268
- const expected = variable.kind === 'boolean' ? 'boolean' : 'number'
269
- if (typeof variable.init !== expected) bad(path + '.init', 'must be a ' + expected)
270
- if (variable.min !== undefined && typeof variable.min !== 'number') bad(path + '.min', 'must be a number')
271
- if (variable.max !== undefined && typeof variable.max !== 'number') bad(path + '.max', 'must be a number')
272
- if (typeof variable.min === 'number' && typeof variable.max === 'number' && variable.min > variable.max) bad(path + '.max', 'must be >= min')
273
- if (variable.monotonic !== undefined && variable.monotonic !== 'inc' && variable.monotonic !== 'dec') bad(path + '.monotonic', "must be 'inc' or 'dec'")
274
- if (variable.kind === 'integer' && typeof variable.init === 'number') {
275
- if (typeof variable.min === 'number' && variable.init < variable.min) bad(path + '.init', 'must be >= min')
276
- if (typeof variable.max === 'number' && variable.init > variable.max) bad(path + '.init', 'must be <= max')
277
- }
278
- })
279
- }
280
- const validateVariableRef = (name: unknown, path: string): void => {
281
- if (typeof name !== 'string' || name.length === 0) bad(path, 'must be a non-empty string')
282
- else if (!variableNames.has(name)) bad(path, 'references unknown variable ' + name)
283
- }
284
- if (root.invariants !== undefined) {
285
- if (!Array.isArray(root.invariants)) bad('invariants', 'must be an array')
286
- else root.invariants.forEach((entry, index) => {
287
- const path = 'invariants[' + index + ']'
288
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
289
- bad(path, 'must be an object')
290
- return
291
- }
292
- const invariant = entry as Record<string, unknown>
293
- if (typeof invariant.id !== 'string' || invariant.id.length === 0) bad(path + '.id', 'must be a non-empty string')
294
- if (typeof invariant.description !== 'string') bad(path + '.description', 'must be a string')
295
- if (invariant.kind === 'never-states') {
296
- if (!Array.isArray(invariant.states) || invariant.states.length === 0) bad(path + '.states', 'must be a non-empty array')
297
- else invariant.states.forEach((state, stateIndex) => {
298
- if (typeof state !== 'string' || state.length === 0) bad(path + '.states[' + stateIndex + ']', 'must be a non-empty string')
299
- })
300
- } else if (invariant.kind === 'var-in-range') {
301
- validateVariableRef(invariant.variable, path + '.variable')
302
- if (invariant.min !== undefined && typeof invariant.min !== 'number') bad(path + '.min', 'must be a number')
303
- if (invariant.max !== undefined && typeof invariant.max !== 'number') bad(path + '.max', 'must be a number')
304
- } else if (invariant.kind === 'event-before-state') {
305
- if (typeof invariant.event !== 'string' || invariant.event.length === 0) bad(path + '.event', 'must be a non-empty string')
306
- if (typeof invariant.state !== 'string' || invariant.state.length === 0) bad(path + '.state', 'must be a non-empty string')
307
- } else if (invariant.kind === 'leads-to') {
308
- if (typeof invariant.from !== 'string' || invariant.from.length === 0) bad(path + '.from', 'must be a non-empty string')
309
- if (typeof invariant.to !== 'string' || invariant.to.length === 0) bad(path + '.to', 'must be a non-empty string')
310
- } else if (invariant.kind === 'sequence') {
311
- if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
312
- else invariant.events.forEach((event, eventIndex) => {
313
- if (typeof event !== 'string' || event.length === 0) bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string')
314
- })
315
- } else if (invariant.kind === 'atomicity') {
316
- if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
317
- else invariant.events.forEach((event, eventIndex) => {
318
- if (typeof event !== 'string' || event.length === 0) bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string')
319
- })
320
- if (typeof invariant.commit !== 'string' || invariant.commit.length === 0) bad(path + '.commit', 'must be a non-empty string')
321
- if (invariant.rollback !== undefined && typeof invariant.rollback !== 'string') bad(path + '.rollback', 'must be a string')
322
- } else {
323
- bad(path + '.kind', 'unknown invariant kind')
324
- }
325
- })
326
- }
327
- if (root.concurrentPairs !== undefined) {
328
- if (!Array.isArray(root.concurrentPairs)) bad('concurrentPairs', 'must be an array')
329
- else root.concurrentPairs.forEach((entry, index) => {
330
- const path = 'concurrentPairs[' + index + ']'
331
- if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') {
332
- bad(path, 'must be a [event, event] string pair')
333
- }
334
- })
335
- }
336
- if (root.boundaryChecks !== undefined) {
337
- if (!Array.isArray(root.boundaryChecks)) bad('boundaryChecks', 'must be an array')
338
- else root.boundaryChecks.forEach((entry, index) => {
339
- const path = 'boundaryChecks[' + index + ']'
340
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
341
- bad(path, 'must be an object')
342
- return
343
- }
344
- const check = entry as Record<string, unknown>
345
- validateVariableRef(check.variable, path + '.variable')
346
- if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number')) bad(path + '.values', 'must be an array of numbers')
347
- })
348
- }
349
- if (root.idempotentEvents !== undefined) {
350
- if (!Array.isArray(root.idempotentEvents)) bad('idempotentEvents', 'must be an array')
351
- else root.idempotentEvents.forEach((entry, index) => {
352
- if (typeof entry !== 'string' || entry.length === 0) bad('idempotentEvents[' + index + ']', 'must be a non-empty string')
353
- })
354
- }
355
- if (root.resourcePairs !== undefined) {
356
- if (!Array.isArray(root.resourcePairs)) bad('resourcePairs', 'must be an array')
357
- else root.resourcePairs.forEach((entry, index) => {
358
- const path = 'resourcePairs[' + index + ']'
359
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
360
- bad(path, 'must be an object')
361
- return
362
- }
363
- const pair = entry as Record<string, unknown>
364
- if (typeof pair.resource !== 'string' || pair.resource.length === 0) bad(path + '.resource', 'must be a non-empty string')
365
- if (typeof pair.acquireEvent !== 'string' || pair.acquireEvent.length === 0) bad(path + '.acquireEvent', 'must be a non-empty string')
366
- if (typeof pair.releaseEvent !== 'string' || pair.releaseEvent.length === 0) bad(path + '.releaseEvent', 'must be a non-empty string')
367
- if (pair.failEvent !== undefined && typeof pair.failEvent !== 'string') bad(path + '.failEvent', 'must be a string')
368
- })
369
- }
370
- if (root.narrative !== undefined) {
371
- const narrativePath = 'narrative'
372
- if (typeof root.narrative !== 'object' || root.narrative === null || Array.isArray(root.narrative)) {
373
- bad(narrativePath, 'must be an object')
374
- } else {
375
- const narrative = root.narrative as Record<string, unknown>
376
- const stateIds = new Set<string>(Array.isArray(root.states) ? (root.states as StateSpec[]).map((state) => state.id) : [])
377
- const eventIds = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.event) : [])
378
- const fromEventGroups = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.from + '|' + transition.event) : [])
379
- if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
380
- bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description')
381
- } else {
382
- for (const [id, description] of Object.entries(narrative.states as Record<string, unknown>)) {
383
- if (!stateIds.has(id)) bad(narrativePath + '.states', 'references unknown state ' + id)
384
- if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.states.' + id, 'must be a non-empty string')
385
- }
386
- for (const id of stateIds) {
387
- if (typeof (narrative.states as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.states', 'missing description for state ' + id)
388
- }
389
- }
390
- if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
391
- bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description')
392
- } else {
393
- for (const [id, description] of Object.entries(narrative.events as Record<string, unknown>)) {
394
- if (!eventIds.has(id)) bad(narrativePath + '.events', 'references unknown event ' + id)
395
- if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.events.' + id, 'must be a non-empty string')
396
- }
397
- for (const id of eventIds) {
398
- if (typeof (narrative.events as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.events', 'missing description for event ' + id)
399
- }
400
- }
401
- if (!Array.isArray(narrative.scenarios)) {
402
- bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }')
403
- } else {
404
- const seen = new Set<string>()
405
- narrative.scenarios.forEach((entry, index) => {
406
- const scenarioPath = narrativePath + '.scenarios[' + index + ']'
407
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
408
- bad(scenarioPath, 'must be an object')
409
- return
410
- }
411
- const scenario = entry as Record<string, unknown>
412
- if (typeof scenario.from !== 'string' || scenario.from.length === 0) bad(scenarioPath + '.from', 'must be a non-empty string')
413
- else if (!stateIds.has(scenario.from)) bad(scenarioPath + '.from', 'unknown state ' + scenario.from)
414
- if (typeof scenario.event !== 'string' || scenario.event.length === 0) bad(scenarioPath + '.event', 'must be a non-empty string')
415
- else if (!eventIds.has(scenario.event)) bad(scenarioPath + '.event', 'unknown event ' + scenario.event)
416
- if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0) bad(scenarioPath + '.scenario', 'must be a non-empty string')
417
- const key = String(scenario.from) + '|' + String(scenario.event)
418
- if (seen.has(key)) bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')')
419
- seen.add(key)
420
- })
421
- for (const key of fromEventGroups) {
422
- if (!seen.has(key)) {
423
- const sep = key.indexOf('|')
424
- bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')')
425
- }
426
- }
427
- }
428
- }
429
- }
430
- // Guard/update variable references were validated structurally; re-walk for references.
431
- if (Array.isArray(root.transitions)) {
432
- for (const entry of root.transitions as TransitionSpec[]) {
433
- walkGuardReferences(entry.guard, variableNames, errors, bad)
434
- for (const update of entry.updates ?? []) {
435
- if (!variableNames.has(update.variable)) bad('transitions.updates', 'references unknown variable ' + update.variable)
436
- if (update.op !== 'set' && update.value !== undefined && update.value === 0) {
437
- // zero delta is harmless; no finding
438
- }
439
- }
440
- }
441
- }
442
- if (errors.length > 0) return { ok: false, errors }
443
- return { ok: true, model: root as unknown as LogicModelV1 }
444
- }
445
-
446
- function validateGuard(input: unknown, path: string, errors: string[], bad: (path: string, message: string) => void): void {
447
- if (typeof input !== 'object' || input === null || Array.isArray(input)) {
448
- bad(path, 'must be an object')
449
- return
450
- }
451
- const guard = input as Record<string, unknown>
452
- if (guard.variable !== undefined) {
453
- if (typeof guard.variable !== 'string' || guard.variable.length === 0) bad(path + '.variable', 'must be a non-empty string')
454
- if (guard.op !== '==' && guard.op !== '!=' && guard.op !== '<' && guard.op !== '<=' && guard.op !== '>' && guard.op !== '>=') bad(path + '.op', 'must be a comparison operator')
455
- if (typeof guard.value !== 'number' && typeof guard.value !== 'boolean') bad(path + '.value', 'must be a number or boolean')
456
- return
457
- }
458
- if (guard.all !== undefined) {
459
- if (!Array.isArray(guard.all)) bad(path + '.all', 'must be an array')
460
- else guard.all.forEach((child, index) => validateGuard(child, path + '.all[' + index + ']', errors, bad))
461
- return
462
- }
463
- if (guard.any !== undefined) {
464
- if (!Array.isArray(guard.any)) bad(path + '.any', 'must be an array')
465
- else guard.any.forEach((child, index) => validateGuard(child, path + '.any[' + index + ']', errors, bad))
466
- return
467
- }
468
- if (guard.not !== undefined) {
469
- validateGuard(guard.not, path + '.not', errors, bad)
470
- return
471
- }
472
- bad(path, 'must be a leaf ({ variable, op, value }), { all }, { any }, or { not }')
473
- }
474
-
475
- function walkGuardReferences(guard: GuardNode | undefined, variableNames: Set<string>, errors: string[], bad: (path: string, message: string) => void): void {
476
- if (guard === undefined) return
477
- if ('variable' in guard) {
478
- if (!variableNames.has(guard.variable)) bad('guard', 'references unknown variable ' + guard.variable)
479
- return
480
- }
481
- if ('all' in guard) {
482
- for (const child of guard.all) walkGuardReferences(child, variableNames, errors, bad)
483
- return
484
- }
485
- if ('any' in guard) {
486
- for (const child of guard.any) walkGuardReferences(child, variableNames, errors, bad)
487
- return
488
- }
489
- if ('not' in guard) walkGuardReferences(guard.not, variableNames, errors, bad)
490
- }
491
-
492
- // ---------------------------------------------------------------------------
493
- // execution
494
- // ---------------------------------------------------------------------------
495
-
496
- function isLeafGuard(guard: GuardNode): guard is LeafGuard {
497
- return 'variable' in guard
498
- }
499
-
500
- export function guardVariables(guard: GuardNode | undefined): string[] {
501
- if (guard === undefined) return []
502
- if (isLeafGuard(guard)) return [guard.variable]
503
- if ('all' in guard) return guard.all.flatMap((child) => guardVariables(child))
504
- if ('any' in guard) return guard.any.flatMap((child) => guardVariables(child))
505
- if ('not' in guard) return guardVariables(guard.not)
506
- return []
507
- }
508
-
509
- function evalGuard(guard: GuardNode, vars: Record<string, VarValue>): boolean {
510
- if (isLeafGuard(guard)) {
511
- const actual = vars[guard.variable]
512
- switch (guard.op) {
513
- case '==': return actual === guard.value
514
- case '!=': return actual !== guard.value
515
- case '<': return (actual as number) < (guard.value as number)
516
- case '<=': return (actual as number) <= (guard.value as number)
517
- case '>': return (actual as number) > (guard.value as number)
518
- case '>=': return (actual as number) >= (guard.value as number)
519
- }
520
- }
521
- if ('all' in guard) return guard.all.every((child) => evalGuard(child, vars))
522
- if ('any' in guard) return guard.any.some((child) => evalGuard(child, vars))
523
- if ('not' in guard) return !evalGuard(guard.not, vars)
524
- return false
525
- }
526
-
527
- function initialState(model: LogicModelV1): RuntimeState {
528
- const vars: Record<string, VarValue> = {}
529
- for (const variable of model.variables ?? []) vars[variable.name] = variable.init
530
- return { state: model.init, vars }
531
- }
532
-
533
- function runtimeKey(runtime: RuntimeState): string {
534
- return runtime.state + '|' + stableStringify(runtime.vars)
535
- }
536
-
537
- function applyUpdates(model: LogicModelV1, transition: TransitionSpec, runtime: RuntimeState): RuntimeState {
538
- const vars = { ...runtime.vars }
539
- for (const update of transition.updates ?? []) {
540
- const current = vars[update.variable]
541
- if (update.op === 'set') vars[update.variable] = update.value ?? 0
542
- else if (update.op === 'inc') vars[update.variable] = ((typeof current === 'number' ? current : 0) + (update.value ?? 1))
543
- else vars[update.variable] = ((typeof current === 'number' ? current : 0) - (update.value ?? 1))
544
- }
545
- return { state: transition.to, vars }
546
- }
547
-
548
- function groupTransitions(model: LogicModelV1): Map<string, TransitionSpec[]> {
549
- const groups = new Map<string, TransitionSpec[]>()
550
- for (const transition of model.transitions) {
551
- const key = transition.from + '|' + transition.event
552
- const list = groups.get(key) ?? []
553
- list.push(transition)
554
- groups.set(key, list)
555
- }
556
- return groups
557
- }
558
-
559
- function applicableTransitions(group: TransitionSpec[], runtime: RuntimeState): TransitionSpec[] {
560
- const guarded: TransitionSpec[] = []
561
- const unguarded: TransitionSpec[] = []
562
- for (const transition of group) {
563
- if (transition.guard === undefined) unguarded.push(transition)
564
- else guarded.push(transition)
565
- }
566
- const matched = guarded.filter((transition) => evalGuard(transition.guard!, runtime.vars))
567
- if (matched.length > 0) return matched
568
- return unguarded
569
- }
570
-
571
- function stepRuntime(model: LogicModelV1, runtime: RuntimeState, event: string): RuntimeState[] {
572
- const group = groupTransitions(model).get(runtime.state + '|' + event)
573
- if (group === undefined || group.length === 0) return []
574
- const seen = new Set<string>()
575
- const outcomes: RuntimeState[] = []
576
- for (const transition of applicableTransitions(group, runtime)) {
577
- const next = applyUpdates(model, transition, runtime)
578
- const key = runtimeKey(next)
579
- if (!seen.has(key)) {
580
- seen.add(key)
581
- outcomes.push(next)
582
- }
583
- }
584
- return outcomes
585
- }
586
-
587
- interface Exploration {
588
- reachable: RuntimeState[]
589
- reachableKeys: Set<string>
590
- truncated: boolean
591
- initialState: RuntimeState
592
- }
593
-
594
- function explore(model: LogicModelV1, options: NormalizedOptions): Exploration {
595
- const init = initialState(model)
596
- const reachable: RuntimeState[] = []
597
- const reachableKeys = new Set<string>()
598
- const queue: RuntimeState[] = [init]
599
- let truncated = false
600
- while (queue.length > 0) {
601
- const runtime = queue.shift()!
602
- const key = runtimeKey(runtime)
603
- if (reachableKeys.has(key)) continue
604
- reachableKeys.add(key)
605
- reachable.push(runtime)
606
- if (reachable.length > options.maxStates) {
607
- truncated = true
608
- break
609
- }
610
- for (const event of allEvents(model)) {
611
- for (const next of stepRuntime(model, runtime, event)) {
612
- if (!reachableKeys.has(runtimeKey(next))) queue.push(next)
613
- }
614
- }
615
- }
616
- return { reachable, reachableKeys, truncated, initialState: init }
617
- }
618
-
619
- function allEvents(model: LogicModelV1): string[] {
620
- return [...new Set(model.transitions.map((transition) => transition.event))].sort()
621
- }
622
-
623
- function stateById(model: LogicModelV1, id: string): StateSpec | undefined {
624
- return model.states.find((state) => state.id === id)
625
- }
626
-
627
- function isTerminal(model: LogicModelV1, state: string): boolean {
628
- return stateById(model, state)?.terminal === true
629
- }
630
-
631
- function uniqueTargets(transitions: TransitionSpec[]): string[] {
632
- return [...new Set(transitions.map((transition) => transition.to))]
633
- }
634
-
635
- function checkResult(id: string, name: string, findings: Finding[], detail: string): CheckResult {
636
- const errors = findings.filter((finding) => finding.severity === 'error').length
637
- const warnings = findings.filter((finding) => finding.severity === 'warning').length
638
- return {
639
- id,
640
- name,
641
- status: findings.length === 0 ? 'pass' : 'fail',
642
- detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
643
- findings,
644
- }
645
- }
646
-
647
- // ---------------------------------------------------------------------------
648
- // S1-S7
649
- // ---------------------------------------------------------------------------
650
-
651
- function S1_reachability(model: LogicModelV1, exploration: Exploration): CheckResult {
652
- const reached = new Set(exploration.reachable.map((runtime) => runtime.state))
653
- const unreachable = model.states.filter((state) => !reached.has(state.id)).map((state) => state.id)
654
- const findings: Finding[] = unreachable.map((state) => ({
655
- code: 'S1_UNREACHABLE_STATE',
656
- severity: 'warning',
657
- message: 'State ' + state + ' is not reachable from init.',
658
- }))
659
- return checkResult('S1', 'Reachability', findings, unreachable.length === 0 ? 'All states reachable' : 'Unreachable states: ' + unreachable.join(', '))
660
- }
661
-
662
- function S2_deadlock(model: LogicModelV1): CheckResult {
663
- const outgoing = new Set(model.transitions.map((transition) => transition.from))
664
- const findings: Finding[] = []
665
- for (const state of model.states) {
666
- if (state.terminal === true) continue
667
- if (!outgoing.has(state.id)) {
668
- findings.push({
669
- code: 'S2_NO_TRANSITIONS',
670
- severity: 'error',
671
- message: 'Non-terminal state ' + state.id + ' has no outgoing transitions.',
672
- })
673
- }
674
- }
675
- return checkResult('S2', 'Deadlock', findings, findings.length === 0 ? 'No deadlocks' : 'Deadlocks: ' + findings.map((finding) => finding.message).join('; '))
676
- }
677
-
678
- function sccs(model: LogicModelV1): string[][] {
679
- const states = model.states.map((state) => state.id)
680
- const edges = new Map<string, string[]>()
681
- for (const state of states) edges.set(state, [])
682
- for (const transition of model.transitions) {
683
- const list = edges.get(transition.from)
684
- if (list !== undefined) list.push(transition.to)
685
- }
686
- let index = 0
687
- const indices = new Map<string, number>()
688
- const low = new Map<string, number>()
689
- const onStack = new Set<string>()
690
- const stack: string[] = []
691
- const components: string[][] = []
692
- const visit = (node: string): void => {
693
- indices.set(node, index)
694
- low.set(node, index)
695
- index += 1
696
- stack.push(node)
697
- onStack.add(node)
698
- for (const next of edges.get(node) ?? []) {
699
- if (!indices.has(next)) {
700
- visit(next)
701
- low.set(node, Math.min(low.get(node)!, low.get(next)!))
702
- } else if (onStack.has(next)) {
703
- low.set(node, Math.min(low.get(node)!, indices.get(next)!))
704
- }
705
- }
706
- if (low.get(node) === indices.get(node)) {
707
- const component: string[] = []
708
- while (stack.length > 0) {
709
- const member = stack.pop()!
710
- onStack.delete(member)
711
- component.push(member)
712
- if (member === node) break
713
- }
714
- components.push(component)
715
- }
716
- }
717
- for (const state of states) {
718
- if (!indices.has(state)) visit(state)
719
- }
720
- return components
721
- }
722
-
723
- function S3_liveness(model: LogicModelV1): CheckResult {
724
- const components = sccs(model)
725
- const findings: Finding[] = []
726
- for (const component of components) {
727
- const componentSet = new Set(component)
728
- const internalEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && componentSet.has(transition.to))
729
- const escapingEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to))
730
- if (internalEdges.length === 0 || escapingEdges.length > 0) continue
731
- if (component.some((state) => isTerminal(model, state))) continue
732
- findings.push({
733
- code: 'S3_CLOSED_SCC',
734
- severity: 'error',
735
- message: 'Closed SCC has no exit and contains no terminal state: ' + component.sort().join(', '),
736
- evidence: { states: component.sort(), transitions: internalEdges.length },
737
- })
738
- }
739
- return checkResult('S3', 'Liveness', findings, findings.length === 0 ? 'No harmful closed SCCs' : 'Closed SCCs: ' + findings.length)
740
- }
741
-
742
- function assignmentsFor(model: LogicModelV1, variables: string[]): Array<Record<string, VarValue>> | null {
743
- const specs = (model.variables ?? []).filter((variable) => variables.includes(variable.name))
744
- let combinations: Array<Record<string, VarValue>> = [{}]
745
- for (const spec of specs) {
746
- let values: VarValue[]
747
- if (spec.kind === 'boolean') values = [false, true]
748
- else {
749
- const constants = collectGuardConstants(model).filter((value) => guardLeaves(model).some((leaf) => leaf.variable === spec.name && typeof leaf.value === 'number' && leaf.value === value))
750
- if (spec.min !== undefined && spec.max !== undefined) {
751
- values = []
752
- for (let value = spec.min; value <= spec.max && value <= spec.min + 200; value++) values.push(value)
753
- } else {
754
- const set = new Set<number>([-1, 0, 1, ...constants])
755
- values = [...set].sort((a, b) => a - b)
756
- }
757
- }
758
- const next: Array<Record<string, VarValue>> = []
759
- for (const assignment of combinations) {
760
- for (const value of values) {
761
- next.push({ ...assignment, [spec.name]: value })
762
- if (next.length > 10_000) return null
763
- }
764
- }
765
- combinations = next
766
- }
767
- return combinations
768
- }
769
-
770
- function guardLeaves(model: LogicModelV1): LeafGuard[] {
771
- const leaves: LeafGuard[] = []
772
- const visit = (guard: GuardNode): void => {
773
- if ('variable' in guard) leaves.push(guard)
774
- else if ('all' in guard) guard.all.forEach(visit)
775
- else if ('any' in guard) guard.any.forEach(visit)
776
- else if ('not' in guard) visit(guard.not)
777
- }
778
- for (const transition of model.transitions) if (transition.guard !== undefined) visit(transition.guard)
779
- return leaves
780
- }
781
-
782
- function collectGuardConstants(model: LogicModelV1): number[] {
783
- return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value as number)
784
- }
785
-
786
- interface GuardAnalysis {
787
- determinism: Finding[]
788
- completeness: Finding[]
789
- truncated: boolean
790
- }
791
-
792
- function analyzeGuards(model: LogicModelV1): GuardAnalysis {
793
- const determinism: Finding[] = []
794
- const completeness: Finding[] = []
795
- let truncated = false
796
- const groups = groupTransitions(model)
797
- for (const [key, group] of groups) {
798
- const [from, event] = key.split('|')
799
- const unguarded = group.filter((transition) => transition.guard === undefined)
800
- const guarded = group.filter((transition) => transition.guard !== undefined)
801
- if (unguarded.length > 1) {
802
- determinism.push({
803
- code: 'S4_AMBIGUOUS_DEFAULT',
804
- severity: 'error',
805
- message: from + ' + ' + event + ' has multiple unconditional transitions: ' + uniqueTargets(unguarded).join(', '),
806
- })
807
- }
808
- if (guarded.length === 0) continue
809
- const variables = [...new Set(group.flatMap((transition) => guardVariables(transition.guard)))]
810
- const assignments = assignmentsFor(model, variables)
811
- if (assignments === null) {
812
- completeness.push({ code: 'S6_UNBOUNDED_DOMAIN', severity: 'warning', message: from + ' + ' + event + ': guard domain too large to enumerate; exhaustive check skipped.' })
813
- truncated = true
814
- continue
815
- }
816
- const targets = uniqueTargets(group)
817
- for (const assignment of assignments) {
818
- const trueBranches = guarded.filter((transition) => evalGuard(transition.guard!, assignment))
819
- if (trueBranches.length > 1) {
820
- determinism.push({
821
- code: 'S4_NONDETERMINISTIC_GUARDS',
822
- severity: 'error',
823
- message: from + ' + ' + event + ' has ' + trueBranches.length + ' simultaneously true guards for assignment ' + stableStringify(assignment) + ': ' + uniqueTargets(trueBranches).join(', '),
824
- evidence: { assignment },
825
- })
826
- }
827
- if (trueBranches.length === 0 && unguarded.length === 0) {
828
- completeness.push({
829
- code: 'S6_INCOMPLETE_GUARD',
830
- severity: 'error',
831
- message: from + ' + ' + event + ' has no true guard and no default branch for assignment ' + stableStringify(assignment),
832
- evidence: { assignment, branches: targets },
833
- })
834
- }
835
- if (determinism.length + completeness.length > 200) {
836
- truncated = true
837
- break
838
- }
839
- }
840
- if (truncated) break
841
- }
842
- return { determinism, completeness, truncated }
843
- }
844
-
845
- function S4_determinism(model: LogicModelV1): CheckResult {
846
- const analysis = analyzeGuards(model)
847
- return checkResult('S4', 'Determinism', analysis.determinism, analysis.determinism.length === 0 ? 'Deterministic' : 'Nondeterminism findings: ' + analysis.determinism.length)
848
- }
849
-
850
- function S6_guardCompleteness(model: LogicModelV1): CheckResult {
851
- const analysis = analyzeGuards(model)
852
- return checkResult('S6', 'Guard Completeness', analysis.completeness, analysis.completeness.length === 0 ? 'All guard branches defined' : 'Guard findings: ' + analysis.completeness.length)
853
- }
854
-
855
- function S5_eventCompleteness(model: LogicModelV1): CheckResult {
856
- const events = allEvents(model)
857
- const findings: Finding[] = []
858
- for (const state of model.states) {
859
- if (state.terminal === true) continue
860
- const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event))
861
- for (const event of events) {
862
- if (!handled.has(event)) {
863
- findings.push({
864
- code: 'S5_UNHANDLED_EVENT',
865
- severity: 'warning',
866
- message: state.id + ' silently ignores event ' + event,
867
- })
868
- }
869
- }
870
- }
871
- return checkResult('S5', 'Event Completeness', findings, findings.length === 0 ? 'All states handle all relevant events' : findings.length + ' unhandled (state, event) pairs')
872
- }
873
-
874
- interface InvariantViolation {
875
- invariant: InvariantSpec
876
- path: PathStep[]
877
- reason: string
878
- }
879
-
880
- function invariantHolds(invariant: InvariantSpec, runtime: RuntimeState): boolean {
881
- if (invariant.kind === 'never-states') return !invariant.states.includes(runtime.state)
882
- if (invariant.kind === 'var-in-range') {
883
- const value = runtime.vars[invariant.variable]
884
- if (typeof value !== 'number') return false
885
- if (invariant.min !== undefined && value < invariant.min) return false
886
- if (invariant.max !== undefined && value > invariant.max) return false
887
- return true
888
- }
889
- return true
890
- }
891
-
892
- function shortestViolationForInvariant(model: LogicModelV1, options: NormalizedOptions, invariant: InvariantSpec): InvariantViolation | undefined {
893
- if (invariant.kind === 'event-before-state') {
894
- return shortestEventBeforeStateViolation(model, options, invariant)
895
- }
896
- const init = initialState(model)
897
- if (!invariantHolds(invariant, init)) {
898
- return { invariant, path: [], reason: 'Initial state violates the invariant.' }
899
- }
900
- const visited = new Set<string>([runtimeKey(init)])
901
- const queue: Array<{ runtime: RuntimeState; path: PathStep[] }> = [{ runtime: init, path: [] }]
902
- let steps = 0
903
- while (queue.length > 0) {
904
- const entry = queue.shift()!
905
- if (++steps > options.maxStates) break
906
- for (const event of allEvents(model)) {
907
- for (const next of stepRuntime(model, entry.runtime, event)) {
908
- const key = runtimeKey(next)
909
- if (visited.has(key)) continue
910
- const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
911
- if (!invariantHolds(invariant, next)) {
912
- return { invariant, path, reason: 'Invariant violated after ' + path.length + ' events.' }
913
- }
914
- visited.add(key)
915
- queue.push({ runtime: next, path })
916
- }
917
- }
918
- }
919
- return undefined
920
- }
921
-
922
- function shortestEventBeforeStateViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'event-before-state' }>): InvariantViolation | undefined {
923
- const init = initialState(model)
924
- const seenInitially = false
925
- if (init.state === invariant.state && !seenInitially) {
926
- return { invariant, path: [], reason: 'Target state ' + invariant.state + ' is initial and the required event ' + invariant.event + ' has not occurred.' }
927
- }
928
- const key = (runtime: RuntimeState, seen: boolean): string => runtimeKey(runtime) + '|' + (seen ? '1' : '0')
929
- const visited = new Set<string>([key(init, false)])
930
- const queue: Array<{ runtime: RuntimeState; seen: boolean; path: PathStep[] }> = [{ runtime: init, seen: false, path: [] }]
931
- let steps = 0
932
- while (queue.length > 0) {
933
- const entry = queue.shift()!
934
- if (++steps > options.maxStates) break
935
- for (const event of allEvents(model)) {
936
- for (const next of stepRuntime(model, entry.runtime, event)) {
937
- const seen = entry.seen || event === invariant.event
938
- const nextKey = key(next, seen)
939
- if (visited.has(nextKey)) continue
940
- const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
941
- if (next.state === invariant.state && !seen) {
942
- return { invariant, path, reason: 'Target state ' + invariant.state + ' is reachable without event ' + invariant.event + ' in ' + path.length + ' steps.' }
943
- }
944
- visited.add(nextKey)
945
- queue.push({ runtime: next, seen, path })
946
- }
947
- }
948
- }
949
- return undefined
950
- }
951
-
952
- function runInvariants(model: LogicModelV1, options: NormalizedOptions): InvariantViolation[] {
953
- const violations: InvariantViolation[] = []
954
- for (const invariant of model.invariants ?? []) {
955
- const violation = shortestViolationForInvariant(model, options, invariant)
956
- if (violation !== undefined) violations.push(violation)
957
- }
958
- return violations
959
- }
960
-
961
- function S7_invariants(model: LogicModelV1, options: NormalizedOptions): CheckResult {
962
- const violations = runInvariants(model, options)
963
- const findings: Finding[] = violations.map((violation) => ({
964
- code: 'S7_INVARIANT_VIOLATION',
965
- severity: 'error',
966
- message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
967
- path: violation.path,
968
- evidence: { invariant: violation.invariant },
969
- }))
970
- return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length)
971
- }
972
-
973
- // ---------------------------------------------------------------------------
974
- // A1-A7
975
- // ---------------------------------------------------------------------------
976
-
977
- function A1_unexpectedEvents(model: LogicModelV1): CheckResult {
978
- const events = allEvents(model)
979
- const findings: Finding[] = []
980
- for (const state of model.states) {
981
- if (state.terminal === true) continue
982
- const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event))
983
- for (const event of events) {
984
- if (!handled.has(event)) {
985
- findings.push({
986
- code: 'A1_UNHANDLED_EVENT',
987
- severity: 'warning',
988
- message: 'Event ' + event + ' in state ' + state.id + ' has no defined transition (silent ignore).',
989
- })
990
- }
991
- }
992
- }
993
- return checkResult('A1', 'Unexpected Event Injection', findings, findings.length === 0 ? 'All event/state combinations defined' : findings.length + ' unhandled combinations')
994
- }
995
-
996
- function firstOutcome(runtime: RuntimeState, outcomes: RuntimeState[]): RuntimeState | undefined {
997
- return outcomes[0]
998
- }
999
-
1000
- function A2_raceInterleaving(model: LogicModelV1, exploration: Exploration): CheckResult {
1001
- const findings: Finding[] = []
1002
- for (const [e1, e2] of model.concurrentPairs ?? []) {
1003
- for (const runtime of exploration.reachable) {
1004
- const first = firstOutcome(runtime, stepRuntime(model, runtime, e1))
1005
- const second = firstOutcome(runtime, stepRuntime(model, runtime, e2))
1006
- if (first === undefined && second === undefined) continue
1007
- const final12 = first === undefined ? runtime : firstOutcome(first, stepRuntime(model, first, e2)) ?? first
1008
- const final21 = second === undefined ? runtime : firstOutcome(second, stepRuntime(model, second, e1)) ?? second
1009
- if (runtimeKey(final12) !== runtimeKey(final21)) {
1010
- findings.push({
1011
- code: 'A2_ORDER_DEPENDENT',
1012
- severity: 'warning',
1013
- message: 'Events ' + e1 + ' and ' + e2 + ' produce order-dependent outcomes from state ' + runtime.state,
1014
- evidence: { e1ThenE2: final12, e2ThenE1: final21 },
1015
- })
1016
- }
1017
- }
1018
- }
1019
- return checkResult('A2', 'Race Interleaving', findings, findings.length === 0 ? 'No race conditions detected' : 'Order-dependent outcomes: ' + findings.length)
1020
- }
1021
-
1022
- function stepSequence(model: LogicModelV1, init: RuntimeState, events: string[]): RuntimeState {
1023
- let runtime = init
1024
- for (const event of events) {
1025
- const next = firstOutcome(runtime, stepRuntime(model, runtime, event))
1026
- if (next === undefined) continue
1027
- runtime = next
1028
- }
1029
- return runtime
1030
- }
1031
-
1032
- function permutations<T>(items: T[]): T[][] {
1033
- if (items.length === 0) return [[]]
1034
- const result: T[][] = []
1035
- for (let index = 0; index < items.length; index++) {
1036
- const rest = items.slice(0, index).concat(items.slice(index + 1))
1037
- for (const suffix of permutations(rest)) result.push([items[index], ...suffix])
1038
- }
1039
- return result
1040
- }
1041
-
1042
- function A3_orderPermutation(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1043
- const events = allEvents(model).slice(0, options.maxPermutationEvents)
1044
- if (events.length < 2) return checkResult('A3', 'Order Permutation', [], 'Fewer than 2 events — skipped')
1045
- const init = initialState(model)
1046
- const outcomes = new Map<string, string[]>()
1047
- for (const permutation of permutations(events)) {
1048
- const final = stepSequence(model, init, permutation)
1049
- const key = runtimeKey(final)
1050
- const list = outcomes.get(key) ?? []
1051
- if (list.length < 3) list.push(permutation.join(','))
1052
- outcomes.set(key, list)
1053
- }
1054
- if (outcomes.size > 1) {
1055
- const examples = [...outcomes.entries()].map(([key, sequences]) => ({ final: key.split('|')[0], example: sequences[0] }))
1056
- return checkResult('A3', 'Order Permutation', [{
1057
- code: 'A3_ORDER_DEPENDENT',
1058
- severity: 'warning',
1059
- message: 'Same event set produces ' + outcomes.size + ' different outcomes depending on order.',
1060
- evidence: { examples },
1061
- }], 'Order-dependent outcomes: ' + outcomes.size)
1062
- }
1063
- return checkResult('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + events.length + ' events)')
1064
- }
1065
-
1066
- function A4_pairSymmetry(model: LogicModelV1): CheckResult {
1067
- const findings: Finding[] = []
1068
- const transitionEvents = new Set(model.transitions.map((transition) => transition.event))
1069
- for (const pair of model.resourcePairs ?? []) {
1070
- const acquireTransitions = model.transitions.filter((transition) => transition.event === pair.acquireEvent)
1071
- const releaseExists = transitionEvents.has(pair.releaseEvent)
1072
- if (acquireTransitions.length === 0 && !releaseExists) continue
1073
- if (acquireTransitions.length > 0 && !releaseExists) {
1074
- findings.push({
1075
- code: 'A4_NO_RELEASE_EVENT',
1076
- severity: 'error',
1077
- message: 'Resource "' + pair.resource + '": acquire event ' + pair.acquireEvent + ' exists but release event ' + pair.releaseEvent + ' is never defined.',
1078
- })
1079
- continue
1080
- }
1081
- for (const acquire of acquireTransitions) {
1082
- // Is any release event reachable in the state graph from the acquired state?
1083
- const reachable = stateGraphReachable(model, acquire.to)
1084
- const releaseReachable = [...reachable].some((state) => model.transitions.some((transition) => transition.from === state && transition.event === pair.releaseEvent))
1085
- if (!releaseReachable) {
1086
- findings.push({
1087
- code: 'A4_NO_RELEASE_REACHABLE',
1088
- severity: 'error',
1089
- message: 'Resource "' + pair.resource + '": after ' + pair.acquireEvent + ' into ' + acquire.to + ', no ' + pair.releaseEvent + ' transition is reachable.',
1090
- evidence: { acquireTransition: acquire },
1091
- })
1092
- continue
1093
- }
1094
- // Path-sensitive probe: track the held flag along state-graph paths.
1095
- const edges = stateGraphEdges(model)
1096
- const visited = new Set<string>()
1097
- const queue: Array<{ state: string; held: boolean; path: PathStep[] }> = [{ state: acquire.to, held: true, path: [] }]
1098
- let steps = 0
1099
- while (queue.length > 0) {
1100
- const entry = queue.shift()!
1101
- if (++steps > 1000) break
1102
- const key = entry.state + '|' + (entry.held ? '1' : '0')
1103
- if (visited.has(key)) continue
1104
- visited.add(key)
1105
- if (entry.held && isTerminal(model, entry.state)) {
1106
- findings.push({
1107
- code: 'A4_TERMINAL_WITH_RESOURCE',
1108
- severity: 'error',
1109
- message: 'Resource "' + pair.resource + '" is still held when entering terminal state ' + entry.state + '.',
1110
- path: entry.path,
1111
- })
1112
- continue
1113
- }
1114
- for (const transition of edges.get(entry.state) ?? []) {
1115
- let held = entry.held
1116
- if (transition.event === pair.releaseEvent) held = false
1117
- else if (transition.event === pair.acquireEvent) {
1118
- if (held) {
1119
- findings.push({
1120
- code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1121
- severity: 'warning',
1122
- message: 'Resource "' + pair.resource + '" is acquired again in state ' + entry.state + ' before ' + pair.releaseEvent + '.',
1123
- path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
1124
- })
1125
- continue
1126
- }
1127
- held = true
1128
- }
1129
- queue.push({ state: transition.to, held, path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }] })
1130
- }
1131
- }
1132
- }
1133
- }
1134
- return checkResult('A4', 'Pair Symmetry', findings, findings.length === 0 ? 'All pairs balanced' : 'Asymmetric pairs: ' + findings.length)
1135
- }
1136
-
1137
- function stateGraphEdges(model: LogicModelV1): Map<string, TransitionSpec[]> {
1138
- const edges = new Map<string, TransitionSpec[]>()
1139
- for (const state of model.states) edges.set(state.id, [])
1140
- for (const transition of model.transitions) edges.get(transition.from)?.push(transition)
1141
- return edges
1142
- }
1143
-
1144
- function stateGraphReachable(model: LogicModelV1, start: string): Set<string> {
1145
- const edges = new Map<string, string[]>()
1146
- for (const state of model.states) edges.set(state.id, [])
1147
- for (const transition of model.transitions) edges.get(transition.from)?.push(transition.to)
1148
- const visited = new Set<string>()
1149
- const queue = [start]
1150
- while (queue.length > 0) {
1151
- const current = queue.shift()!
1152
- if (visited.has(current)) continue
1153
- visited.add(current)
1154
- for (const next of edges.get(current) ?? []) queue.push(next)
1155
- }
1156
- return visited
1157
- }
1158
-
1159
- function A5_boundaryBlast(model: LogicModelV1): CheckResult {
1160
- const findings: Finding[] = []
1161
- for (const check of model.boundaryChecks ?? []) {
1162
- const variable = (model.variables ?? []).find((entry) => entry.name === check.variable)
1163
- if (variable === undefined) continue
1164
- for (const value of check.values) {
1165
- if (variable.min !== undefined && value < variable.min) {
1166
- findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is below min ' + variable.min })
1167
- }
1168
- if (variable.max !== undefined && value > variable.max) {
1169
- findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is above max ' + variable.max })
1170
- }
1171
- const assignment = { ...(model.variables ?? []).reduce<Record<string, VarValue>>((result, entry) => { result[entry.name] = entry.init; return result }, {}), [check.variable]: value }
1172
- for (const [key, group] of groupTransitions(model)) {
1173
- const [from, event] = key.split('|')
1174
- if (!group.some((transition) => guardVariables(transition.guard).includes(check.variable))) continue
1175
- const guarded = group.filter((transition) => transition.guard !== undefined)
1176
- const unguarded = group.filter((transition) => transition.guard === undefined)
1177
- const matched = guarded.filter((transition) => evalGuard(transition.guard!, assignment))
1178
- if (matched.length === 0 && unguarded.length === 0) {
1179
- findings.push({
1180
- code: 'A5_GUARD_HOLE',
1181
- severity: 'warning',
1182
- message: from + ' + ' + event + ' has no branch for ' + check.variable + '=' + value,
1183
- evidence: { assignment },
1184
- })
1185
- }
1186
- if (matched.length > 1) {
1187
- findings.push({
1188
- code: 'A5_GUARD_OVERLAP',
1189
- severity: 'warning',
1190
- message: from + ' + ' + event + ' has ' + matched.length + ' true branches for ' + check.variable + '=' + value,
1191
- evidence: { assignment },
1192
- })
1193
- }
1194
- }
1195
- }
1196
- }
1197
- return checkResult('A5', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length)
1198
- }
1199
-
1200
- function A6_resourceInjection(model: LogicModelV1): CheckResult {
1201
- const findings: Finding[] = []
1202
- for (const pair of model.resourcePairs ?? []) {
1203
- if (pair.failEvent === undefined) continue
1204
- const acquireStates = new Set(model.transitions.filter((transition) => transition.event === pair.acquireEvent).map((transition) => transition.from))
1205
- for (const state of acquireStates) {
1206
- const handled = model.transitions.some((transition) => transition.from === state && transition.event === pair.failEvent)
1207
- if (!handled) {
1208
- findings.push({
1209
- code: 'A6_NO_FAILURE_HANDLER',
1210
- severity: 'warning',
1211
- message: 'State ' + state + ' can ' + pair.acquireEvent + ' for "' + pair.resource + '" but has no ' + pair.failEvent + ' transition.',
1212
- })
1213
- }
1214
- }
1215
- }
1216
- return checkResult('A6', 'Resource Injection', findings, findings.length === 0 ? 'No resource vulnerabilities detected' : 'Resource failure paths missing: ' + findings.length)
1217
- }
1218
-
1219
- function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1220
- const violations = runInvariants(model, options)
1221
- const findings: Finding[] = violations.map((violation) => ({
1222
- code: 'A7_SHORTEST_COUNTEREXAMPLE',
1223
- severity: 'warning',
1224
- message: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + violation.path.length + (violation.path.length === 0 ? ' (initial state)' : ''),
1225
- path: violation.path,
1226
- evidence: { invariant: violation.invariant.id },
1227
- }))
1228
- return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length)
1229
- }
1230
-
1231
- function mapStateId(mapping: Record<string, string>, state: string): string {
1232
- return mapping[state] ?? state
1233
- }
1234
-
1235
- function D1_behavioralPreservation(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
1236
- const findings: Finding[] = []
1237
- const afterStates = new Set(after.states.map((state) => state.id))
1238
- const afterByFromEvent = new Map<string, Set<string>>()
1239
- for (const transition of after.transitions) {
1240
- const key = transition.from + '|' + transition.event
1241
- const set = afterByFromEvent.get(key) ?? new Set<string>()
1242
- set.add(transition.to)
1243
- afterByFromEvent.set(key, set)
1244
- }
1245
- const beforeInitMapped = mapStateId(mapping, before.init)
1246
- if (beforeInitMapped !== after.init) {
1247
- findings.push({
1248
- code: 'D1_INIT_MISMATCH',
1249
- severity: 'warning',
1250
- message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
1251
- evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
1252
- })
1253
- }
1254
- for (const state of before.states) {
1255
- const mapped = mapStateId(mapping, state.id)
1256
- if (!afterStates.has(mapped)) {
1257
- findings.push({
1258
- code: 'D1_MAPPED_STATE_MISSING',
1259
- severity: 'error',
1260
- message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
1261
- evidence: { beforeState: state.id, mappedState: mapped },
1262
- })
1263
- continue
1264
- }
1265
- for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
1266
- const key = mapped + '|' + transition.event
1267
- const targets = afterByFromEvent.get(key)
1268
- if (targets === undefined || targets.size === 0) {
1269
- findings.push({
1270
- code: 'D1_EVENT_DISABLED',
1271
- severity: 'error',
1272
- message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
1273
- path: [{ from: state.id, event: transition.event, to: transition.to }],
1274
- evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
1275
- })
1276
- }
1277
- }
1278
- }
1279
- return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length)
1280
- }
1281
-
1282
- function mapInvariantForComparison(invariant: InvariantSpec, mapping: Record<string, string>): InvariantSpec {
1283
- if (invariant.kind === 'never-states') {
1284
- return {
1285
- ...invariant,
1286
- id: invariant.id + ':before',
1287
- description: invariant.description + ' (from BEFORE)',
1288
- states: invariant.states.map((state) => mapStateId(mapping, state)),
1289
- }
1290
- }
1291
- if (invariant.kind === 'event-before-state') {
1292
- return {
1293
- ...invariant,
1294
- id: invariant.id + ':before',
1295
- description: invariant.description + ' (from BEFORE)',
1296
- state: mapStateId(mapping, invariant.state),
1297
- }
1298
- }
1299
- return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' }
1300
- }
1301
-
1302
- function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, options: NormalizedOptions, mapping: Record<string, string>): CheckResult {
1303
- const findings: Finding[] = []
1304
- const afterStates = new Set(after.states.map((state) => state.id))
1305
- const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name))
1306
- for (const invariant of before.invariants ?? []) {
1307
- const mapped = mapInvariantForComparison(invariant, mapping)
1308
- if (mapped.kind === 'never-states') {
1309
- for (const state of mapped.states) {
1310
- if (!afterStates.has(state)) {
1311
- findings.push({
1312
- code: 'D2_MAPPED_STATE_MISSING',
1313
- severity: 'warning',
1314
- message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
1315
- evidence: { invariant: invariant.id, state },
1316
- })
1317
- }
1318
- }
1319
- } else if (mapped.kind === 'event-before-state') {
1320
- if (!afterStates.has(mapped.state)) {
1321
- findings.push({
1322
- code: 'D2_MAPPED_STATE_MISSING',
1323
- severity: 'warning',
1324
- message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
1325
- evidence: { invariant: invariant.id, state: mapped.state },
1326
- })
1327
- }
1328
- } else if (mapped.kind === 'var-in-range') {
1329
- if (!afterVariables.has(mapped.variable)) {
1330
- findings.push({
1331
- code: 'D2_VARIABLE_MISSING',
1332
- severity: 'warning',
1333
- message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
1334
- evidence: { invariant: invariant.id, variable: mapped.variable },
1335
- })
1336
- continue
1337
- }
1338
- }
1339
- const violation = shortestViolationForInvariant(after, options, mapped)
1340
- if (violation !== undefined) {
1341
- findings.push({
1342
- code: 'D2_INVARIANT_REGRESSION',
1343
- severity: 'error',
1344
- message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
1345
- path: violation.path,
1346
- evidence: { beforeInvariant: invariant, afterInvariant: mapped },
1347
- })
1348
- }
1349
- }
1350
- return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length)
1351
- }
1352
-
1353
- function D3_regressionDelta(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
1354
- const afterStateIds = new Set(after.states.map((state) => state.id))
1355
- const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
1356
- const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
1357
- const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
1358
- const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
1359
- const afterEvents = new Set(after.transitions.map((transition) => transition.event))
1360
- const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
1361
- const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
1362
- const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
1363
- candidate.from === mapStateId(mapping, transition.from) &&
1364
- candidate.event === transition.event &&
1365
- candidate.to === mapStateId(mapping, transition.to)
1366
- ))
1367
- const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
1368
- mapStateId(mapping, candidate.from) === transition.from &&
1369
- candidate.event === transition.event &&
1370
- mapStateId(mapping, candidate.to) === transition.to
1371
- ))
1372
- const findings: Finding[] = []
1373
- for (const state of removedStates) {
1374
- findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } })
1375
- }
1376
- for (const event of removedEvents) {
1377
- findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } })
1378
- }
1379
- for (const transition of removedTransitions) {
1380
- findings.push({
1381
- code: 'D3_REMOVED_TRANSITION',
1382
- severity: 'warning',
1383
- message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
1384
- evidence: { transition },
1385
- })
1386
- }
1387
- const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions'
1388
- return checkResult('D3', 'Regression Delta', findings, detail)
1389
- }
1390
-
1391
- function deadlockStateIds(model: LogicModelV1): string[] {
1392
- const outgoing = new Set(model.transitions.map((transition) => transition.from))
1393
- return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id)
1394
- }
1395
-
1396
- function closedSccStateSets(model: LogicModelV1): string[][] {
1397
- return sccs(model)
1398
- .filter((component) => {
1399
- const componentSet = new Set(component)
1400
- const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to))
1401
- const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to))
1402
- return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state))
1403
- })
1404
- .map((component) => component.sort())
1405
- }
1406
-
1407
- function D4_deadlockLivenessRegression(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
1408
- const findings: Finding[] = []
1409
- const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)))
1410
- const afterDeadlock = deadlockStateIds(after)
1411
- for (const state of afterDeadlock) {
1412
- if (!beforeDeadlock.has(state)) {
1413
- findings.push({
1414
- code: 'D4_DEADLOCK_REGRESSION',
1415
- severity: 'error',
1416
- message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
1417
- evidence: { state },
1418
- })
1419
- }
1420
- }
1421
- const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')))
1422
- const afterScc = closedSccStateSets(after)
1423
- for (const component of afterScc) {
1424
- const key = component.join(',')
1425
- if (!beforeScc.has(key)) {
1426
- findings.push({
1427
- code: 'D4_LIVENESS_REGRESSION',
1428
- severity: 'error',
1429
- message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
1430
- evidence: { states: component },
1431
- })
1432
- }
1433
- }
1434
- return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length)
1435
- }
1436
-
1437
- function buildComparisonSummary(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): ComparisonSummary {
1438
- const afterStateIds = new Set(after.states.map((state) => state.id))
1439
- const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
1440
- const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
1441
- const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
1442
- const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
1443
- const afterEvents = new Set(after.transitions.map((transition) => transition.event))
1444
- const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
1445
- const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
1446
- const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
1447
- candidate.from === mapStateId(mapping, transition.from) &&
1448
- candidate.event === transition.event &&
1449
- candidate.to === mapStateId(mapping, transition.to)
1450
- ))
1451
- const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
1452
- mapStateId(mapping, candidate.from) === transition.from &&
1453
- candidate.event === transition.event &&
1454
- mapStateId(mapping, candidate.to) === transition.to
1455
- ))
1456
- return {
1457
- beforeModelHash: modelHash(before),
1458
- afterModelHash: modelHash(after),
1459
- stateMapping: mapping,
1460
- beforeStates: before.states.length,
1461
- beforeTransitions: before.transitions.length,
1462
- afterStates: after.states.length,
1463
- afterTransitions: after.transitions.length,
1464
- addedStates,
1465
- removedStates,
1466
- addedEvents,
1467
- removedEvents,
1468
- addedTransitions,
1469
- removedTransitions,
1470
- }
1471
- }
1472
- function A8_idempotentReplay(model: LogicModelV1, exploration: Exploration): CheckResult {
1473
- const events = model.idempotentEvents ?? []
1474
- const findings: Finding[] = []
1475
- for (const event of events) {
1476
- for (const runtime of exploration.reachable) {
1477
- const onceOptions = stepRuntime(model, runtime, event)
1478
- if (onceOptions.length === 0) continue
1479
- for (const once of onceOptions) {
1480
- const twiceOptions = stepRuntime(model, once, event)
1481
- if (twiceOptions.length === 0) {
1482
- findings.push({
1483
- code: 'A8_NOT_REPLAYABLE',
1484
- severity: 'warning',
1485
- message: 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime.state + '.',
1486
- path: [{ from: runtime.state, event, to: once.state }],
1487
- evidence: { state: runtime.state, event },
1488
- })
1489
- continue
1490
- }
1491
- for (const twice of twiceOptions) {
1492
- if (runtimeKey(twice) !== runtimeKey(once)) {
1493
- findings.push({
1494
- code: 'A8_NOT_IDEMPOTENT',
1495
- severity: 'error',
1496
- message: 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime.state + '.',
1497
- path: [{ from: runtime.state, event, to: once.state }, { from: once.state, event, to: twice.state }],
1498
- evidence: { state: runtime.state, event, afterOnce: once, afterTwice: twice },
1499
- })
1500
- break
1501
- }
1502
- }
1503
- }
1504
- }
1505
- }
1506
- return checkResult('A8', 'Idempotent Replay', findings, findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length)
1507
- }
1508
- function S8_monotonicVariables(model: LogicModelV1): CheckResult {
1509
- const findings: Finding[] = []
1510
- for (const variable of model.variables ?? []) {
1511
- if (variable.monotonic === undefined) continue
1512
- for (const transition of model.transitions) {
1513
- for (const update of transition.updates ?? []) {
1514
- if (update.variable !== variable.name) continue
1515
- if (variable.monotonic === 'inc' && update.op === 'dec') {
1516
- findings.push({ code: 'S8_MONOTONIC_DECREASE', severity: 'error', message: 'Monotonic (inc) variable ' + variable.name + ' is decreased by ' + transition.event + '.', evidence: { variable: variable.name, transition } })
1517
- } else if (variable.monotonic === 'dec' && update.op === 'inc') {
1518
- findings.push({ code: 'S8_MONOTONIC_INCREASE', severity: 'error', message: 'Monotonic (dec) variable ' + variable.name + ' is increased by ' + transition.event + '.', evidence: { variable: variable.name, transition } })
1519
- } else if (update.op === 'set') {
1520
- findings.push({ code: 'S8_MONOTONIC_SET_REVIEW', severity: 'warning', message: 'Monotonic variable ' + variable.name + ' uses set in ' + transition.event + '; verify it cannot move backwards.', evidence: { variable: variable.name, transition } })
1521
- }
1522
- }
1523
- }
1524
- }
1525
- return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length)
1526
- }
1527
-
1528
- function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: string): { path: PathStep[]; reason: string } | undefined {
1529
- if (start.state === target) return undefined
1530
- const visited = new Set<string>()
1531
- const queue: Array<{ runtime: RuntimeState; path: PathStep[] }> = [{ runtime: start, path: [] }]
1532
- while (queue.length > 0) {
1533
- const entry = queue.shift()!
1534
- const key = runtimeKey(entry.runtime)
1535
- if (entry.runtime.state === target) continue
1536
- if (visited.has(key)) return { path: entry.path, reason: 'Cycle avoids target ' + target }
1537
- visited.add(key)
1538
- const nexts: Array<{ next: RuntimeState; event: string }> = []
1539
- for (const event of allEvents(model)) {
1540
- for (const next of stepRuntime(model, entry.runtime, event)) nexts.push({ next, event })
1541
- }
1542
- if (nexts.length === 0) return { path: entry.path, reason: 'Dead end before target ' + target }
1543
- for (const { next, event } of nexts) {
1544
- queue.push({ runtime: next, path: [...entry.path, { from: entry.runtime.state, event, to: next.state }] })
1545
- }
1546
- }
1547
- return { path: [], reason: 'No path reaches target ' + target }
1548
- }
1549
-
1550
- function A9_leadsTo(model: LogicModelV1, exploration: Exploration): CheckResult {
1551
- const findings: Finding[] = []
1552
- for (const invariant of model.invariants ?? []) {
1553
- if (invariant.kind !== 'leads-to') continue
1554
- for (const runtime of exploration.reachable) {
1555
- if (runtime.state !== invariant.from) continue
1556
- const bad = findLeadsToBadPath(model, runtime, invariant.to)
1557
- if (bad !== undefined) {
1558
- findings.push({
1559
- code: 'A9_LEADS_TO_VIOLATION',
1560
- severity: 'error',
1561
- message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + bad.reason,
1562
- path: bad.path,
1563
- evidence: { invariant },
1564
- })
1565
- break
1566
- }
1567
- }
1568
- }
1569
- return checkResult('A9', 'Leads-To', findings, findings.length === 0 ? 'All leads-to invariants hold' : 'Leads-to findings: ' + findings.length)
1570
- }
1571
-
1572
- function findSequenceViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'sequence' }>): InvariantViolation | undefined {
1573
- const events = invariant.events
1574
- const init = initialState(model)
1575
- const key = (runtime: RuntimeState, progress: number): string => runtimeKey(runtime) + '|' + progress
1576
- const visited = new Set<string>([key(init, 0)])
1577
- const queue: Array<{ runtime: RuntimeState; progress: number; path: PathStep[] }> = [{ runtime: init, progress: 0, path: [] }]
1578
- let steps = 0
1579
- while (queue.length > 0) {
1580
- const entry = queue.shift()!
1581
- if (++steps > options.maxStates) break
1582
- for (const event of allEvents(model)) {
1583
- for (const next of stepRuntime(model, entry.runtime, event)) {
1584
- let progress = entry.progress
1585
- let violation = false
1586
- if (progress < events.length && event === events[progress]) {
1587
- progress += 1
1588
- } else {
1589
- const index = events.indexOf(event)
1590
- if (index > progress) violation = true
1591
- }
1592
- const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
1593
- if (violation) {
1594
- return { invariant, path, reason: 'Event ' + event + ' occurred before ' + events[progress] }
1595
- }
1596
- const nextKey = key(next, progress)
1597
- if (!visited.has(nextKey)) {
1598
- visited.add(nextKey)
1599
- queue.push({ runtime: next, progress, path })
1600
- }
1601
- }
1602
- }
1603
- }
1604
- return undefined
1605
- }
1606
-
1607
- function A10_sequenceOrder(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1608
- const findings: Finding[] = []
1609
- for (const invariant of model.invariants ?? []) {
1610
- if (invariant.kind !== 'sequence') continue
1611
- const violation = findSequenceViolation(model, options, invariant)
1612
- if (violation !== undefined) {
1613
- findings.push({
1614
- code: 'A10_SEQUENCE_VIOLATION',
1615
- severity: 'error',
1616
- message: 'Sequence invariant "' + invariant.id + '" violated: ' + violation.reason,
1617
- path: violation.path,
1618
- evidence: { invariant },
1619
- })
1620
- }
1621
- }
1622
- return checkResult('A10', 'Sequence Order', findings, findings.length === 0 ? 'All sequence invariants hold' : 'Sequence findings: ' + findings.length)
1623
- }
1624
-
1625
- function findAtomicityViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'atomicity' }>): InvariantViolation | undefined {
1626
- const atomic = new Set(invariant.events)
1627
- const init = initialState(model)
1628
- const key = (runtime: RuntimeState, started: boolean, closed: boolean): string => runtimeKey(runtime) + '|' + (started ? '1' : '0') + '|' + (closed ? '1' : '0')
1629
- const visited = new Set<string>([key(init, false, false)])
1630
- const queue: Array<{ runtime: RuntimeState; started: boolean; closed: boolean; path: PathStep[] }> = [{ runtime: init, started: false, closed: false, path: [] }]
1631
- let steps = 0
1632
- while (queue.length > 0) {
1633
- const entry = queue.shift()!
1634
- if (++steps > options.maxStates) break
1635
- for (const event of allEvents(model)) {
1636
- for (const next of stepRuntime(model, entry.runtime, event)) {
1637
- const started = entry.started || atomic.has(event)
1638
- const closed = entry.closed || event === invariant.commit || (invariant.rollback !== undefined && event === invariant.rollback)
1639
- const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
1640
- if (entry.started && !entry.closed && !atomic.has(event) && event !== invariant.commit && event !== invariant.rollback) {
1641
- return { invariant, path, reason: 'Left atomic scope via ' + event + ' without commit/rollback' }
1642
- }
1643
- if (started && !closed && isTerminal(model, next.state)) {
1644
- return { invariant, path, reason: 'Terminal state reached with incomplete atomic group' }
1645
- }
1646
- const nextKey = key(next, started, closed)
1647
- if (!visited.has(nextKey)) {
1648
- visited.add(nextKey)
1649
- queue.push({ runtime: next, started, closed, path })
1650
- }
1651
- }
1652
- }
1653
- }
1654
- return undefined
1655
- }
1656
-
1657
- function A11_atomicity(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1658
- const findings: Finding[] = []
1659
- for (const invariant of model.invariants ?? []) {
1660
- if (invariant.kind !== 'atomicity') continue
1661
- const violation = findAtomicityViolation(model, options, invariant)
1662
- if (violation !== undefined) {
1663
- findings.push({
1664
- code: 'A11_ATOMICITY_VIOLATION',
1665
- severity: 'error',
1666
- message: 'Atomicity invariant "' + invariant.id + '" violated: ' + violation.reason,
1667
- path: violation.path,
1668
- evidence: { invariant },
1669
- })
1670
- }
1671
- }
1672
- return checkResult('A11', 'Atomicity', findings, findings.length === 0 ? 'All atomicity invariants hold' : 'Atomicity findings: ' + findings.length)
1673
- }
1674
- // ---------------------------------------------------------------------------
1675
- // main entry
1676
- // ---------------------------------------------------------------------------
1677
-
1678
- export function runVerification(input: unknown, options: VerificationOptions = {}): VerificationReport {
1679
- const normalized: NormalizedOptions = {
1680
- maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
1681
- maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
1682
- }
1683
- const validation = validateModel(input)
1684
- if (!validation.ok) {
1685
- return {
1686
- ok: false,
1687
- schemaVersion: 1,
1688
- modelHash: '',
1689
- summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
1690
- checks: [{
1691
- id: 'MODEL',
1692
- name: 'Model Validation',
1693
- status: 'fail',
1694
- detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
1695
- findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
1696
- }],
1697
- }
1698
- }
1699
- const model = validation.model
1700
- const exploration = explore(model, normalized)
1701
- const checks: CheckResult[] = [
1702
- S1_reachability(model, exploration),
1703
- S2_deadlock(model),
1704
- S3_liveness(model),
1705
- S4_determinism(model),
1706
- S5_eventCompleteness(model),
1707
- S6_guardCompleteness(model),
1708
- S7_invariants(model, normalized),
1709
- S8_monotonicVariables(model),
1710
- A1_unexpectedEvents(model),
1711
- A2_raceInterleaving(model, exploration),
1712
- A3_orderPermutation(model, normalized),
1713
- A4_pairSymmetry(model),
1714
- A5_boundaryBlast(model),
1715
- A6_resourceInjection(model),
1716
- A7_shortestViolations(model, normalized),
1717
- A8_idempotentReplay(model, exploration),
1718
- A9_leadsTo(model, exploration),
1719
- A10_sequenceOrder(model, normalized),
1720
- A11_atomicity(model, normalized),
1721
- ]
1722
- let comparison: ComparisonSummary | undefined
1723
- if (options.beforeModel !== undefined) {
1724
- const beforeValidation = validateModel(options.beforeModel)
1725
- if (!beforeValidation.ok) {
1726
- return {
1727
- ok: false,
1728
- schemaVersion: 1,
1729
- modelHash: modelHash(model),
1730
- summary: {
1731
- states: model.states.length,
1732
- transitions: model.transitions.length,
1733
- errors: beforeValidation.errors.length,
1734
- warnings: 0,
1735
- checksRun: checks.length + 1,
1736
- truncated: exploration.truncated,
1737
- },
1738
- checks: [
1739
- ...checks,
1740
- {
1741
- id: 'BEFORE_MODEL',
1742
- name: 'Before Model Validation',
1743
- status: 'fail',
1744
- detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
1745
- findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
1746
- },
1747
- ],
1748
- }
1749
- }
1750
- const before = beforeValidation.model
1751
- const mapping = options.stateMapping ?? {}
1752
- checks.push(
1753
- D1_behavioralPreservation(before, model, mapping),
1754
- D2_invariantContinuity(before, model, normalized, mapping),
1755
- D3_regressionDelta(before, model, mapping),
1756
- D4_deadlockLivenessRegression(before, model, mapping),
1757
- )
1758
- comparison = buildComparisonSummary(before, model, mapping)
1759
- }
1760
- const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
1761
- const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
1762
- return {
1763
- ok: true,
1764
- schemaVersion: 1,
1765
- modelHash: modelHash(model),
1766
- summary: {
1767
- states: model.states.length,
1768
- transitions: model.transitions.length,
1769
- errors,
1770
- warnings,
1771
- checksRun: checks.length,
1772
- truncated: exploration.truncated,
1773
- },
1774
- checks,
1775
- ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
1776
- ...(comparison === undefined ? {} : { comparison }),
1777
- }
1778
- }
1
+ import { createHash } from 'node:crypto'
2
+
3
+ export const ENGINE_SCHEMA_VERSION = 1
4
+
5
+ export type VarValue = number | boolean
6
+
7
+ export type GuardOp = '==' | '!=' | '<' | '<=' | '>' | '>='
8
+
9
+ export interface LeafGuard {
10
+ variable: string
11
+ op: GuardOp
12
+ value: VarValue
13
+ }
14
+
15
+ export interface AllGuard {
16
+ all: GuardNode[]
17
+ }
18
+
19
+ export interface AnyGuard {
20
+ any: GuardNode[]
21
+ }
22
+
23
+ export interface NotGuard {
24
+ not: GuardNode
25
+ }
26
+
27
+ export type GuardNode = LeafGuard | AllGuard | AnyGuard | NotGuard
28
+
29
+ export interface StateSpec {
30
+ id: string
31
+ terminal?: boolean
32
+ /** Actions fired automatically when the state is entered. They do not change state or variables; checks such as A4 Pair Symmetry treat them as implicit acquire/release events so lock/unlock hidden inside entry actions is verified. */
33
+ onEntry?: string[]
34
+ /** Actions fired automatically when the state is left. Same semantics as onEntry. */
35
+ onExit?: string[]
36
+ /** Deadline (A14): the state must be left within this many declared tick events of entering it. */
37
+ maxTicks?: number
38
+ }
39
+
40
+ export interface UpdateSpec {
41
+ variable: string
42
+ op: 'set' | 'inc' | 'dec'
43
+ value?: number
44
+ }
45
+
46
+ export interface TransitionSpec {
47
+ from: string
48
+ event: string
49
+ to: string
50
+ /** Absent guard is the else/default branch for the same (from, event) group. */
51
+ guard?: GuardNode
52
+ updates?: UpdateSpec[]
53
+ /** Execution cost of firing this transition (e.g. cycles, microseconds). Absent cost defaults to 1, so an unannotated machine keeps step-count semantics. Checked by A12 against budget invariants. */
54
+ cost?: number
55
+ /** Relative probability weight when a probability invariant is declared (DTMC interpretation). Absent weight = 1; weight 0 means the branch never fires probabilistically. */
56
+ weight?: number
57
+ }
58
+
59
+ export interface TransitionScenarioSpec {
60
+ from: string
61
+ event: string
62
+ /** Natural language: what this (state, event) combination represents in the real scenario. */
63
+ scenario: string
64
+ }
65
+
66
+ export interface ModelNarrative {
67
+ /** Natural-language meaning of each state id. */
68
+ states?: Record<string, string>
69
+ /** Natural-language meaning of each event id. */
70
+ events?: Record<string, string>
71
+ /** Natural-language scenario for each distinct (from, event) combination. */
72
+ scenarios?: TransitionScenarioSpec[]
73
+ }
74
+
75
+ export interface VariableSpec {
76
+ name: string
77
+ kind: 'integer' | 'boolean'
78
+ init: number | boolean
79
+ min?: number
80
+ max?: number
81
+ monotonic?: 'inc' | 'dec'
82
+ }
83
+
84
+ export type InvariantSpec =
85
+ | { id: string; description: string; kind: 'never-states'; states: string[] }
86
+ | { id: string; description: string; kind: 'var-in-range'; variable: string; min?: number; max?: number }
87
+ | { id: string; description: string; kind: 'event-before-state'; event: string; state: string }
88
+ | { id: string; description: string; kind: 'leads-to'; from: string; to: string }
89
+ | { id: string; description: string; kind: 'sequence'; events: string[] }
90
+ | { id: string; description: string; kind: 'atomicity'; events: string[]; commit: string; rollback?: string }
91
+ | { id: string; description: string; kind: 'budget'; budget: number }
92
+ | { id: string; description: string; kind: 'probability'; target: string; op: '>=' | '<=' | '>' | '<'; p: number }
93
+
94
+ export interface ResourcePairSpec {
95
+ resource: string
96
+ acquireEvent: string
97
+ releaseEvent: string
98
+ failEvent?: string
99
+ }
100
+
101
+ export interface BoundaryCheckSpec {
102
+ variable: string
103
+ values: number[]
104
+ }
105
+
106
+ export interface LogicModelV1 {
107
+ schemaVersion: 1
108
+ init: string
109
+ states: StateSpec[]
110
+ transitions: TransitionSpec[]
111
+ variables?: VariableSpec[]
112
+ invariants?: InvariantSpec[]
113
+ concurrentPairs?: [string, string][]
114
+ boundaryChecks?: BoundaryCheckSpec[]
115
+ resourcePairs?: ResourcePairSpec[]
116
+ idempotentEvents?: string[]
117
+ /** Events that advance the discrete clock by one tick; used by A14 deadline checks. */
118
+ tickEvents?: string[]
119
+ /** Natural-language descriptions of states, events, and (state, event) scenarios. */
120
+ narrative?: ModelNarrative
121
+ }
122
+
123
+ export interface VerificationOptions {
124
+ maxStates?: number
125
+ maxPermutationEvents?: number
126
+ beforeModel?: unknown
127
+ stateMapping?: Record<string, string>
128
+ }
129
+
130
+ export interface PathStep {
131
+ from: string
132
+ event: string
133
+ to: string
134
+ }
135
+
136
+ export interface Finding {
137
+ code: string
138
+ severity: 'error' | 'warning'
139
+ message: string
140
+ path?: PathStep[]
141
+ evidence?: Record<string, unknown>
142
+ }
143
+
144
+ export interface CheckResult {
145
+ id: string
146
+ name: string
147
+ status: 'pass' | 'fail' | 'skip'
148
+ detail: string
149
+ findings: Finding[]
150
+ }
151
+
152
+ export interface VerificationReport {
153
+ ok: boolean
154
+ schemaVersion: 1
155
+ modelHash: string
156
+ /** Echo of the model's natural-language narrative, when present. */
157
+ narrative?: ModelNarrative
158
+ summary: {
159
+ states: number
160
+ transitions: number
161
+ errors: number
162
+ warnings: number
163
+ checksRun: number
164
+ truncated?: boolean
165
+ }
166
+ checks: CheckResult[]
167
+ comparison?: ComparisonSummary
168
+ /** Informational notes about semantic dimensions this model references (timing, preemption)
169
+ * that this engine does not verify. Heuristic, vocabulary-based — never a substitute for the checks. */
170
+ coverageNotes?: string[]
171
+ }
172
+
173
+ export interface ComparisonSummary {
174
+ beforeModelHash: string
175
+ afterModelHash: string
176
+ stateMapping: Record<string, string>
177
+ beforeStates: number
178
+ beforeTransitions: number
179
+ afterStates: number
180
+ afterTransitions: number
181
+ addedStates: string[]
182
+ removedStates: string[]
183
+ addedEvents: string[]
184
+ removedEvents: string[]
185
+ addedTransitions: TransitionSpec[]
186
+ removedTransitions: TransitionSpec[]
187
+ }
188
+
189
+ export interface RuntimeState {
190
+ state: string
191
+ vars: Record<string, VarValue>
192
+ }
193
+
194
+ type NormalizedOptions = Required<Pick<VerificationOptions, 'maxStates' | 'maxPermutationEvents'>>
195
+
196
+ const DEFAULT_MAX_STATES = 10_000
197
+ const DEFAULT_MAX_PERMUTATION_EVENTS = 5
198
+
199
+ function stableStringify(value: unknown): string {
200
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
201
+ if (Array.isArray(value)) return '[' + value.map((item) => stableStringify(item)).join(',') + ']'
202
+ const record = value as Record<string, unknown>
203
+ return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}'
204
+ }
205
+
206
+ export function modelHash(model: LogicModelV1): string {
207
+ return createHash('sha256').update(stableStringify(model)).digest('hex')
208
+ }
209
+
210
+ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 } | { ok: false; errors: string[] } {
211
+ const errors: string[] = []
212
+ const bad = (path: string, message: string): void => {
213
+ errors.push(path + ': ' + message)
214
+ }
215
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
216
+ return { ok: false, errors: ['model: must be an object'] }
217
+ }
218
+ const root = input as Record<string, unknown>
219
+ if (root.schemaVersion !== 1) bad('schemaVersion', 'must be 1')
220
+ if (typeof root.init !== 'string' || root.init.length === 0) bad('init', 'must be a non-empty string')
221
+ if (!Array.isArray(root.states) || root.states.length === 0) {
222
+ bad('states', 'must be a non-empty array')
223
+ } else {
224
+ const seen = new Set<string>()
225
+ root.states.forEach((entry, index) => {
226
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
227
+ bad('states[' + index + ']', 'must be an object')
228
+ return
229
+ }
230
+ const state = entry as Record<string, unknown>
231
+ if (typeof state.id !== 'string' || state.id.length === 0) bad('states[' + index + '].id', 'must be a non-empty string')
232
+ else if (seen.has(state.id)) bad('states[' + index + '].id', 'duplicate state id ' + state.id)
233
+ else seen.add(state.id)
234
+ if (state.terminal !== undefined && typeof state.terminal !== 'boolean') bad('states[' + index + '].terminal', 'must be a boolean')
235
+ for (const kind of ['onEntry', 'onExit'] as const) {
236
+ const actions = state[kind]
237
+ if (actions !== undefined) {
238
+ if (!Array.isArray(actions)) bad('states[' + index + '].' + kind, 'must be an array of action names')
239
+ else if (actions.length > 64) bad('states[' + index + '].' + kind, 'must not exceed 64 actions')
240
+ else actions.forEach((action, actionIndex) => {
241
+ if (typeof action !== 'string' || action.length === 0) bad('states[' + index + '].' + kind + '[' + actionIndex + ']', 'must be a non-empty string')
242
+ })
243
+ }
244
+ }
245
+ })
246
+ if (typeof root.init === 'string' && root.init.length > 0 && !seen.has(root.init)) bad('init', 'must name a declared state')
247
+ }
248
+ if (!Array.isArray(root.transitions)) {
249
+ bad('transitions', 'must be an array')
250
+ } else {
251
+ const stateIds = new Set<string>(Array.isArray(root.states) ? (root.states as StateSpec[]).map((state) => state.id) : [])
252
+ root.transitions.forEach((entry, index) => {
253
+ const path = 'transitions[' + index + ']'
254
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
255
+ bad(path, 'must be an object')
256
+ return
257
+ }
258
+ const transition = entry as Record<string, unknown>
259
+ if (typeof transition.from !== 'string' || transition.from.length === 0) bad(path + '.from', 'must be a non-empty string')
260
+ else if (!stateIds.has(transition.from)) bad(path + '.from', 'unknown state ' + transition.from)
261
+ if (typeof transition.event !== 'string' || transition.event.length === 0) bad(path + '.event', 'must be a non-empty string')
262
+ if (typeof transition.to !== 'string' || transition.to.length === 0) bad(path + '.to', 'must be a non-empty string')
263
+ else if (!stateIds.has(transition.to)) bad(path + '.to', 'unknown state ' + transition.to)
264
+ if (transition.guard !== undefined) validateGuard(transition.guard, path + '.guard', errors, bad)
265
+ if (transition.updates !== undefined) {
266
+ if (!Array.isArray(transition.updates)) bad(path + '.updates', 'must be an array')
267
+ else transition.updates.forEach((update, updateIndex) => {
268
+ const updatePath = path + '.updates[' + updateIndex + ']'
269
+ if (typeof update !== 'object' || update === null || Array.isArray(update)) {
270
+ bad(updatePath, 'must be an object')
271
+ return
272
+ }
273
+ const record = update as Record<string, unknown>
274
+ if (typeof record.variable !== 'string' || record.variable.length === 0) bad(updatePath + '.variable', 'must be a non-empty string')
275
+ if (record.op !== 'set' && record.op !== 'inc' && record.op !== 'dec') bad(updatePath + '.op', "must be 'set', 'inc', or 'dec'")
276
+ if (record.value !== undefined && typeof record.value !== 'number') bad(updatePath + '.value', 'must be a number')
277
+ })
278
+ }
279
+ if (transition.cost !== undefined) {
280
+ if (typeof transition.cost !== 'number' || !Number.isFinite(transition.cost) || transition.cost < 0) bad(path + '.cost', 'must be a non-negative finite number')
281
+ }
282
+ if (transition.weight !== undefined) {
283
+ if (typeof transition.weight !== 'number' || !Number.isFinite(transition.weight) || transition.weight < 0) bad(path + '.weight', 'must be a non-negative finite number')
284
+ }
285
+ })
286
+ }
287
+ if (Array.isArray(root.states)) {
288
+ (root.states as StateSpec[]).forEach((state, index) => {
289
+ if (state.maxTicks !== undefined && (!Number.isInteger(state.maxTicks) || state.maxTicks < 0)) {
290
+ bad('states[' + index + '].maxTicks', 'must be a non-negative integer')
291
+ }
292
+ })
293
+ }
294
+ const variableNames = new Set<string>()
295
+ if (root.variables !== undefined) {
296
+ if (!Array.isArray(root.variables)) bad('variables', 'must be an array')
297
+ else root.variables.forEach((entry, index) => {
298
+ const path = 'variables[' + index + ']'
299
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
300
+ bad(path, 'must be an object')
301
+ return
302
+ }
303
+ const variable = entry as Record<string, unknown>
304
+ if (typeof variable.name !== 'string' || variable.name.length === 0) bad(path + '.name', 'must be a non-empty string')
305
+ else if (variableNames.has(variable.name)) bad(path + '.name', 'duplicate variable ' + variable.name)
306
+ else variableNames.add(variable.name)
307
+ if (variable.kind !== 'integer' && variable.kind !== 'boolean') bad(path + '.kind', "must be 'integer' or 'boolean'")
308
+ const expected = variable.kind === 'boolean' ? 'boolean' : 'number'
309
+ if (typeof variable.init !== expected) bad(path + '.init', 'must be a ' + expected)
310
+ if (variable.min !== undefined && typeof variable.min !== 'number') bad(path + '.min', 'must be a number')
311
+ if (variable.max !== undefined && typeof variable.max !== 'number') bad(path + '.max', 'must be a number')
312
+ if (typeof variable.min === 'number' && typeof variable.max === 'number' && variable.min > variable.max) bad(path + '.max', 'must be >= min')
313
+ if (variable.monotonic !== undefined && variable.monotonic !== 'inc' && variable.monotonic !== 'dec') bad(path + '.monotonic', "must be 'inc' or 'dec'")
314
+ if (variable.kind === 'integer' && typeof variable.init === 'number') {
315
+ if (typeof variable.min === 'number' && variable.init < variable.min) bad(path + '.init', 'must be >= min')
316
+ if (typeof variable.max === 'number' && variable.init > variable.max) bad(path + '.init', 'must be <= max')
317
+ }
318
+ })
319
+ }
320
+ const validateVariableRef = (name: unknown, path: string): void => {
321
+ if (typeof name !== 'string' || name.length === 0) bad(path, 'must be a non-empty string')
322
+ else if (!variableNames.has(name)) bad(path, 'references unknown variable ' + name)
323
+ }
324
+ if (root.invariants !== undefined) {
325
+ if (!Array.isArray(root.invariants)) bad('invariants', 'must be an array')
326
+ else root.invariants.forEach((entry, index) => {
327
+ const path = 'invariants[' + index + ']'
328
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
329
+ bad(path, 'must be an object')
330
+ return
331
+ }
332
+ const invariant = entry as Record<string, unknown>
333
+ if (typeof invariant.id !== 'string' || invariant.id.length === 0) bad(path + '.id', 'must be a non-empty string')
334
+ if (typeof invariant.description !== 'string') bad(path + '.description', 'must be a string')
335
+ if (invariant.kind === 'never-states') {
336
+ if (!Array.isArray(invariant.states) || invariant.states.length === 0) bad(path + '.states', 'must be a non-empty array')
337
+ else invariant.states.forEach((state, stateIndex) => {
338
+ if (typeof state !== 'string' || state.length === 0) bad(path + '.states[' + stateIndex + ']', 'must be a non-empty string')
339
+ })
340
+ } else if (invariant.kind === 'var-in-range') {
341
+ validateVariableRef(invariant.variable, path + '.variable')
342
+ if (invariant.min !== undefined && typeof invariant.min !== 'number') bad(path + '.min', 'must be a number')
343
+ if (invariant.max !== undefined && typeof invariant.max !== 'number') bad(path + '.max', 'must be a number')
344
+ } else if (invariant.kind === 'event-before-state') {
345
+ if (typeof invariant.event !== 'string' || invariant.event.length === 0) bad(path + '.event', 'must be a non-empty string')
346
+ if (typeof invariant.state !== 'string' || invariant.state.length === 0) bad(path + '.state', 'must be a non-empty string')
347
+ } else if (invariant.kind === 'leads-to') {
348
+ if (typeof invariant.from !== 'string' || invariant.from.length === 0) bad(path + '.from', 'must be a non-empty string')
349
+ if (typeof invariant.to !== 'string' || invariant.to.length === 0) bad(path + '.to', 'must be a non-empty string')
350
+ } else if (invariant.kind === 'sequence') {
351
+ if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
352
+ else invariant.events.forEach((event, eventIndex) => {
353
+ if (typeof event !== 'string' || event.length === 0) bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string')
354
+ })
355
+ } else if (invariant.kind === 'atomicity') {
356
+ if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
357
+ else invariant.events.forEach((event, eventIndex) => {
358
+ if (typeof event !== 'string' || event.length === 0) bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string')
359
+ })
360
+ if (typeof invariant.commit !== 'string' || invariant.commit.length === 0) bad(path + '.commit', 'must be a non-empty string')
361
+ if (invariant.rollback !== undefined && typeof invariant.rollback !== 'string') bad(path + '.rollback', 'must be a string')
362
+ } else if (invariant.kind === 'budget') {
363
+ if (typeof invariant.budget !== 'number' || !Number.isFinite(invariant.budget) || invariant.budget < 0) bad(path + '.budget', 'must be a non-negative finite number')
364
+ } else if (invariant.kind === 'probability') {
365
+ if (typeof invariant.target !== 'string' || invariant.target.length === 0) bad(path + '.target', 'must be a non-empty string')
366
+ if (invariant.op !== '>=' && invariant.op !== '<=' && invariant.op !== '>' && invariant.op !== '<') bad(path + '.op', "must be one of '>=', '<=', '>', '<'")
367
+ if (typeof invariant.p !== 'number' || !Number.isFinite(invariant.p) || invariant.p < 0 || invariant.p > 1) bad(path + '.p', 'must be a number in [0, 1]')
368
+ } else {
369
+ bad(path + '.kind', 'unknown invariant kind')
370
+ }
371
+ })
372
+ }
373
+ if (root.concurrentPairs !== undefined) {
374
+ if (!Array.isArray(root.concurrentPairs)) bad('concurrentPairs', 'must be an array')
375
+ else root.concurrentPairs.forEach((entry, index) => {
376
+ const path = 'concurrentPairs[' + index + ']'
377
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') {
378
+ bad(path, 'must be a [event, event] string pair')
379
+ }
380
+ })
381
+ }
382
+ if (root.boundaryChecks !== undefined) {
383
+ if (!Array.isArray(root.boundaryChecks)) bad('boundaryChecks', 'must be an array')
384
+ else root.boundaryChecks.forEach((entry, index) => {
385
+ const path = 'boundaryChecks[' + index + ']'
386
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
387
+ bad(path, 'must be an object')
388
+ return
389
+ }
390
+ const check = entry as Record<string, unknown>
391
+ validateVariableRef(check.variable, path + '.variable')
392
+ if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number')) bad(path + '.values', 'must be an array of numbers')
393
+ })
394
+ }
395
+ if (root.idempotentEvents !== undefined) {
396
+ if (!Array.isArray(root.idempotentEvents)) bad('idempotentEvents', 'must be an array')
397
+ else root.idempotentEvents.forEach((entry, index) => {
398
+ if (typeof entry !== 'string' || entry.length === 0) bad('idempotentEvents[' + index + ']', 'must be a non-empty string')
399
+ })
400
+ }
401
+ if (root.tickEvents !== undefined) {
402
+ if (!Array.isArray(root.tickEvents)) bad('tickEvents', 'must be an array')
403
+ else root.tickEvents.forEach((entry, index) => {
404
+ if (typeof entry !== 'string' || entry.length === 0) bad('tickEvents[' + index + ']', 'must be a non-empty string')
405
+ })
406
+ }
407
+ if (root.resourcePairs !== undefined) {
408
+ if (!Array.isArray(root.resourcePairs)) bad('resourcePairs', 'must be an array')
409
+ else root.resourcePairs.forEach((entry, index) => {
410
+ const path = 'resourcePairs[' + index + ']'
411
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
412
+ bad(path, 'must be an object')
413
+ return
414
+ }
415
+ const pair = entry as Record<string, unknown>
416
+ if (typeof pair.resource !== 'string' || pair.resource.length === 0) bad(path + '.resource', 'must be a non-empty string')
417
+ if (typeof pair.acquireEvent !== 'string' || pair.acquireEvent.length === 0) bad(path + '.acquireEvent', 'must be a non-empty string')
418
+ if (typeof pair.releaseEvent !== 'string' || pair.releaseEvent.length === 0) bad(path + '.releaseEvent', 'must be a non-empty string')
419
+ if (pair.failEvent !== undefined && typeof pair.failEvent !== 'string') bad(path + '.failEvent', 'must be a string')
420
+ })
421
+ }
422
+ if (root.narrative !== undefined) {
423
+ const narrativePath = 'narrative'
424
+ if (typeof root.narrative !== 'object' || root.narrative === null || Array.isArray(root.narrative)) {
425
+ bad(narrativePath, 'must be an object')
426
+ } else {
427
+ const narrative = root.narrative as Record<string, unknown>
428
+ const stateIds = new Set<string>(Array.isArray(root.states) ? (root.states as StateSpec[]).map((state) => state.id) : [])
429
+ const eventIds = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.event) : [])
430
+ const fromEventGroups = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.from + '|' + transition.event) : [])
431
+ if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
432
+ bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description')
433
+ } else {
434
+ for (const [id, description] of Object.entries(narrative.states as Record<string, unknown>)) {
435
+ if (!stateIds.has(id)) bad(narrativePath + '.states', 'references unknown state ' + id)
436
+ if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.states.' + id, 'must be a non-empty string')
437
+ }
438
+ for (const id of stateIds) {
439
+ if (typeof (narrative.states as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.states', 'missing description for state ' + id)
440
+ }
441
+ }
442
+ if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
443
+ bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description')
444
+ } else {
445
+ for (const [id, description] of Object.entries(narrative.events as Record<string, unknown>)) {
446
+ if (!eventIds.has(id)) bad(narrativePath + '.events', 'references unknown event ' + id)
447
+ if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.events.' + id, 'must be a non-empty string')
448
+ }
449
+ for (const id of eventIds) {
450
+ if (typeof (narrative.events as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.events', 'missing description for event ' + id)
451
+ }
452
+ }
453
+ if (!Array.isArray(narrative.scenarios)) {
454
+ bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }')
455
+ } else {
456
+ const seen = new Set<string>()
457
+ narrative.scenarios.forEach((entry, index) => {
458
+ const scenarioPath = narrativePath + '.scenarios[' + index + ']'
459
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
460
+ bad(scenarioPath, 'must be an object')
461
+ return
462
+ }
463
+ const scenario = entry as Record<string, unknown>
464
+ if (typeof scenario.from !== 'string' || scenario.from.length === 0) bad(scenarioPath + '.from', 'must be a non-empty string')
465
+ else if (!stateIds.has(scenario.from)) bad(scenarioPath + '.from', 'unknown state ' + scenario.from)
466
+ if (typeof scenario.event !== 'string' || scenario.event.length === 0) bad(scenarioPath + '.event', 'must be a non-empty string')
467
+ else if (!eventIds.has(scenario.event)) bad(scenarioPath + '.event', 'unknown event ' + scenario.event)
468
+ if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0) bad(scenarioPath + '.scenario', 'must be a non-empty string')
469
+ const key = String(scenario.from) + '|' + String(scenario.event)
470
+ if (seen.has(key)) bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')')
471
+ seen.add(key)
472
+ })
473
+ for (const key of fromEventGroups) {
474
+ if (!seen.has(key)) {
475
+ const sep = key.indexOf('|')
476
+ bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')')
477
+ }
478
+ }
479
+ }
480
+ }
481
+ }
482
+ // Guard/update variable references were validated structurally; re-walk for references.
483
+ if (Array.isArray(root.transitions)) {
484
+ for (const entry of root.transitions as TransitionSpec[]) {
485
+ walkGuardReferences(entry.guard, variableNames, errors, bad)
486
+ for (const update of entry.updates ?? []) {
487
+ if (!variableNames.has(update.variable)) bad('transitions.updates', 'references unknown variable ' + update.variable)
488
+ if (update.op !== 'set' && update.value !== undefined && update.value === 0) {
489
+ // zero delta is harmless; no finding
490
+ }
491
+ }
492
+ }
493
+ }
494
+ if (errors.length > 0) return { ok: false, errors }
495
+ return { ok: true, model: root as unknown as LogicModelV1 }
496
+ }
497
+
498
+ function validateGuard(input: unknown, path: string, errors: string[], bad: (path: string, message: string) => void): void {
499
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
500
+ bad(path, 'must be an object')
501
+ return
502
+ }
503
+ const guard = input as Record<string, unknown>
504
+ if (guard.variable !== undefined) {
505
+ if (typeof guard.variable !== 'string' || guard.variable.length === 0) bad(path + '.variable', 'must be a non-empty string')
506
+ if (guard.op !== '==' && guard.op !== '!=' && guard.op !== '<' && guard.op !== '<=' && guard.op !== '>' && guard.op !== '>=') bad(path + '.op', 'must be a comparison operator')
507
+ if (typeof guard.value !== 'number' && typeof guard.value !== 'boolean') bad(path + '.value', 'must be a number or boolean')
508
+ return
509
+ }
510
+ if (guard.all !== undefined) {
511
+ if (!Array.isArray(guard.all)) bad(path + '.all', 'must be an array')
512
+ else guard.all.forEach((child, index) => validateGuard(child, path + '.all[' + index + ']', errors, bad))
513
+ return
514
+ }
515
+ if (guard.any !== undefined) {
516
+ if (!Array.isArray(guard.any)) bad(path + '.any', 'must be an array')
517
+ else guard.any.forEach((child, index) => validateGuard(child, path + '.any[' + index + ']', errors, bad))
518
+ return
519
+ }
520
+ if (guard.not !== undefined) {
521
+ validateGuard(guard.not, path + '.not', errors, bad)
522
+ return
523
+ }
524
+ bad(path, 'must be a leaf ({ variable, op, value }), { all }, { any }, or { not }')
525
+ }
526
+
527
+ function walkGuardReferences(guard: GuardNode | undefined, variableNames: Set<string>, errors: string[], bad: (path: string, message: string) => void): void {
528
+ if (guard === undefined) return
529
+ if ('variable' in guard) {
530
+ if (!variableNames.has(guard.variable)) bad('guard', 'references unknown variable ' + guard.variable)
531
+ return
532
+ }
533
+ if ('all' in guard) {
534
+ for (const child of guard.all) walkGuardReferences(child, variableNames, errors, bad)
535
+ return
536
+ }
537
+ if ('any' in guard) {
538
+ for (const child of guard.any) walkGuardReferences(child, variableNames, errors, bad)
539
+ return
540
+ }
541
+ if ('not' in guard) walkGuardReferences(guard.not, variableNames, errors, bad)
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // execution
546
+ // ---------------------------------------------------------------------------
547
+
548
+ function isLeafGuard(guard: GuardNode): guard is LeafGuard {
549
+ return 'variable' in guard
550
+ }
551
+
552
+ export function guardVariables(guard: GuardNode | undefined): string[] {
553
+ if (guard === undefined) return []
554
+ if (isLeafGuard(guard)) return [guard.variable]
555
+ if ('all' in guard) return guard.all.flatMap((child) => guardVariables(child))
556
+ if ('any' in guard) return guard.any.flatMap((child) => guardVariables(child))
557
+ if ('not' in guard) return guardVariables(guard.not)
558
+ return []
559
+ }
560
+
561
+ function evalGuard(guard: GuardNode, vars: Record<string, VarValue>): boolean {
562
+ if (isLeafGuard(guard)) {
563
+ const actual = vars[guard.variable]
564
+ switch (guard.op) {
565
+ case '==': return actual === guard.value
566
+ case '!=': return actual !== guard.value
567
+ case '<': return (actual as number) < (guard.value as number)
568
+ case '<=': return (actual as number) <= (guard.value as number)
569
+ case '>': return (actual as number) > (guard.value as number)
570
+ case '>=': return (actual as number) >= (guard.value as number)
571
+ }
572
+ }
573
+ if ('all' in guard) return guard.all.every((child) => evalGuard(child, vars))
574
+ if ('any' in guard) return guard.any.some((child) => evalGuard(child, vars))
575
+ if ('not' in guard) return !evalGuard(guard.not, vars)
576
+ return false
577
+ }
578
+
579
+ function initialState(model: LogicModelV1): RuntimeState {
580
+ const vars: Record<string, VarValue> = {}
581
+ for (const variable of model.variables ?? []) vars[variable.name] = variable.init
582
+ return { state: model.init, vars }
583
+ }
584
+
585
+ function runtimeKey(runtime: RuntimeState): string {
586
+ return runtime.state + '|' + stableStringify(runtime.vars)
587
+ }
588
+
589
+ function applyUpdates(model: LogicModelV1, transition: TransitionSpec, runtime: RuntimeState): RuntimeState {
590
+ const vars = { ...runtime.vars }
591
+ for (const update of transition.updates ?? []) {
592
+ const current = vars[update.variable]
593
+ if (update.op === 'set') vars[update.variable] = update.value ?? 0
594
+ else if (update.op === 'inc') vars[update.variable] = ((typeof current === 'number' ? current : 0) + (update.value ?? 1))
595
+ else vars[update.variable] = ((typeof current === 'number' ? current : 0) - (update.value ?? 1))
596
+ }
597
+ return { state: transition.to, vars }
598
+ }
599
+
600
+ function groupTransitions(model: LogicModelV1): Map<string, TransitionSpec[]> {
601
+ const groups = new Map<string, TransitionSpec[]>()
602
+ for (const transition of model.transitions) {
603
+ const key = transition.from + '|' + transition.event
604
+ const list = groups.get(key) ?? []
605
+ list.push(transition)
606
+ groups.set(key, list)
607
+ }
608
+ return groups
609
+ }
610
+
611
+ function applicableTransitions(group: TransitionSpec[], runtime: RuntimeState): TransitionSpec[] {
612
+ const guarded: TransitionSpec[] = []
613
+ const unguarded: TransitionSpec[] = []
614
+ for (const transition of group) {
615
+ if (transition.guard === undefined) unguarded.push(transition)
616
+ else guarded.push(transition)
617
+ }
618
+ const matched = guarded.filter((transition) => evalGuard(transition.guard!, runtime.vars))
619
+ if (matched.length > 0) return matched
620
+ return unguarded
621
+ }
622
+
623
+ function stepRuntime(model: LogicModelV1, runtime: RuntimeState, event: string): RuntimeState[] {
624
+ const group = groupTransitions(model).get(runtime.state + '|' + event)
625
+ if (group === undefined || group.length === 0) return []
626
+ const seen = new Set<string>()
627
+ const outcomes: RuntimeState[] = []
628
+ for (const transition of applicableTransitions(group, runtime)) {
629
+ const next = applyUpdates(model, transition, runtime)
630
+ const key = runtimeKey(next)
631
+ if (!seen.has(key)) {
632
+ seen.add(key)
633
+ outcomes.push(next)
634
+ }
635
+ }
636
+ return outcomes
637
+ }
638
+
639
+ interface Exploration {
640
+ reachable: RuntimeState[]
641
+ reachableKeys: Set<string>
642
+ truncated: boolean
643
+ initialState: RuntimeState
644
+ }
645
+
646
+ function explore(model: LogicModelV1, options: NormalizedOptions): Exploration {
647
+ const init = initialState(model)
648
+ const reachable: RuntimeState[] = []
649
+ const reachableKeys = new Set<string>()
650
+ const queue: RuntimeState[] = [init]
651
+ let truncated = false
652
+ while (queue.length > 0) {
653
+ const runtime = queue.shift()!
654
+ const key = runtimeKey(runtime)
655
+ if (reachableKeys.has(key)) continue
656
+ reachableKeys.add(key)
657
+ reachable.push(runtime)
658
+ if (reachable.length > options.maxStates) {
659
+ truncated = true
660
+ break
661
+ }
662
+ for (const event of allEvents(model)) {
663
+ for (const next of stepRuntime(model, runtime, event)) {
664
+ if (!reachableKeys.has(runtimeKey(next))) queue.push(next)
665
+ }
666
+ }
667
+ }
668
+ return { reachable, reachableKeys, truncated, initialState: init }
669
+ }
670
+
671
+ function allEvents(model: LogicModelV1): string[] {
672
+ return [...new Set(model.transitions.map((transition) => transition.event))].sort()
673
+ }
674
+
675
+ function stateById(model: LogicModelV1, id: string): StateSpec | undefined {
676
+ return model.states.find((state) => state.id === id)
677
+ }
678
+
679
+ function isTerminal(model: LogicModelV1, state: string): boolean {
680
+ return stateById(model, state)?.terminal === true
681
+ }
682
+
683
+ function uniqueTargets(transitions: TransitionSpec[]): string[] {
684
+ return [...new Set(transitions.map((transition) => transition.to))]
685
+ }
686
+
687
+ function checkResult(id: string, name: string, findings: Finding[], detail: string): CheckResult {
688
+ const errors = findings.filter((finding) => finding.severity === 'error').length
689
+ const warnings = findings.filter((finding) => finding.severity === 'warning').length
690
+ return {
691
+ id,
692
+ name,
693
+ status: findings.length === 0 ? 'pass' : 'fail',
694
+ detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
695
+ findings,
696
+ }
697
+ }
698
+
699
+ // ---------------------------------------------------------------------------
700
+ // S1-S7
701
+ // ---------------------------------------------------------------------------
702
+
703
+ function S1_reachability(model: LogicModelV1, exploration: Exploration): CheckResult {
704
+ const reached = new Set(exploration.reachable.map((runtime) => runtime.state))
705
+ const unreachable = model.states.filter((state) => !reached.has(state.id)).map((state) => state.id)
706
+ const findings: Finding[] = unreachable.map((state) => ({
707
+ code: 'S1_UNREACHABLE_STATE',
708
+ severity: 'warning',
709
+ message: 'State ' + state + ' is not reachable from init.',
710
+ }))
711
+ return checkResult('S1', 'Reachability', findings, unreachable.length === 0 ? 'All states reachable' : 'Unreachable states: ' + unreachable.join(', '))
712
+ }
713
+
714
+ function S2_deadlock(model: LogicModelV1): CheckResult {
715
+ const outgoing = new Set(model.transitions.map((transition) => transition.from))
716
+ const findings: Finding[] = []
717
+ for (const state of model.states) {
718
+ if (state.terminal === true) continue
719
+ if (!outgoing.has(state.id)) {
720
+ findings.push({
721
+ code: 'S2_NO_TRANSITIONS',
722
+ severity: 'error',
723
+ message: 'Non-terminal state ' + state.id + ' has no outgoing transitions.',
724
+ })
725
+ }
726
+ }
727
+ return checkResult('S2', 'Deadlock', findings, findings.length === 0 ? 'No deadlocks' : 'Deadlocks: ' + findings.map((finding) => finding.message).join('; '))
728
+ }
729
+
730
+ function sccs(model: LogicModelV1): string[][] {
731
+ const states = model.states.map((state) => state.id)
732
+ const edges = new Map<string, string[]>()
733
+ for (const state of states) edges.set(state, [])
734
+ for (const transition of model.transitions) {
735
+ const list = edges.get(transition.from)
736
+ if (list !== undefined) list.push(transition.to)
737
+ }
738
+ let index = 0
739
+ const indices = new Map<string, number>()
740
+ const low = new Map<string, number>()
741
+ const onStack = new Set<string>()
742
+ const stack: string[] = []
743
+ const components: string[][] = []
744
+ const visit = (node: string): void => {
745
+ indices.set(node, index)
746
+ low.set(node, index)
747
+ index += 1
748
+ stack.push(node)
749
+ onStack.add(node)
750
+ for (const next of edges.get(node) ?? []) {
751
+ if (!indices.has(next)) {
752
+ visit(next)
753
+ low.set(node, Math.min(low.get(node)!, low.get(next)!))
754
+ } else if (onStack.has(next)) {
755
+ low.set(node, Math.min(low.get(node)!, indices.get(next)!))
756
+ }
757
+ }
758
+ if (low.get(node) === indices.get(node)) {
759
+ const component: string[] = []
760
+ while (stack.length > 0) {
761
+ const member = stack.pop()!
762
+ onStack.delete(member)
763
+ component.push(member)
764
+ if (member === node) break
765
+ }
766
+ components.push(component)
767
+ }
768
+ }
769
+ for (const state of states) {
770
+ if (!indices.has(state)) visit(state)
771
+ }
772
+ return components
773
+ }
774
+
775
+ function S3_liveness(model: LogicModelV1): CheckResult {
776
+ const components = sccs(model)
777
+ const findings: Finding[] = []
778
+ for (const component of components) {
779
+ const componentSet = new Set(component)
780
+ const internalEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && componentSet.has(transition.to))
781
+ const escapingEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to))
782
+ if (internalEdges.length === 0 || escapingEdges.length > 0) continue
783
+ if (component.some((state) => isTerminal(model, state))) continue
784
+ findings.push({
785
+ code: 'S3_CLOSED_SCC',
786
+ severity: 'error',
787
+ message: 'Closed SCC has no exit and contains no terminal state: ' + component.sort().join(', '),
788
+ evidence: { states: component.sort(), transitions: internalEdges.length },
789
+ })
790
+ }
791
+ return checkResult('S3', 'Liveness', findings, findings.length === 0 ? 'No harmful closed SCCs' : 'Closed SCCs: ' + findings.length)
792
+ }
793
+
794
+ function assignmentsFor(model: LogicModelV1, variables: string[]): Array<Record<string, VarValue>> | null {
795
+ const specs = (model.variables ?? []).filter((variable) => variables.includes(variable.name))
796
+ let combinations: Array<Record<string, VarValue>> = [{}]
797
+ for (const spec of specs) {
798
+ let values: VarValue[]
799
+ if (spec.kind === 'boolean') values = [false, true]
800
+ else {
801
+ const constants = collectGuardConstants(model).filter((value) => guardLeaves(model).some((leaf) => leaf.variable === spec.name && typeof leaf.value === 'number' && leaf.value === value))
802
+ if (spec.min !== undefined && spec.max !== undefined) {
803
+ values = []
804
+ for (let value = spec.min; value <= spec.max && value <= spec.min + 200; value++) values.push(value)
805
+ } else {
806
+ const set = new Set<number>([-1, 0, 1, ...constants])
807
+ values = [...set].sort((a, b) => a - b)
808
+ }
809
+ }
810
+ const next: Array<Record<string, VarValue>> = []
811
+ for (const assignment of combinations) {
812
+ for (const value of values) {
813
+ next.push({ ...assignment, [spec.name]: value })
814
+ if (next.length > 10_000) return null
815
+ }
816
+ }
817
+ combinations = next
818
+ }
819
+ return combinations
820
+ }
821
+
822
+ function guardLeaves(model: LogicModelV1): LeafGuard[] {
823
+ const leaves: LeafGuard[] = []
824
+ const visit = (guard: GuardNode): void => {
825
+ if ('variable' in guard) leaves.push(guard)
826
+ else if ('all' in guard) guard.all.forEach(visit)
827
+ else if ('any' in guard) guard.any.forEach(visit)
828
+ else if ('not' in guard) visit(guard.not)
829
+ }
830
+ for (const transition of model.transitions) if (transition.guard !== undefined) visit(transition.guard)
831
+ return leaves
832
+ }
833
+
834
+ function collectGuardConstants(model: LogicModelV1): number[] {
835
+ return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value as number)
836
+ }
837
+
838
+ interface GuardAnalysis {
839
+ determinism: Finding[]
840
+ completeness: Finding[]
841
+ truncated: boolean
842
+ }
843
+
844
+ function analyzeGuards(model: LogicModelV1): GuardAnalysis {
845
+ const determinism: Finding[] = []
846
+ const completeness: Finding[] = []
847
+ let truncated = false
848
+ const groups = groupTransitions(model)
849
+ for (const [key, group] of groups) {
850
+ const [from, event] = key.split('|')
851
+ const unguarded = group.filter((transition) => transition.guard === undefined)
852
+ const guarded = group.filter((transition) => transition.guard !== undefined)
853
+ if (unguarded.length > 1) {
854
+ determinism.push({
855
+ code: 'S4_AMBIGUOUS_DEFAULT',
856
+ severity: 'error',
857
+ message: from + ' + ' + event + ' has multiple unconditional transitions: ' + uniqueTargets(unguarded).join(', '),
858
+ })
859
+ }
860
+ if (guarded.length === 0) continue
861
+ const variables = [...new Set(group.flatMap((transition) => guardVariables(transition.guard)))]
862
+ const assignments = assignmentsFor(model, variables)
863
+ if (assignments === null) {
864
+ completeness.push({ code: 'S6_UNBOUNDED_DOMAIN', severity: 'warning', message: from + ' + ' + event + ': guard domain too large to enumerate; exhaustive check skipped.' })
865
+ truncated = true
866
+ continue
867
+ }
868
+ const targets = uniqueTargets(group)
869
+ for (const assignment of assignments) {
870
+ const trueBranches = guarded.filter((transition) => evalGuard(transition.guard!, assignment))
871
+ if (trueBranches.length > 1) {
872
+ determinism.push({
873
+ code: 'S4_NONDETERMINISTIC_GUARDS',
874
+ severity: 'error',
875
+ message: from + ' + ' + event + ' has ' + trueBranches.length + ' simultaneously true guards for assignment ' + stableStringify(assignment) + ': ' + uniqueTargets(trueBranches).join(', '),
876
+ evidence: { assignment },
877
+ })
878
+ }
879
+ if (trueBranches.length === 0 && unguarded.length === 0) {
880
+ completeness.push({
881
+ code: 'S6_INCOMPLETE_GUARD',
882
+ severity: 'error',
883
+ message: from + ' + ' + event + ' has no true guard and no default branch for assignment ' + stableStringify(assignment),
884
+ evidence: { assignment, branches: targets },
885
+ })
886
+ }
887
+ if (determinism.length + completeness.length > 200) {
888
+ truncated = true
889
+ break
890
+ }
891
+ }
892
+ if (truncated) break
893
+ }
894
+ return { determinism, completeness, truncated }
895
+ }
896
+
897
+ function S4_determinism(model: LogicModelV1): CheckResult {
898
+ const analysis = analyzeGuards(model)
899
+ return checkResult('S4', 'Determinism', analysis.determinism, analysis.determinism.length === 0 ? 'Deterministic' : 'Nondeterminism findings: ' + analysis.determinism.length)
900
+ }
901
+
902
+ function S6_guardCompleteness(model: LogicModelV1): CheckResult {
903
+ const analysis = analyzeGuards(model)
904
+ return checkResult('S6', 'Guard Completeness', analysis.completeness, analysis.completeness.length === 0 ? 'All guard branches defined' : 'Guard findings: ' + analysis.completeness.length)
905
+ }
906
+
907
+ function S5_eventCompleteness(model: LogicModelV1): CheckResult {
908
+ const events = allEvents(model)
909
+ const findings: Finding[] = []
910
+ for (const state of model.states) {
911
+ if (state.terminal === true) continue
912
+ const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event))
913
+ for (const event of events) {
914
+ if (!handled.has(event)) {
915
+ findings.push({
916
+ code: 'S5_UNHANDLED_EVENT',
917
+ severity: 'warning',
918
+ message: state.id + ' silently ignores event ' + event,
919
+ })
920
+ }
921
+ }
922
+ }
923
+ return checkResult('S5', 'Event Completeness', findings, findings.length === 0 ? 'All states handle all relevant events' : findings.length + ' unhandled (state, event) pairs')
924
+ }
925
+
926
+ interface InvariantViolation {
927
+ invariant: InvariantSpec
928
+ path: PathStep[]
929
+ reason: string
930
+ }
931
+
932
+ function invariantHolds(invariant: InvariantSpec, runtime: RuntimeState): boolean {
933
+ if (invariant.kind === 'never-states') return !invariant.states.includes(runtime.state)
934
+ if (invariant.kind === 'var-in-range') {
935
+ const value = runtime.vars[invariant.variable]
936
+ if (typeof value !== 'number') return false
937
+ if (invariant.min !== undefined && value < invariant.min) return false
938
+ if (invariant.max !== undefined && value > invariant.max) return false
939
+ return true
940
+ }
941
+ return true
942
+ }
943
+
944
+ function shortestViolationForInvariant(model: LogicModelV1, options: NormalizedOptions, invariant: InvariantSpec): InvariantViolation | undefined {
945
+ if (invariant.kind === 'event-before-state') {
946
+ return shortestEventBeforeStateViolation(model, options, invariant)
947
+ }
948
+ const init = initialState(model)
949
+ if (!invariantHolds(invariant, init)) {
950
+ return { invariant, path: [], reason: 'Initial state violates the invariant.' }
951
+ }
952
+ const visited = new Set<string>([runtimeKey(init)])
953
+ const queue: Array<{ runtime: RuntimeState; path: PathStep[] }> = [{ runtime: init, path: [] }]
954
+ let steps = 0
955
+ while (queue.length > 0) {
956
+ const entry = queue.shift()!
957
+ if (++steps > options.maxStates) break
958
+ for (const event of allEvents(model)) {
959
+ for (const next of stepRuntime(model, entry.runtime, event)) {
960
+ const key = runtimeKey(next)
961
+ if (visited.has(key)) continue
962
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
963
+ if (!invariantHolds(invariant, next)) {
964
+ return { invariant, path, reason: 'Invariant violated after ' + path.length + ' events.' }
965
+ }
966
+ visited.add(key)
967
+ queue.push({ runtime: next, path })
968
+ }
969
+ }
970
+ }
971
+ return undefined
972
+ }
973
+
974
+ function shortestEventBeforeStateViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'event-before-state' }>): InvariantViolation | undefined {
975
+ const init = initialState(model)
976
+ const seenInitially = false
977
+ if (init.state === invariant.state && !seenInitially) {
978
+ return { invariant, path: [], reason: 'Target state ' + invariant.state + ' is initial and the required event ' + invariant.event + ' has not occurred.' }
979
+ }
980
+ const key = (runtime: RuntimeState, seen: boolean): string => runtimeKey(runtime) + '|' + (seen ? '1' : '0')
981
+ const visited = new Set<string>([key(init, false)])
982
+ const queue: Array<{ runtime: RuntimeState; seen: boolean; path: PathStep[] }> = [{ runtime: init, seen: false, path: [] }]
983
+ let steps = 0
984
+ while (queue.length > 0) {
985
+ const entry = queue.shift()!
986
+ if (++steps > options.maxStates) break
987
+ for (const event of allEvents(model)) {
988
+ for (const next of stepRuntime(model, entry.runtime, event)) {
989
+ const seen = entry.seen || event === invariant.event
990
+ const nextKey = key(next, seen)
991
+ if (visited.has(nextKey)) continue
992
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
993
+ if (next.state === invariant.state && !seen) {
994
+ return { invariant, path, reason: 'Target state ' + invariant.state + ' is reachable without event ' + invariant.event + ' in ' + path.length + ' steps.' }
995
+ }
996
+ visited.add(nextKey)
997
+ queue.push({ runtime: next, seen, path })
998
+ }
999
+ }
1000
+ }
1001
+ return undefined
1002
+ }
1003
+
1004
+ function runInvariants(model: LogicModelV1, options: NormalizedOptions): InvariantViolation[] {
1005
+ const violations: InvariantViolation[] = []
1006
+ for (const invariant of model.invariants ?? []) {
1007
+ const violation = shortestViolationForInvariant(model, options, invariant)
1008
+ if (violation !== undefined) violations.push(violation)
1009
+ }
1010
+ return violations
1011
+ }
1012
+
1013
+ function S7_invariants(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1014
+ const violations = runInvariants(model, options)
1015
+ const findings: Finding[] = violations.map((violation) => ({
1016
+ code: 'S7_INVARIANT_VIOLATION',
1017
+ severity: 'error',
1018
+ message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
1019
+ path: violation.path,
1020
+ evidence: { invariant: violation.invariant },
1021
+ }))
1022
+ return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length)
1023
+ }
1024
+
1025
+ // ---------------------------------------------------------------------------
1026
+ // A1-A7
1027
+ // ---------------------------------------------------------------------------
1028
+
1029
+ function A1_unexpectedEvents(model: LogicModelV1): CheckResult {
1030
+ const events = allEvents(model)
1031
+ const findings: Finding[] = []
1032
+ for (const state of model.states) {
1033
+ if (state.terminal === true) continue
1034
+ const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event))
1035
+ for (const event of events) {
1036
+ if (!handled.has(event)) {
1037
+ findings.push({
1038
+ code: 'A1_UNHANDLED_EVENT',
1039
+ severity: 'warning',
1040
+ message: 'Event ' + event + ' in state ' + state.id + ' has no defined transition (silent ignore).',
1041
+ })
1042
+ }
1043
+ }
1044
+ }
1045
+ return checkResult('A1', 'Unexpected Event Injection', findings, findings.length === 0 ? 'All event/state combinations defined' : findings.length + ' unhandled combinations')
1046
+ }
1047
+
1048
+ function firstOutcome(runtime: RuntimeState, outcomes: RuntimeState[]): RuntimeState | undefined {
1049
+ return outcomes[0]
1050
+ }
1051
+
1052
+ function A2_raceInterleaving(model: LogicModelV1, exploration: Exploration): CheckResult {
1053
+ const findings: Finding[] = []
1054
+ for (const [e1, e2] of model.concurrentPairs ?? []) {
1055
+ for (const runtime of exploration.reachable) {
1056
+ const first = firstOutcome(runtime, stepRuntime(model, runtime, e1))
1057
+ const second = firstOutcome(runtime, stepRuntime(model, runtime, e2))
1058
+ if (first === undefined && second === undefined) continue
1059
+ const final12 = first === undefined ? runtime : firstOutcome(first, stepRuntime(model, first, e2)) ?? first
1060
+ const final21 = second === undefined ? runtime : firstOutcome(second, stepRuntime(model, second, e1)) ?? second
1061
+ if (runtimeKey(final12) !== runtimeKey(final21)) {
1062
+ findings.push({
1063
+ code: 'A2_ORDER_DEPENDENT',
1064
+ severity: 'warning',
1065
+ message: 'Events ' + e1 + ' and ' + e2 + ' produce order-dependent outcomes from state ' + runtime.state,
1066
+ evidence: { e1ThenE2: final12, e2ThenE1: final21 },
1067
+ })
1068
+ }
1069
+ }
1070
+ }
1071
+ return checkResult('A2', 'Race Interleaving', findings, findings.length === 0 ? 'No race conditions detected' : 'Order-dependent outcomes: ' + findings.length)
1072
+ }
1073
+
1074
+ function stepSequence(model: LogicModelV1, init: RuntimeState, events: string[]): RuntimeState {
1075
+ let runtime = init
1076
+ for (const event of events) {
1077
+ const next = firstOutcome(runtime, stepRuntime(model, runtime, event))
1078
+ if (next === undefined) continue
1079
+ runtime = next
1080
+ }
1081
+ return runtime
1082
+ }
1083
+
1084
+ function permutations<T>(items: T[]): T[][] {
1085
+ if (items.length === 0) return [[]]
1086
+ const result: T[][] = []
1087
+ for (let index = 0; index < items.length; index++) {
1088
+ const rest = items.slice(0, index).concat(items.slice(index + 1))
1089
+ for (const suffix of permutations(rest)) result.push([items[index], ...suffix])
1090
+ }
1091
+ return result
1092
+ }
1093
+
1094
+ function A3_orderPermutation(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1095
+ const events = allEvents(model).slice(0, options.maxPermutationEvents)
1096
+ if (events.length < 2) return checkResult('A3', 'Order Permutation', [], 'Fewer than 2 events — skipped')
1097
+ const init = initialState(model)
1098
+ const outcomes = new Map<string, string[]>()
1099
+ for (const permutation of permutations(events)) {
1100
+ const final = stepSequence(model, init, permutation)
1101
+ const key = runtimeKey(final)
1102
+ const list = outcomes.get(key) ?? []
1103
+ if (list.length < 3) list.push(permutation.join(','))
1104
+ outcomes.set(key, list)
1105
+ }
1106
+ if (outcomes.size > 1) {
1107
+ const examples = [...outcomes.entries()].map(([key, sequences]) => ({ final: key.split('|')[0], example: sequences[0] }))
1108
+ return checkResult('A3', 'Order Permutation', [{
1109
+ code: 'A3_ORDER_DEPENDENT',
1110
+ severity: 'warning',
1111
+ message: 'Same event set produces ' + outcomes.size + ' different outcomes depending on order.',
1112
+ evidence: { examples },
1113
+ }], 'Order-dependent outcomes: ' + outcomes.size)
1114
+ }
1115
+ return checkResult('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + events.length + ' events)')
1116
+ }
1117
+
1118
+ function stateActionList(model: LogicModelV1, stateId: string, kind: 'onEntry' | 'onExit'): string[] {
1119
+ const state = model.states.find((entry) => entry.id === stateId)
1120
+ const list = state === undefined ? undefined : state[kind]
1121
+ return list ?? []
1122
+ }
1123
+
1124
+ function actionsDeclared(model: LogicModelV1): boolean {
1125
+ return model.states.some((state) => (state.onEntry ?? []).length > 0 || (state.onExit ?? []).length > 0)
1126
+ }
1127
+
1128
+ /** Apply an ordered action list to the held flag. Actions never change state or variables; they only matter to pairing checks. */
1129
+ function applyActionList(list: string[], acquireEvent: string, releaseEvent: string, held: boolean): { held: boolean; reacquired: boolean } {
1130
+ let next = held
1131
+ let reacquired = false
1132
+ for (const action of list) {
1133
+ if (action === acquireEvent) {
1134
+ if (next) reacquired = true
1135
+ next = true
1136
+ } else if (action === releaseEvent) {
1137
+ next = false
1138
+ }
1139
+ }
1140
+ return { held: next, reacquired }
1141
+ }
1142
+
1143
+ function A4_pairSymmetry(model: LogicModelV1): CheckResult {
1144
+ const findings: Finding[] = []
1145
+ const hasActions = actionsDeclared(model)
1146
+ const transitionEvents = new Set(model.transitions.map((transition) => transition.event))
1147
+ const actionEvents = new Set<string>()
1148
+ if (hasActions) {
1149
+ for (const state of model.states) {
1150
+ for (const action of stateActionList(model, state.id, 'onEntry')) actionEvents.add(action)
1151
+ for (const action of stateActionList(model, state.id, 'onExit')) actionEvents.add(action)
1152
+ }
1153
+ }
1154
+ const pairHasEvent = (event: string): boolean => transitionEvents.has(event) || actionEvents.has(event)
1155
+ const releaseReachableFrom = (stateId: string, releaseEvent: string): boolean => {
1156
+ const reachable = stateGraphReachable(model, stateId)
1157
+ return [...reachable].some((state) =>
1158
+ model.transitions.some((transition) => transition.from === state && transition.event === releaseEvent)
1159
+ || (hasActions && (stateActionList(model, state, 'onEntry').includes(releaseEvent) || stateActionList(model, state, 'onExit').includes(releaseEvent))))
1160
+ }
1161
+ const edges = stateGraphEdges(model)
1162
+ for (const pair of model.resourcePairs ?? []) {
1163
+ const acquireTransitions = model.transitions.filter((transition) => transition.event === pair.acquireEvent)
1164
+ const actionAcquire = hasActions && model.states.some((state) =>
1165
+ stateActionList(model, state.id, 'onEntry').includes(pair.acquireEvent) || stateActionList(model, state.id, 'onExit').includes(pair.acquireEvent))
1166
+ const releaseExists = pairHasEvent(pair.releaseEvent)
1167
+ if (acquireTransitions.length === 0 && !actionAcquire && !releaseExists) continue
1168
+ if ((acquireTransitions.length > 0 || actionAcquire) && !releaseExists) {
1169
+ findings.push({
1170
+ code: 'A4_NO_RELEASE_EVENT',
1171
+ severity: 'error',
1172
+ message: 'Resource "' + pair.resource + '": acquire event ' + pair.acquireEvent + ' exists but release event ' + pair.releaseEvent + ' is never defined.',
1173
+ })
1174
+ continue
1175
+ }
1176
+ // Collect acquire sites: transitions whose event acquires, and state entry/exit actions that end holding the resource.
1177
+ const seeds: Array<{ state: string; held: boolean; path: PathStep[] }> = []
1178
+ for (const acquire of acquireTransitions) {
1179
+ if (!releaseReachableFrom(acquire.to, pair.releaseEvent)) {
1180
+ findings.push({
1181
+ code: 'A4_NO_RELEASE_REACHABLE',
1182
+ severity: 'error',
1183
+ message: 'Resource "' + pair.resource + '": after ' + pair.acquireEvent + ' into ' + acquire.to + ', no ' + pair.releaseEvent + ' is reachable.',
1184
+ evidence: { acquireTransition: acquire },
1185
+ })
1186
+ continue
1187
+ }
1188
+ seeds.push({ state: acquire.to, held: true, path: [] })
1189
+ }
1190
+ if (hasActions) {
1191
+ for (const state of model.states) {
1192
+ // acquire fired by onEntry: resource is held for the whole stay in the state
1193
+ const entry = stateActionList(model, state.id, 'onEntry')
1194
+ if (entry.includes(pair.acquireEvent)) {
1195
+ const sim = applyActionList(entry, pair.acquireEvent, pair.releaseEvent, false)
1196
+ if (sim.reacquired) {
1197
+ findings.push({
1198
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1199
+ severity: 'warning',
1200
+ message: 'Resource "' + pair.resource + '" is acquired more than once inside onEntry of ' + state.id + ' before ' + pair.releaseEvent + '.',
1201
+ })
1202
+ }
1203
+ if (sim.held) seeds.push({ state: state.id, held: true, path: [] })
1204
+ }
1205
+ // acquire fired by onExit: happens on every outgoing edge, never on a terminal state
1206
+ const exit = stateActionList(model, state.id, 'onExit')
1207
+ if (exit.includes(pair.acquireEvent) && !state.terminal) {
1208
+ const sim = applyActionList(exit, pair.acquireEvent, pair.releaseEvent, false)
1209
+ if (sim.reacquired) {
1210
+ findings.push({
1211
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1212
+ severity: 'warning',
1213
+ message: 'Resource "' + pair.resource + '" is acquired more than once inside onExit of ' + state.id + ' before ' + pair.releaseEvent + '.',
1214
+ })
1215
+ }
1216
+ if (sim.held) seeds.push({ state: state.id, held: false, path: [] })
1217
+ }
1218
+ }
1219
+ }
1220
+ // Path-sensitive probe: track the held flag along state-graph paths, firing
1221
+ // onExit of the source state before each transition and onEntry of the target after it.
1222
+ for (const seed of seeds) {
1223
+ const visited = new Set<string>()
1224
+ const queue: Array<{ state: string; held: boolean; path: PathStep[] }> = [seed]
1225
+ let steps = 0
1226
+ while (queue.length > 0) {
1227
+ const entry = queue.shift()!
1228
+ if (++steps > 1000) break
1229
+ const key = entry.state + '|' + (entry.held ? '1' : '0')
1230
+ if (visited.has(key)) continue
1231
+ visited.add(key)
1232
+ if (entry.held && isTerminal(model, entry.state)) {
1233
+ findings.push({
1234
+ code: 'A4_TERMINAL_WITH_RESOURCE',
1235
+ severity: 'error',
1236
+ message: 'Resource "' + pair.resource + '" is still held when entering terminal state ' + entry.state + '.',
1237
+ path: entry.path,
1238
+ })
1239
+ continue
1240
+ }
1241
+ for (const transition of edges.get(entry.state) ?? []) {
1242
+ let held = entry.held
1243
+ if (hasActions) {
1244
+ const sim = applyActionList(stateActionList(model, entry.state, 'onExit'), pair.acquireEvent, pair.releaseEvent, held)
1245
+ if (sim.reacquired) {
1246
+ findings.push({
1247
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1248
+ severity: 'warning',
1249
+ message: 'Resource "' + pair.resource + '" is acquired again in onExit of ' + entry.state + ' before ' + pair.releaseEvent + '.',
1250
+ path: entry.path,
1251
+ })
1252
+ continue
1253
+ }
1254
+ held = sim.held
1255
+ }
1256
+ if (transition.event === pair.releaseEvent) held = false
1257
+ else if (transition.event === pair.acquireEvent) {
1258
+ if (held) {
1259
+ findings.push({
1260
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1261
+ severity: 'warning',
1262
+ message: 'Resource "' + pair.resource + '" is acquired again in state ' + entry.state + ' before ' + pair.releaseEvent + '.',
1263
+ path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
1264
+ })
1265
+ continue
1266
+ }
1267
+ held = true
1268
+ }
1269
+ if (hasActions) {
1270
+ const sim = applyActionList(stateActionList(model, transition.to, 'onEntry'), pair.acquireEvent, pair.releaseEvent, held)
1271
+ if (sim.reacquired) {
1272
+ findings.push({
1273
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1274
+ severity: 'warning',
1275
+ message: 'Resource "' + pair.resource + '" is acquired again inside onEntry of ' + transition.to + ' before ' + pair.releaseEvent + '.',
1276
+ path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
1277
+ })
1278
+ continue
1279
+ }
1280
+ held = sim.held
1281
+ }
1282
+ queue.push({ state: transition.to, held, path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }] })
1283
+ }
1284
+ }
1285
+ }
1286
+ }
1287
+ return checkResult('A4', 'Pair Symmetry', findings, findings.length === 0 ? 'All pairs balanced' : 'Asymmetric pairs: ' + findings.length)
1288
+ }
1289
+
1290
+ function stateGraphEdges(model: LogicModelV1): Map<string, TransitionSpec[]> {
1291
+ const edges = new Map<string, TransitionSpec[]>()
1292
+ for (const state of model.states) edges.set(state.id, [])
1293
+ for (const transition of model.transitions) edges.get(transition.from)?.push(transition)
1294
+ return edges
1295
+ }
1296
+
1297
+ function stateGraphReachable(model: LogicModelV1, start: string): Set<string> {
1298
+ const edges = new Map<string, string[]>()
1299
+ for (const state of model.states) edges.set(state.id, [])
1300
+ for (const transition of model.transitions) edges.get(transition.from)?.push(transition.to)
1301
+ const visited = new Set<string>()
1302
+ const queue = [start]
1303
+ while (queue.length > 0) {
1304
+ const current = queue.shift()!
1305
+ if (visited.has(current)) continue
1306
+ visited.add(current)
1307
+ for (const next of edges.get(current) ?? []) queue.push(next)
1308
+ }
1309
+ return visited
1310
+ }
1311
+
1312
+ function A5_boundaryBlast(model: LogicModelV1): CheckResult {
1313
+ const findings: Finding[] = []
1314
+ for (const check of model.boundaryChecks ?? []) {
1315
+ const variable = (model.variables ?? []).find((entry) => entry.name === check.variable)
1316
+ if (variable === undefined) continue
1317
+ for (const value of check.values) {
1318
+ if (variable.min !== undefined && value < variable.min) {
1319
+ findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is below min ' + variable.min })
1320
+ }
1321
+ if (variable.max !== undefined && value > variable.max) {
1322
+ findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is above max ' + variable.max })
1323
+ }
1324
+ const assignment = { ...(model.variables ?? []).reduce<Record<string, VarValue>>((result, entry) => { result[entry.name] = entry.init; return result }, {}), [check.variable]: value }
1325
+ for (const [key, group] of groupTransitions(model)) {
1326
+ const [from, event] = key.split('|')
1327
+ if (!group.some((transition) => guardVariables(transition.guard).includes(check.variable))) continue
1328
+ const guarded = group.filter((transition) => transition.guard !== undefined)
1329
+ const unguarded = group.filter((transition) => transition.guard === undefined)
1330
+ const matched = guarded.filter((transition) => evalGuard(transition.guard!, assignment))
1331
+ if (matched.length === 0 && unguarded.length === 0) {
1332
+ findings.push({
1333
+ code: 'A5_GUARD_HOLE',
1334
+ severity: 'warning',
1335
+ message: from + ' + ' + event + ' has no branch for ' + check.variable + '=' + value,
1336
+ evidence: { assignment },
1337
+ })
1338
+ }
1339
+ if (matched.length > 1) {
1340
+ findings.push({
1341
+ code: 'A5_GUARD_OVERLAP',
1342
+ severity: 'warning',
1343
+ message: from + ' + ' + event + ' has ' + matched.length + ' true branches for ' + check.variable + '=' + value,
1344
+ evidence: { assignment },
1345
+ })
1346
+ }
1347
+ }
1348
+ }
1349
+ }
1350
+ return checkResult('A5', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length)
1351
+ }
1352
+
1353
+ function A6_resourceInjection(model: LogicModelV1): CheckResult {
1354
+ const findings: Finding[] = []
1355
+ for (const pair of model.resourcePairs ?? []) {
1356
+ if (pair.failEvent === undefined) continue
1357
+ const acquireStates = new Set(model.transitions.filter((transition) => transition.event === pair.acquireEvent).map((transition) => transition.from))
1358
+ for (const state of acquireStates) {
1359
+ const handled = model.transitions.some((transition) => transition.from === state && transition.event === pair.failEvent)
1360
+ if (!handled) {
1361
+ findings.push({
1362
+ code: 'A6_NO_FAILURE_HANDLER',
1363
+ severity: 'warning',
1364
+ message: 'State ' + state + ' can ' + pair.acquireEvent + ' for "' + pair.resource + '" but has no ' + pair.failEvent + ' transition.',
1365
+ })
1366
+ }
1367
+ }
1368
+ }
1369
+ return checkResult('A6', 'Resource Injection', findings, findings.length === 0 ? 'No resource vulnerabilities detected' : 'Resource failure paths missing: ' + findings.length)
1370
+ }
1371
+
1372
+ function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1373
+ const violations = runInvariants(model, options)
1374
+ const findings: Finding[] = violations.map((violation) => ({
1375
+ code: 'A7_SHORTEST_COUNTEREXAMPLE',
1376
+ severity: 'warning',
1377
+ message: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + violation.path.length + (violation.path.length === 0 ? ' (initial state)' : ''),
1378
+ path: violation.path,
1379
+ evidence: { invariant: violation.invariant.id },
1380
+ }))
1381
+ return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length)
1382
+ }
1383
+
1384
+ function mapStateId(mapping: Record<string, string>, state: string): string {
1385
+ return mapping[state] ?? state
1386
+ }
1387
+
1388
+ function D1_behavioralPreservation(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
1389
+ const findings: Finding[] = []
1390
+ const afterStates = new Set(after.states.map((state) => state.id))
1391
+ const afterByFromEvent = new Map<string, Set<string>>()
1392
+ for (const transition of after.transitions) {
1393
+ const key = transition.from + '|' + transition.event
1394
+ const set = afterByFromEvent.get(key) ?? new Set<string>()
1395
+ set.add(transition.to)
1396
+ afterByFromEvent.set(key, set)
1397
+ }
1398
+ const beforeInitMapped = mapStateId(mapping, before.init)
1399
+ if (beforeInitMapped !== after.init) {
1400
+ findings.push({
1401
+ code: 'D1_INIT_MISMATCH',
1402
+ severity: 'warning',
1403
+ message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
1404
+ evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
1405
+ })
1406
+ }
1407
+ for (const state of before.states) {
1408
+ const mapped = mapStateId(mapping, state.id)
1409
+ if (!afterStates.has(mapped)) {
1410
+ findings.push({
1411
+ code: 'D1_MAPPED_STATE_MISSING',
1412
+ severity: 'error',
1413
+ message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
1414
+ evidence: { beforeState: state.id, mappedState: mapped },
1415
+ })
1416
+ continue
1417
+ }
1418
+ for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
1419
+ const key = mapped + '|' + transition.event
1420
+ const targets = afterByFromEvent.get(key)
1421
+ if (targets === undefined || targets.size === 0) {
1422
+ findings.push({
1423
+ code: 'D1_EVENT_DISABLED',
1424
+ severity: 'error',
1425
+ message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
1426
+ path: [{ from: state.id, event: transition.event, to: transition.to }],
1427
+ evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
1428
+ })
1429
+ }
1430
+ }
1431
+ }
1432
+ return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length)
1433
+ }
1434
+
1435
+ function mapInvariantForComparison(invariant: InvariantSpec, mapping: Record<string, string>): InvariantSpec {
1436
+ if (invariant.kind === 'never-states') {
1437
+ return {
1438
+ ...invariant,
1439
+ id: invariant.id + ':before',
1440
+ description: invariant.description + ' (from BEFORE)',
1441
+ states: invariant.states.map((state) => mapStateId(mapping, state)),
1442
+ }
1443
+ }
1444
+ if (invariant.kind === 'event-before-state') {
1445
+ return {
1446
+ ...invariant,
1447
+ id: invariant.id + ':before',
1448
+ description: invariant.description + ' (from BEFORE)',
1449
+ state: mapStateId(mapping, invariant.state),
1450
+ }
1451
+ }
1452
+ return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' }
1453
+ }
1454
+
1455
+ function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, options: NormalizedOptions, mapping: Record<string, string>): CheckResult {
1456
+ const findings: Finding[] = []
1457
+ const afterStates = new Set(after.states.map((state) => state.id))
1458
+ const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name))
1459
+ for (const invariant of before.invariants ?? []) {
1460
+ const mapped = mapInvariantForComparison(invariant, mapping)
1461
+ if (mapped.kind === 'never-states') {
1462
+ for (const state of mapped.states) {
1463
+ if (!afterStates.has(state)) {
1464
+ findings.push({
1465
+ code: 'D2_MAPPED_STATE_MISSING',
1466
+ severity: 'warning',
1467
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
1468
+ evidence: { invariant: invariant.id, state },
1469
+ })
1470
+ }
1471
+ }
1472
+ } else if (mapped.kind === 'event-before-state') {
1473
+ if (!afterStates.has(mapped.state)) {
1474
+ findings.push({
1475
+ code: 'D2_MAPPED_STATE_MISSING',
1476
+ severity: 'warning',
1477
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
1478
+ evidence: { invariant: invariant.id, state: mapped.state },
1479
+ })
1480
+ }
1481
+ } else if (mapped.kind === 'var-in-range') {
1482
+ if (!afterVariables.has(mapped.variable)) {
1483
+ findings.push({
1484
+ code: 'D2_VARIABLE_MISSING',
1485
+ severity: 'warning',
1486
+ message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
1487
+ evidence: { invariant: invariant.id, variable: mapped.variable },
1488
+ })
1489
+ continue
1490
+ }
1491
+ }
1492
+ const violation = shortestViolationForInvariant(after, options, mapped)
1493
+ if (violation !== undefined) {
1494
+ findings.push({
1495
+ code: 'D2_INVARIANT_REGRESSION',
1496
+ severity: 'error',
1497
+ message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
1498
+ path: violation.path,
1499
+ evidence: { beforeInvariant: invariant, afterInvariant: mapped },
1500
+ })
1501
+ }
1502
+ }
1503
+ return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length)
1504
+ }
1505
+
1506
+ function D3_regressionDelta(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
1507
+ const afterStateIds = new Set(after.states.map((state) => state.id))
1508
+ const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
1509
+ const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
1510
+ const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
1511
+ const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
1512
+ const afterEvents = new Set(after.transitions.map((transition) => transition.event))
1513
+ const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
1514
+ const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
1515
+ const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
1516
+ candidate.from === mapStateId(mapping, transition.from) &&
1517
+ candidate.event === transition.event &&
1518
+ candidate.to === mapStateId(mapping, transition.to)
1519
+ ))
1520
+ const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
1521
+ mapStateId(mapping, candidate.from) === transition.from &&
1522
+ candidate.event === transition.event &&
1523
+ mapStateId(mapping, candidate.to) === transition.to
1524
+ ))
1525
+ const findings: Finding[] = []
1526
+ for (const state of removedStates) {
1527
+ findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } })
1528
+ }
1529
+ for (const event of removedEvents) {
1530
+ findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } })
1531
+ }
1532
+ for (const transition of removedTransitions) {
1533
+ findings.push({
1534
+ code: 'D3_REMOVED_TRANSITION',
1535
+ severity: 'warning',
1536
+ message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
1537
+ evidence: { transition },
1538
+ })
1539
+ }
1540
+ const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions'
1541
+ return checkResult('D3', 'Regression Delta', findings, detail)
1542
+ }
1543
+
1544
+ function deadlockStateIds(model: LogicModelV1): string[] {
1545
+ const outgoing = new Set(model.transitions.map((transition) => transition.from))
1546
+ return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id)
1547
+ }
1548
+
1549
+ function closedSccStateSets(model: LogicModelV1): string[][] {
1550
+ return sccs(model)
1551
+ .filter((component) => {
1552
+ const componentSet = new Set(component)
1553
+ const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to))
1554
+ const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to))
1555
+ return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state))
1556
+ })
1557
+ .map((component) => component.sort())
1558
+ }
1559
+
1560
+ function D4_deadlockLivenessRegression(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
1561
+ const findings: Finding[] = []
1562
+ const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)))
1563
+ const afterDeadlock = deadlockStateIds(after)
1564
+ for (const state of afterDeadlock) {
1565
+ if (!beforeDeadlock.has(state)) {
1566
+ findings.push({
1567
+ code: 'D4_DEADLOCK_REGRESSION',
1568
+ severity: 'error',
1569
+ message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
1570
+ evidence: { state },
1571
+ })
1572
+ }
1573
+ }
1574
+ const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')))
1575
+ const afterScc = closedSccStateSets(after)
1576
+ for (const component of afterScc) {
1577
+ const key = component.join(',')
1578
+ if (!beforeScc.has(key)) {
1579
+ findings.push({
1580
+ code: 'D4_LIVENESS_REGRESSION',
1581
+ severity: 'error',
1582
+ message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
1583
+ evidence: { states: component },
1584
+ })
1585
+ }
1586
+ }
1587
+ return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length)
1588
+ }
1589
+
1590
+ function buildComparisonSummary(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): ComparisonSummary {
1591
+ const afterStateIds = new Set(after.states.map((state) => state.id))
1592
+ const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
1593
+ const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
1594
+ const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
1595
+ const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
1596
+ const afterEvents = new Set(after.transitions.map((transition) => transition.event))
1597
+ const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
1598
+ const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
1599
+ const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
1600
+ candidate.from === mapStateId(mapping, transition.from) &&
1601
+ candidate.event === transition.event &&
1602
+ candidate.to === mapStateId(mapping, transition.to)
1603
+ ))
1604
+ const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
1605
+ mapStateId(mapping, candidate.from) === transition.from &&
1606
+ candidate.event === transition.event &&
1607
+ mapStateId(mapping, candidate.to) === transition.to
1608
+ ))
1609
+ return {
1610
+ beforeModelHash: modelHash(before),
1611
+ afterModelHash: modelHash(after),
1612
+ stateMapping: mapping,
1613
+ beforeStates: before.states.length,
1614
+ beforeTransitions: before.transitions.length,
1615
+ afterStates: after.states.length,
1616
+ afterTransitions: after.transitions.length,
1617
+ addedStates,
1618
+ removedStates,
1619
+ addedEvents,
1620
+ removedEvents,
1621
+ addedTransitions,
1622
+ removedTransitions,
1623
+ }
1624
+ }
1625
+ function A8_idempotentReplay(model: LogicModelV1, exploration: Exploration): CheckResult {
1626
+ const events = model.idempotentEvents ?? []
1627
+ const findings: Finding[] = []
1628
+ for (const event of events) {
1629
+ for (const runtime of exploration.reachable) {
1630
+ const onceOptions = stepRuntime(model, runtime, event)
1631
+ if (onceOptions.length === 0) continue
1632
+ for (const once of onceOptions) {
1633
+ const twiceOptions = stepRuntime(model, once, event)
1634
+ if (twiceOptions.length === 0) {
1635
+ findings.push({
1636
+ code: 'A8_NOT_REPLAYABLE',
1637
+ severity: 'warning',
1638
+ message: 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime.state + '.',
1639
+ path: [{ from: runtime.state, event, to: once.state }],
1640
+ evidence: { state: runtime.state, event },
1641
+ })
1642
+ continue
1643
+ }
1644
+ for (const twice of twiceOptions) {
1645
+ if (runtimeKey(twice) !== runtimeKey(once)) {
1646
+ findings.push({
1647
+ code: 'A8_NOT_IDEMPOTENT',
1648
+ severity: 'error',
1649
+ message: 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime.state + '.',
1650
+ path: [{ from: runtime.state, event, to: once.state }, { from: once.state, event, to: twice.state }],
1651
+ evidence: { state: runtime.state, event, afterOnce: once, afterTwice: twice },
1652
+ })
1653
+ break
1654
+ }
1655
+ }
1656
+ }
1657
+ }
1658
+ }
1659
+ return checkResult('A8', 'Idempotent Replay', findings, findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length)
1660
+ }
1661
+ function S8_monotonicVariables(model: LogicModelV1): CheckResult {
1662
+ const findings: Finding[] = []
1663
+ for (const variable of model.variables ?? []) {
1664
+ if (variable.monotonic === undefined) continue
1665
+ for (const transition of model.transitions) {
1666
+ for (const update of transition.updates ?? []) {
1667
+ if (update.variable !== variable.name) continue
1668
+ if (variable.monotonic === 'inc' && update.op === 'dec') {
1669
+ findings.push({ code: 'S8_MONOTONIC_DECREASE', severity: 'error', message: 'Monotonic (inc) variable ' + variable.name + ' is decreased by ' + transition.event + '.', evidence: { variable: variable.name, transition } })
1670
+ } else if (variable.monotonic === 'dec' && update.op === 'inc') {
1671
+ findings.push({ code: 'S8_MONOTONIC_INCREASE', severity: 'error', message: 'Monotonic (dec) variable ' + variable.name + ' is increased by ' + transition.event + '.', evidence: { variable: variable.name, transition } })
1672
+ } else if (update.op === 'set') {
1673
+ findings.push({ code: 'S8_MONOTONIC_SET_REVIEW', severity: 'warning', message: 'Monotonic variable ' + variable.name + ' uses set in ' + transition.event + '; verify it cannot move backwards.', evidence: { variable: variable.name, transition } })
1674
+ }
1675
+ }
1676
+ }
1677
+ }
1678
+ return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length)
1679
+ }
1680
+
1681
+ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: string): { path: PathStep[]; reason: string } | undefined {
1682
+ if (start.state === target) return undefined
1683
+ const visited = new Set<string>()
1684
+ const queue: Array<{ runtime: RuntimeState; path: PathStep[] }> = [{ runtime: start, path: [] }]
1685
+ while (queue.length > 0) {
1686
+ const entry = queue.shift()!
1687
+ const key = runtimeKey(entry.runtime)
1688
+ if (entry.runtime.state === target) continue
1689
+ if (visited.has(key)) return { path: entry.path, reason: 'Cycle avoids target ' + target }
1690
+ visited.add(key)
1691
+ const nexts: Array<{ next: RuntimeState; event: string }> = []
1692
+ for (const event of allEvents(model)) {
1693
+ for (const next of stepRuntime(model, entry.runtime, event)) nexts.push({ next, event })
1694
+ }
1695
+ if (nexts.length === 0) return { path: entry.path, reason: 'Dead end before target ' + target }
1696
+ for (const { next, event } of nexts) {
1697
+ queue.push({ runtime: next, path: [...entry.path, { from: entry.runtime.state, event, to: next.state }] })
1698
+ }
1699
+ }
1700
+ return { path: [], reason: 'No path reaches target ' + target }
1701
+ }
1702
+
1703
+ function A9_leadsTo(model: LogicModelV1, exploration: Exploration): CheckResult {
1704
+ const findings: Finding[] = []
1705
+ for (const invariant of model.invariants ?? []) {
1706
+ if (invariant.kind !== 'leads-to') continue
1707
+ for (const runtime of exploration.reachable) {
1708
+ if (runtime.state !== invariant.from) continue
1709
+ const bad = findLeadsToBadPath(model, runtime, invariant.to)
1710
+ if (bad !== undefined) {
1711
+ findings.push({
1712
+ code: 'A9_LEADS_TO_VIOLATION',
1713
+ severity: 'error',
1714
+ message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + bad.reason,
1715
+ path: bad.path,
1716
+ evidence: { invariant },
1717
+ })
1718
+ break
1719
+ }
1720
+ }
1721
+ }
1722
+ return checkResult('A9', 'Leads-To', findings, findings.length === 0 ? 'All leads-to invariants hold' : 'Leads-to findings: ' + findings.length)
1723
+ }
1724
+
1725
+ function findSequenceViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'sequence' }>): InvariantViolation | undefined {
1726
+ const events = invariant.events
1727
+ const init = initialState(model)
1728
+ const key = (runtime: RuntimeState, progress: number): string => runtimeKey(runtime) + '|' + progress
1729
+ const visited = new Set<string>([key(init, 0)])
1730
+ const queue: Array<{ runtime: RuntimeState; progress: number; path: PathStep[] }> = [{ runtime: init, progress: 0, path: [] }]
1731
+ let steps = 0
1732
+ while (queue.length > 0) {
1733
+ const entry = queue.shift()!
1734
+ if (++steps > options.maxStates) break
1735
+ for (const event of allEvents(model)) {
1736
+ for (const next of stepRuntime(model, entry.runtime, event)) {
1737
+ let progress = entry.progress
1738
+ let violation = false
1739
+ if (progress < events.length && event === events[progress]) {
1740
+ progress += 1
1741
+ } else {
1742
+ const index = events.indexOf(event)
1743
+ if (index > progress) violation = true
1744
+ }
1745
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
1746
+ if (violation) {
1747
+ return { invariant, path, reason: 'Event ' + event + ' occurred before ' + events[progress] }
1748
+ }
1749
+ const nextKey = key(next, progress)
1750
+ if (!visited.has(nextKey)) {
1751
+ visited.add(nextKey)
1752
+ queue.push({ runtime: next, progress, path })
1753
+ }
1754
+ }
1755
+ }
1756
+ }
1757
+ return undefined
1758
+ }
1759
+
1760
+ function A10_sequenceOrder(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1761
+ const findings: Finding[] = []
1762
+ for (const invariant of model.invariants ?? []) {
1763
+ if (invariant.kind !== 'sequence') continue
1764
+ const violation = findSequenceViolation(model, options, invariant)
1765
+ if (violation !== undefined) {
1766
+ findings.push({
1767
+ code: 'A10_SEQUENCE_VIOLATION',
1768
+ severity: 'error',
1769
+ message: 'Sequence invariant "' + invariant.id + '" violated: ' + violation.reason,
1770
+ path: violation.path,
1771
+ evidence: { invariant },
1772
+ })
1773
+ }
1774
+ }
1775
+ return checkResult('A10', 'Sequence Order', findings, findings.length === 0 ? 'All sequence invariants hold' : 'Sequence findings: ' + findings.length)
1776
+ }
1777
+
1778
+ function findAtomicityViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'atomicity' }>): InvariantViolation | undefined {
1779
+ const atomic = new Set(invariant.events)
1780
+ const init = initialState(model)
1781
+ const key = (runtime: RuntimeState, started: boolean, closed: boolean): string => runtimeKey(runtime) + '|' + (started ? '1' : '0') + '|' + (closed ? '1' : '0')
1782
+ const visited = new Set<string>([key(init, false, false)])
1783
+ const queue: Array<{ runtime: RuntimeState; started: boolean; closed: boolean; path: PathStep[] }> = [{ runtime: init, started: false, closed: false, path: [] }]
1784
+ let steps = 0
1785
+ while (queue.length > 0) {
1786
+ const entry = queue.shift()!
1787
+ if (++steps > options.maxStates) break
1788
+ for (const event of allEvents(model)) {
1789
+ for (const next of stepRuntime(model, entry.runtime, event)) {
1790
+ const started = entry.started || atomic.has(event)
1791
+ const closed = entry.closed || event === invariant.commit || (invariant.rollback !== undefined && event === invariant.rollback)
1792
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
1793
+ if (entry.started && !entry.closed && !atomic.has(event) && event !== invariant.commit && event !== invariant.rollback) {
1794
+ return { invariant, path, reason: 'Left atomic scope via ' + event + ' without commit/rollback' }
1795
+ }
1796
+ if (started && !closed && isTerminal(model, next.state)) {
1797
+ return { invariant, path, reason: 'Terminal state reached with incomplete atomic group' }
1798
+ }
1799
+ const nextKey = key(next, started, closed)
1800
+ if (!visited.has(nextKey)) {
1801
+ visited.add(nextKey)
1802
+ queue.push({ runtime: next, started, closed, path })
1803
+ }
1804
+ }
1805
+ }
1806
+ }
1807
+ return undefined
1808
+ }
1809
+
1810
+ function A11_atomicity(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1811
+ const findings: Finding[] = []
1812
+ for (const invariant of model.invariants ?? []) {
1813
+ if (invariant.kind !== 'atomicity') continue
1814
+ const violation = findAtomicityViolation(model, options, invariant)
1815
+ if (violation !== undefined) {
1816
+ findings.push({
1817
+ code: 'A11_ATOMICITY_VIOLATION',
1818
+ severity: 'error',
1819
+ message: 'Atomicity invariant "' + invariant.id + '" violated: ' + violation.reason,
1820
+ path: violation.path,
1821
+ evidence: { invariant },
1822
+ })
1823
+ }
1824
+ }
1825
+ return checkResult('A11', 'Atomicity', findings, findings.length === 0 ? 'All atomicity invariants hold' : 'Atomicity findings: ' + findings.length)
1826
+ }
1827
+
1828
+ // ---------------------------------------------------------------------------
1829
+ // A12 — Budget (worst-case path cost)
1830
+ // ---------------------------------------------------------------------------
1831
+
1832
+ function transitionCost(transition: TransitionSpec): number {
1833
+ return transition.cost ?? 1
1834
+ }
1835
+
1836
+ interface BudgetViolation {
1837
+ path: PathStep[]
1838
+ /** Accumulated cost when the violation was detected (unbounded-cycle witnesses carry no total). */
1839
+ cost?: number
1840
+ /** True when the witness is a repeatable positive-cost runtime cycle: cost can grow without bound. */
1841
+ unbounded: boolean
1842
+ }
1843
+
1844
+ interface RuntimeEdge {
1845
+ to: string
1846
+ step: PathStep
1847
+ cost: number
1848
+ }
1849
+
1850
+ interface RuntimeGraph {
1851
+ edges: Map<string, RuntimeEdge[]>
1852
+ keys: string[]
1853
+ initKey: string
1854
+ }
1855
+
1856
+ function runtimeGraph(model: LogicModelV1, options: NormalizedOptions): RuntimeGraph {
1857
+ const groupMap = groupTransitions(model)
1858
+ const exploration = explore(model, options)
1859
+ const edges = new Map<string, RuntimeEdge[]>()
1860
+ for (const runtime of exploration.reachable) {
1861
+ const out: RuntimeEdge[] = []
1862
+ for (const event of allEvents(model)) {
1863
+ const group = groupMap.get(runtime.state + '|' + event)
1864
+ if (group === undefined || group.length === 0) continue
1865
+ for (const transition of applicableTransitions(group, runtime)) {
1866
+ const next = applyUpdates(model, transition, runtime)
1867
+ out.push({
1868
+ to: runtimeKey(next),
1869
+ step: { from: runtime.state, event: transition.event, to: next.state },
1870
+ cost: transitionCost(transition),
1871
+ })
1872
+ }
1873
+ }
1874
+ edges.set(runtimeKey(runtime), out)
1875
+ }
1876
+ return {
1877
+ edges,
1878
+ keys: exploration.reachable.map((runtime) => runtimeKey(runtime)),
1879
+ initKey: runtimeKey(exploration.initialState),
1880
+ }
1881
+ }
1882
+
1883
+ /** Shortest path from init to targetKey over the runtime graph, or undefined when unreachable. */
1884
+ function runtimePathToKey(graph: RuntimeGraph, targetKey: string): PathStep[] | undefined {
1885
+ const parent = new Map<string, PathStep>()
1886
+ const visited = new Set<string>([graph.initKey])
1887
+ const queue: string[] = [graph.initKey]
1888
+ while (queue.length > 0) {
1889
+ const current = queue.shift()!
1890
+ if (current === targetKey) {
1891
+ const steps: PathStep[] = []
1892
+ let key = targetKey
1893
+ while (key !== graph.initKey) {
1894
+ const step = parent.get(key)
1895
+ if (step === undefined) break
1896
+ steps.unshift(step)
1897
+ key = step.from === undefined ? graph.initKey : keyOfFromStep(graph, step, key)
1898
+ }
1899
+ return steps
1900
+ }
1901
+ for (const edge of graph.edges.get(current) ?? []) {
1902
+ if (visited.has(edge.to)) continue
1903
+ visited.add(edge.to)
1904
+ parent.set(edge.to, edge.step)
1905
+ queue.push(edge.to)
1906
+ }
1907
+ }
1908
+ return undefined
1909
+ }
1910
+
1911
+ function keyOfFromStep(graph: RuntimeGraph, step: PathStep, targetKey: string): string {
1912
+ // Reconstruct the source key by scanning predecessors of targetKey whose step matches.
1913
+ for (const [from, edges] of graph.edges) {
1914
+ for (const edge of edges) {
1915
+ if (edge.to === targetKey && edge.step.event === step.event && edge.step.from === step.from && edge.step.to === step.to) {
1916
+ return from
1917
+ }
1918
+ }
1919
+ }
1920
+ return graph.initKey
1921
+ }
1922
+
1923
+ /** Find a reachable cycle whose total cost is positive; return a witness path init -> one full round. */
1924
+ function findUnboundedCycle(model: LogicModelV1, options: NormalizedOptions): PathStep[] | undefined {
1925
+ const graph = runtimeGraph(model, options)
1926
+ const color = new Map<string, number>()
1927
+ for (const start of graph.keys) {
1928
+ if (color.has(start)) continue
1929
+ const nodeStack: string[] = [start]
1930
+ const idxStack: number[] = [0]
1931
+ const depthOf = new Map<string, number>([[start, 0]])
1932
+ const costAt = new Map<string, number>([[start, 0]])
1933
+ const prevKey = new Map<string, string>()
1934
+ const prevStep = new Map<string, PathStep>()
1935
+ color.set(start, 1)
1936
+ while (nodeStack.length > 0) {
1937
+ const node = nodeStack[nodeStack.length - 1]
1938
+ const outs = graph.edges.get(node) ?? []
1939
+ if (idxStack[idxStack.length - 1] < outs.length) {
1940
+ const edge = outs[idxStack[idxStack.length - 1]++]
1941
+ const seen = color.get(edge.to)
1942
+ if (seen === undefined) {
1943
+ color.set(edge.to, 1)
1944
+ depthOf.set(edge.to, depthOf.get(node)! + 1)
1945
+ costAt.set(edge.to, costAt.get(node)! + edge.cost)
1946
+ prevKey.set(edge.to, node)
1947
+ prevStep.set(edge.to, edge.step)
1948
+ nodeStack.push(edge.to)
1949
+ idxStack.push(0)
1950
+ } else if (seen === 1) {
1951
+ // Back edge on the current path: this is a true cycle.
1952
+ const cycleCost = costAt.get(node)! + edge.cost - costAt.get(edge.to)!
1953
+ if (cycleCost > 0) {
1954
+ const entry = runtimePathToKey(graph, edge.to) ?? []
1955
+ const round: PathStep[] = []
1956
+ let cursor = node
1957
+ while (cursor !== edge.to && prevStep.has(cursor)) {
1958
+ round.unshift(prevStep.get(cursor)!)
1959
+ cursor = prevKey.get(cursor)!
1960
+ }
1961
+ round.push(edge.step)
1962
+ return [...entry, ...round]
1963
+ }
1964
+ }
1965
+ } else {
1966
+ color.set(node, 2)
1967
+ nodeStack.pop()
1968
+ idxStack.pop()
1969
+ depthOf.delete(node)
1970
+ costAt.delete(node)
1971
+ prevKey.delete(node)
1972
+ prevStep.delete(node)
1973
+ }
1974
+ }
1975
+ }
1976
+ return undefined
1977
+ }
1978
+
1979
+ function findBudgetViolation(
1980
+ model: LogicModelV1,
1981
+ options: NormalizedOptions,
1982
+ invariant: Extract<InvariantSpec, { kind: 'budget' }>,
1983
+ ): BudgetViolation | undefined {
1984
+ const groupMap = groupTransitions(model)
1985
+ const init = initialState(model)
1986
+ const bestCost = new Map<string, number>([[runtimeKey(init), 0]])
1987
+ const queue: Array<{ runtime: RuntimeState; cost: number; path: PathStep[] }> = [{ runtime: init, cost: 0, path: [] }]
1988
+ let steps = 0
1989
+ while (queue.length > 0) {
1990
+ const entry = queue.shift()!
1991
+ if (++steps > options.maxStates) break
1992
+ for (const event of allEvents(model)) {
1993
+ const group = groupMap.get(entry.runtime.state + '|' + event)
1994
+ if (group === undefined || group.length === 0) continue
1995
+ for (const transition of applicableTransitions(group, entry.runtime)) {
1996
+ const next = applyUpdates(model, transition, entry.runtime)
1997
+ const cost = entry.cost + transitionCost(transition)
1998
+ const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }]
1999
+ // Costs are non-negative, so the first step that pushes the accumulated cost
2000
+ // over the budget is already a witness.
2001
+ if (cost > invariant.budget) return { path, cost, unbounded: false }
2002
+ const key = runtimeKey(next)
2003
+ const best = bestCost.get(key)
2004
+ if (best === undefined || cost < best) {
2005
+ bestCost.set(key, cost)
2006
+ queue.push({ runtime: next, cost, path })
2007
+ }
2008
+ }
2009
+ }
2010
+ }
2011
+ // No finite path exceeds the budget within reachable space; a reachable positive-cost
2012
+ // cycle still makes the worst-case unbounded. Plain DAG merges are not cycles.
2013
+ const cycle = findUnboundedCycle(model, options)
2014
+ if (cycle !== undefined) return { path: cycle, unbounded: true }
2015
+ return undefined
2016
+ }
2017
+
2018
+ function A12_budget(model: LogicModelV1, options: NormalizedOptions): CheckResult {
2019
+ const budgets = (model.invariants ?? []).filter((invariant) => invariant.kind === 'budget') as Array<Extract<InvariantSpec, { kind: 'budget' }>>
2020
+ const usesCost = model.transitions.some((transition) => transition.cost !== undefined)
2021
+ const findings: Finding[] = []
2022
+ if (usesCost && budgets.length === 0) {
2023
+ findings.push({
2024
+ code: 'A12_COST_WITHOUT_BUDGET',
2025
+ severity: 'warning',
2026
+ message: 'Transitions declare cost, but no budget invariant is declared, so worst-case path cost is not verified. Add an invariant of kind budget to check it.',
2027
+ })
2028
+ }
2029
+ for (const invariant of budgets) {
2030
+ const violation = findBudgetViolation(model, options, invariant)
2031
+ if (violation !== undefined) {
2032
+ findings.push({
2033
+ code: 'A12_BUDGET_OVER',
2034
+ severity: 'error',
2035
+ message: violation.unbounded
2036
+ ? 'Budget invariant "' + invariant.id + '" (' + invariant.description + ') exceeded: a reachable positive-cost cycle lets path cost grow without bound, so no finite budget ' + invariant.budget + ' holds.'
2037
+ : 'Budget invariant "' + invariant.id + '" (' + invariant.description + ') exceeded: worst-case path cost ' + violation.cost + ' is over budget ' + invariant.budget + '.',
2038
+ path: violation.path,
2039
+ evidence: violation.unbounded ? { invariant, unbounded: true } : { invariant, totalCost: violation.cost, unbounded: false },
2040
+ })
2041
+ }
2042
+ }
2043
+ return checkResult('A12', 'Budget', findings, findings.length === 0 ? (budgets.length === 0 ? 'No budget invariants declared' : 'All budget invariants hold') : 'Budget findings: ' + findings.length)
2044
+ }
2045
+ // ---------------------------------------------------------------------------
2046
+ // A13 — Probability reachability (DTMC)
2047
+ // ---------------------------------------------------------------------------
2048
+
2049
+ function probabilityOutcomes(model: LogicModelV1, runtime: RuntimeState): Map<string, { state: string; weight: number }> {
2050
+ const groupMap = groupTransitions(model)
2051
+ const outcomes = new Map<string, { state: string; weight: number }>()
2052
+ for (const event of allEvents(model)) {
2053
+ const group = groupMap.get(runtime.state + '|' + event)
2054
+ if (group === undefined || group.length === 0) continue
2055
+ for (const transition of applicableTransitions(group, runtime)) {
2056
+ const weight = transition.weight ?? 1
2057
+ if (!(weight > 0)) continue
2058
+ const next = applyUpdates(model, transition, runtime)
2059
+ const key = runtimeKey(next)
2060
+ const current = outcomes.get(key)
2061
+ if (current === undefined) outcomes.set(key, { state: next.state, weight })
2062
+ else current.weight += weight
2063
+ }
2064
+ }
2065
+ return outcomes
2066
+ }
2067
+
2068
+ /**
2069
+ * Probability of ever reaching targetState from the initial state under the DTMC
2070
+ * interpretation: at each runtime state every applicable transition with weight > 0 is an
2071
+ * outcome chosen proportionally to its weight. Solved by value iteration (least fixed
2072
+ * point of the absorbing chain).
2073
+ */
2074
+ function computeHitProbability(model: LogicModelV1, options: NormalizedOptions, targetState: string): { probability: number; converged: boolean } {
2075
+ const exploration = explore(model, options)
2076
+ const reachable = exploration.reachable
2077
+ const n = reachable.length
2078
+ const keyIndex = new Map<string, number>()
2079
+ reachable.forEach((runtime, i) => keyIndex.set(runtimeKey(runtime), i))
2080
+ const fixedValue = new Array<number | null>(n).fill(null)
2081
+ const chains: Array<Array<{ j: number; w: number }>> = []
2082
+ for (let i = 0; i < n; i++) {
2083
+ const runtime = reachable[i]
2084
+ if (runtime.state === targetState) {
2085
+ fixedValue[i] = 1
2086
+ chains.push([])
2087
+ continue
2088
+ }
2089
+ const outs = probabilityOutcomes(model, runtime)
2090
+ let sum = 0
2091
+ const list: Array<{ j: number; w: number }> = []
2092
+ for (const [key, entry] of outs) {
2093
+ const j = keyIndex.get(key)
2094
+ if (j === undefined) continue
2095
+ list.push({ j, w: entry.weight })
2096
+ sum += entry.weight
2097
+ }
2098
+ if (sum <= 0) {
2099
+ fixedValue[i] = 0
2100
+ chains.push([])
2101
+ continue
2102
+ }
2103
+ chains.push(list.map((item) => ({ j: item.j, w: item.w / sum })))
2104
+ }
2105
+ const p = new Array<number>(n).fill(0)
2106
+ let converged = false
2107
+ for (let iter = 0; iter < 20000; iter++) {
2108
+ let maxDelta = 0
2109
+ for (let i = 0; i < n; i++) {
2110
+ if (fixedValue[i] !== null) continue
2111
+ const list = chains[i]
2112
+ let acc = 0
2113
+ for (const item of list) {
2114
+ const fixed = fixedValue[item.j]
2115
+ acc += item.w * (fixed === null ? p[item.j] : fixed)
2116
+ }
2117
+ const delta = Math.abs(acc - p[i])
2118
+ if (delta > maxDelta) maxDelta = delta
2119
+ p[i] = acc
2120
+ }
2121
+ if (maxDelta < 1e-9) {
2122
+ converged = true
2123
+ break
2124
+ }
2125
+ }
2126
+ const initIndex = keyIndex.get(runtimeKey(exploration.initialState)) ?? 0
2127
+ const fixed = fixedValue[initIndex]
2128
+ return { probability: fixed === null ? p[initIndex] : fixed, converged }
2129
+ }
2130
+
2131
+ function A13_probability(model: LogicModelV1, options: NormalizedOptions): CheckResult {
2132
+ const findings: Finding[] = []
2133
+ const eps = 1e-9
2134
+ for (const invariant of model.invariants ?? []) {
2135
+ if (invariant.kind !== 'probability') continue
2136
+ const { probability, converged } = computeHitProbability(model, options, invariant.target)
2137
+ let violated = false
2138
+ if (invariant.op === '>=') violated = probability < invariant.p - eps
2139
+ else if (invariant.op === '>') violated = probability <= invariant.p + eps
2140
+ else if (invariant.op === '<=') violated = probability > invariant.p + eps
2141
+ else violated = probability >= invariant.p - eps
2142
+ if (violated) {
2143
+ findings.push({
2144
+ code: 'A13_PROBABILITY_VIOLATION',
2145
+ severity: 'error',
2146
+ message: 'Probability invariant "' + invariant.id + '" (' + invariant.description + ') violated: P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + invariant.p + '.',
2147
+ evidence: { invariant, computed: probability, converged },
2148
+ })
2149
+ } else if (!converged) {
2150
+ findings.push({
2151
+ code: 'A13_NO_CONVERGENCE',
2152
+ severity: 'warning',
2153
+ message: 'Probability invariant "' + invariant.id + '" passes, but value iteration did not fully converge within the iteration cap.',
2154
+ evidence: { invariant, computed: probability, converged },
2155
+ })
2156
+ }
2157
+ }
2158
+ return checkResult('A13', 'Probability Reachability', findings, findings.length === 0 ? 'No probability invariants declared or all hold' : 'Probability findings: ' + findings.length)
2159
+ }
2160
+
2161
+ // ---------------------------------------------------------------------------
2162
+ // A14 — Deadline (discrete tick clock)
2163
+ // ---------------------------------------------------------------------------
2164
+
2165
+ function A14_deadline(model: LogicModelV1, options: NormalizedOptions): CheckResult {
2166
+ const findings: Finding[] = []
2167
+ const tickEvents = new Set(model.tickEvents ?? [])
2168
+ const limits = new Map<string, number>()
2169
+ let hasLimit = false
2170
+ for (const state of model.states) {
2171
+ if (state.maxTicks !== undefined) {
2172
+ limits.set(state.id, state.maxTicks)
2173
+ hasLimit = true
2174
+ }
2175
+ }
2176
+ if (!hasLimit) {
2177
+ return checkResult('A14', 'Deadline', [], 'No state declares maxTicks')
2178
+ }
2179
+ if (tickEvents.size === 0) {
2180
+ return checkResult('A14', 'Deadline', [{
2181
+ code: 'A14_NO_TICK_EVENTS',
2182
+ severity: 'warning',
2183
+ message: 'States declare maxTicks, but no tickEvents are declared, so deadline compliance cannot be verified.',
2184
+ }], 'Missing tick events')
2185
+ }
2186
+ const groupMap = groupTransitions(model)
2187
+ const init = initialState(model)
2188
+ const limitOf = (stateId: string): number | undefined => limits.get(stateId)
2189
+ const queue: Array<{ runtime: RuntimeState; ticks: number; path: PathStep[] }> = [{ runtime: init, ticks: 0, path: [] }]
2190
+ const seen = new Set<string>()
2191
+ let steps = 0
2192
+ while (queue.length > 0) {
2193
+ const entry = queue.shift()!
2194
+ if (++steps > options.maxStates) break
2195
+ const key = runtimeKey(entry.runtime) + '|' + entry.ticks
2196
+ if (seen.has(key)) continue
2197
+ seen.add(key)
2198
+ for (const event of allEvents(model)) {
2199
+ const group = groupMap.get(entry.runtime.state + '|' + event)
2200
+ if (group === undefined || group.length === 0) continue
2201
+ for (const transition of applicableTransitions(group, entry.runtime)) {
2202
+ const next = applyUpdates(model, transition, entry.runtime)
2203
+ const isTick = tickEvents.has(event)
2204
+ const stays = next.state === entry.runtime.state
2205
+ const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }]
2206
+ let ticks: number
2207
+ if (isTick && stays) {
2208
+ const lim = limitOf(next.state)
2209
+ if (lim !== undefined && entry.ticks + 1 > lim) {
2210
+ findings.push({
2211
+ code: 'A14_DEADLINE_MISS',
2212
+ severity: 'error',
2213
+ message: 'Deadline missed: state ' + next.state + ' can remain resident for more than ' + lim + ' tick(s).',
2214
+ path,
2215
+ evidence: { state: next.state, maxTicks: lim, ticks: entry.ticks + 1 },
2216
+ })
2217
+ continue
2218
+ }
2219
+ ticks = entry.ticks + 1
2220
+ } else if (isTick && !stays) {
2221
+ ticks = 0
2222
+ } else if (!isTick && stays) {
2223
+ ticks = entry.ticks
2224
+ } else {
2225
+ ticks = 0
2226
+ }
2227
+ const nextKey = runtimeKey(next) + '|' + ticks
2228
+ if (!seen.has(nextKey)) queue.push({ runtime: next, ticks, path })
2229
+ }
2230
+ }
2231
+ }
2232
+ return checkResult('A14', 'Deadline', findings, findings.length === 0 ? 'All deadlines respected' : 'Deadline findings: ' + findings.length)
2233
+ }
2234
+
2235
+ // ---------------------------------------------------------------------------
2236
+ // Coverage notes (informational gap notices)
2237
+ // ---------------------------------------------------------------------------
2238
+
2239
+ // Underscore-separated identifiers like watchdog_timeout or isr_cmd are common in
2240
+ // state machines, so boundaries must treat '_' as a separator rather than a word char.
2241
+ const TIMING_VOCABULARY = /(?<![a-z0-9])(timeout|watchdog|timer|tick|deadline|period|delay|elapsed|latency)(?![a-z0-9])/i
2242
+ const PREEMPTION_VOCABULARY = /(?<![a-z0-9])(isr|irq|interrupt|task|thread|preempt|rtos)(?![a-z0-9])/i
2243
+ const HYBRID_VOCABULARY = /(?<![a-z0-9])(pid|plant|feedback|control.?loop|stability|stable|settling|damping|oscillat|chatter|kalman|foc|field.?weaken|motor|torque)(?![a-z0-9])/i
2244
+ const PROBABILISTIC_VOCABULARY = /(?<![a-z0-9])(mtbf|mttf|failure.?rate|reliability|probability|probabilistic|markov|stochastic|fault.?tree|fmea)(?![a-z0-9])/i
2245
+
2246
+ function computeCoverageNotes(model: LogicModelV1): string[] {
2247
+ const names: string[] = []
2248
+ for (const state of model.states) {
2249
+ names.push(state.id)
2250
+ for (const action of state.onEntry ?? []) names.push(action)
2251
+ for (const action of state.onExit ?? []) names.push(action)
2252
+ }
2253
+ for (const transition of model.transitions) names.push(transition.event)
2254
+ const corpus = names.join(' ')
2255
+ const notes: string[] = []
2256
+ if (TIMING_VOCABULARY.test(corpus)) {
2257
+ notes.push('The model references time-like vocabulary (timeout/watchdog/timer/deadline...). logicprobe verifies ordering, counts, and path budgets (A12) but not hard real-time semantics: deadlines, periods, and clock invariants need a timed model checker (e.g. UPPAAL).')
2258
+ }
2259
+ if (PREEMPTION_VOCABULARY.test(corpus)) {
2260
+ notes.push('The model references preemption/concurrency vocabulary (ISR/IRQ/task/interrupt...). logicprobe models event-order interleavings (A2/A3) but not preemptive concurrency; absolute claims such as thread-safe or interrupt-safe need dedicated verification (TSan, CBMC, or a model checker such as TLA+).')
2261
+ }
2262
+ if (HYBRID_VOCABULARY.test(corpus)) {
2263
+ notes.push('The model references control/hybrid vocabulary (pid/plant/feedback/stability/motor...). logicprobe verifies discrete transitions only; stability, settling time, and mode-switch dynamics over a continuous plant need hybrid verification (SpaceEx, Flow*, or Simulink/Stateflow analysis).')
2264
+ }
2265
+ if (PROBABILISTIC_VOCABULARY.test(corpus)) {
2266
+ notes.push('The model references probabilistic/reliability vocabulary (mtbf/failure rate/probability...). logicprobe is a qualitative model checker; reliability or probability claims need a stochastic model checker (PRISM, Storm) or fault-tree analysis.')
2267
+ }
2268
+ return notes
2269
+ }
2270
+
2271
+ // ---------------------------------------------------------------------------
2272
+ // main entry
2273
+ // ---------------------------------------------------------------------------
2274
+
2275
+ export function runVerification(input: unknown, options: VerificationOptions = {}): VerificationReport {
2276
+ const normalized: NormalizedOptions = {
2277
+ maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
2278
+ maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
2279
+ }
2280
+ const validation = validateModel(input)
2281
+ if (!validation.ok) {
2282
+ return {
2283
+ ok: false,
2284
+ schemaVersion: 1,
2285
+ modelHash: '',
2286
+ summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
2287
+ checks: [{
2288
+ id: 'MODEL',
2289
+ name: 'Model Validation',
2290
+ status: 'fail',
2291
+ detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
2292
+ findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
2293
+ }],
2294
+ }
2295
+ }
2296
+ const model = validation.model
2297
+ const exploration = explore(model, normalized)
2298
+ const checks: CheckResult[] = [
2299
+ S1_reachability(model, exploration),
2300
+ S2_deadlock(model),
2301
+ S3_liveness(model),
2302
+ S4_determinism(model),
2303
+ S5_eventCompleteness(model),
2304
+ S6_guardCompleteness(model),
2305
+ S7_invariants(model, normalized),
2306
+ S8_monotonicVariables(model),
2307
+ A1_unexpectedEvents(model),
2308
+ A2_raceInterleaving(model, exploration),
2309
+ A3_orderPermutation(model, normalized),
2310
+ A4_pairSymmetry(model),
2311
+ A5_boundaryBlast(model),
2312
+ A6_resourceInjection(model),
2313
+ A7_shortestViolations(model, normalized),
2314
+ A8_idempotentReplay(model, exploration),
2315
+ A9_leadsTo(model, exploration),
2316
+ A10_sequenceOrder(model, normalized),
2317
+ A11_atomicity(model, normalized),
2318
+ A12_budget(model, normalized),
2319
+ A13_probability(model, normalized),
2320
+ A14_deadline(model, normalized),
2321
+ ]
2322
+ let comparison: ComparisonSummary | undefined
2323
+ if (options.beforeModel !== undefined) {
2324
+ const beforeValidation = validateModel(options.beforeModel)
2325
+ if (!beforeValidation.ok) {
2326
+ return {
2327
+ ok: false,
2328
+ schemaVersion: 1,
2329
+ modelHash: modelHash(model),
2330
+ summary: {
2331
+ states: model.states.length,
2332
+ transitions: model.transitions.length,
2333
+ errors: beforeValidation.errors.length,
2334
+ warnings: 0,
2335
+ checksRun: checks.length + 1,
2336
+ truncated: exploration.truncated,
2337
+ },
2338
+ checks: [
2339
+ ...checks,
2340
+ {
2341
+ id: 'BEFORE_MODEL',
2342
+ name: 'Before Model Validation',
2343
+ status: 'fail',
2344
+ detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
2345
+ findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
2346
+ },
2347
+ ],
2348
+ }
2349
+ }
2350
+ const before = beforeValidation.model
2351
+ const mapping = options.stateMapping ?? {}
2352
+ checks.push(
2353
+ D1_behavioralPreservation(before, model, mapping),
2354
+ D2_invariantContinuity(before, model, normalized, mapping),
2355
+ D3_regressionDelta(before, model, mapping),
2356
+ D4_deadlockLivenessRegression(before, model, mapping),
2357
+ )
2358
+ comparison = buildComparisonSummary(before, model, mapping)
2359
+ }
2360
+ const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
2361
+ const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
2362
+ const coverageNotes = computeCoverageNotes(model)
2363
+ return {
2364
+ ok: true,
2365
+ schemaVersion: 1,
2366
+ modelHash: modelHash(model),
2367
+ summary: {
2368
+ states: model.states.length,
2369
+ transitions: model.transitions.length,
2370
+ errors,
2371
+ warnings,
2372
+ checksRun: checks.length,
2373
+ truncated: exploration.truncated,
2374
+ },
2375
+ checks,
2376
+ ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
2377
+ ...(comparison === undefined ? {} : { comparison }),
2378
+ ...(coverageNotes.length === 0 ? {} : { coverageNotes }),
2379
+ }
2380
+ }
2381
+
2382
+ // ---------------------------------------------------------------------------
2383
+ // Composition verification (N machines, D6)
2384
+ // ---------------------------------------------------------------------------
2385
+
2386
+ export interface CompositionStep {
2387
+ event: string
2388
+ /** Indices of the machines that advanced together on this step. */
2389
+ machines: number[]
2390
+ }
2391
+
2392
+ export interface CompositionOptions {
2393
+ /** Events that require a synchronized multi-machine step (handshake). */
2394
+ rendezvous?: string[]
2395
+ maxStates?: number
2396
+ }
2397
+
2398
+ export interface CompositionSummary {
2399
+ machineCount: number
2400
+ machines: Array<{ modelHash: string; states: number; transitions: number }>
2401
+ compositeStates: number
2402
+ errors: number
2403
+ warnings: number
2404
+ truncated: boolean
2405
+ }
2406
+
2407
+ export interface CompositionReport {
2408
+ ok: boolean
2409
+ summary: CompositionSummary
2410
+ checks: CheckResult[]
2411
+ }
2412
+
2413
+ interface CompositionNode {
2414
+ runtimes: RuntimeState[]
2415
+ path: CompositionStep[]
2416
+ }
2417
+
2418
+ interface CompositionMove {
2419
+ next: CompositionNode
2420
+ event: string
2421
+ machines: number[]
2422
+ }
2423
+
2424
+ /**
2425
+ * N-machine composition semantics (documented in dsh-model-schema.md):
2426
+ * - a non-rendezvous event advances exactly ONE non-terminal machine that fires it;
2427
+ * - a rendezvous event (handshake) fires only when AT LEAST TWO machines declare it in
2428
+ * their alphabet and EVERY such non-terminal machine has it enabled (guards held);
2429
+ * all participants then advance simultaneously. A machine that is terminal, or whose
2430
+ * alphabet does not include the event, does not participate;
2431
+ * - a terminal machine is stopped and takes no further part.
2432
+ * Checks: C1 composition deadlock (reachable node where no machine can advance while at
2433
+ * least one is not terminal) and C2 rendezvous that can never fire.
2434
+ */
2435
+ export function runCompositionVerification(machinesInput: unknown[], options: CompositionOptions = {}): CompositionReport {
2436
+ const models: LogicModelV1[] = []
2437
+ const hashes: string[] = []
2438
+ const modelFindings: Finding[] = []
2439
+ machinesInput.forEach((input, index) => {
2440
+ const validation = validateModel(input)
2441
+ if (!validation.ok) {
2442
+ modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'machine ' + index + ' invalid: ' + validation.errors.join('; ') })
2443
+ } else {
2444
+ models.push(validation.model)
2445
+ hashes.push(modelHash(validation.model))
2446
+ }
2447
+ })
2448
+ const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }))
2449
+ if (modelFindings.length > 0 || models.length < 2) {
2450
+ if (models.length < 2 && modelFindings.length === 0) {
2451
+ modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'composition requires at least two machines' })
2452
+ }
2453
+ return {
2454
+ ok: false,
2455
+ summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
2456
+ checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
2457
+ }
2458
+ }
2459
+ const rendezvous = new Set(options.rendezvous ?? [])
2460
+ const maxStates = options.maxStates ?? DEFAULT_MAX_STATES
2461
+ const machineEventSets = models.map((model) => new Set(model.transitions.map((transition) => transition.event)))
2462
+
2463
+ const compositionMoves = (node: CompositionNode): CompositionMove[] => {
2464
+ const moves: CompositionMove[] = []
2465
+ // single-machine (non-rendezvous) steps
2466
+ for (let i = 0; i < models.length; i++) {
2467
+ if (isTerminal(models[i], node.runtimes[i].state)) continue
2468
+ for (const event of allEvents(models[i])) {
2469
+ if (rendezvous.has(event)) continue
2470
+ for (const next of stepRuntime(models[i], node.runtimes[i], event)) {
2471
+ const runtimes = node.runtimes.slice()
2472
+ runtimes[i] = next
2473
+ moves.push({ next: { runtimes, path: [] }, event, machines: [i] })
2474
+ }
2475
+ }
2476
+ }
2477
+ // synchronized rendezvous steps (>= 2 participants, all enabled)
2478
+ for (const event of rendezvous) {
2479
+ const participants: number[] = []
2480
+ for (let i = 0; i < models.length; i++) {
2481
+ if (isTerminal(models[i], node.runtimes[i].state)) continue
2482
+ if (!machineEventSets[i].has(event)) continue
2483
+ participants.push(i)
2484
+ }
2485
+ if (participants.length < 2) continue // handshake needs a partner
2486
+ const outcomes = participants.map((i) => stepRuntime(models[i], node.runtimes[i], event))
2487
+ if (outcomes.some((list) => list.length === 0)) continue // not all participants ready
2488
+ let combos: Array<RuntimeState[]> = [node.runtimes.slice()]
2489
+ participants.forEach((i, slot) => {
2490
+ const nextCombos: Array<RuntimeState[]> = []
2491
+ for (const combo of combos) {
2492
+ for (const outcome of outcomes[slot]) {
2493
+ const copy = combo.slice()
2494
+ copy[i] = outcome
2495
+ nextCombos.push(copy)
2496
+ }
2497
+ }
2498
+ combos = nextCombos
2499
+ })
2500
+ for (const runtimes of combos) {
2501
+ moves.push({ next: { runtimes, path: [] }, event, machines: participants })
2502
+ }
2503
+ }
2504
+ return moves
2505
+ }
2506
+
2507
+ const keyOf = (node: CompositionNode): string => node.runtimes.map((runtime) => runtimeKey(runtime)).join('|')
2508
+ const init: CompositionNode = { runtimes: models.map((model) => initialState(model)), path: [] }
2509
+ const visited = new Set<string>()
2510
+ const queue: CompositionNode[] = [init]
2511
+ let compositeStates = 0
2512
+ let truncated = false
2513
+ const firedCount = new Map<string, number>()
2514
+ for (const event of rendezvous) firedCount.set(event, 0)
2515
+ const c1Findings: Finding[] = []
2516
+ while (queue.length > 0) {
2517
+ const node = queue.shift()!
2518
+ const key = keyOf(node)
2519
+ if (visited.has(key)) continue
2520
+ visited.add(key)
2521
+ compositeStates += 1
2522
+ if (compositeStates > maxStates) {
2523
+ truncated = true
2524
+ break
2525
+ }
2526
+ const moves = compositionMoves(node)
2527
+ const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state))
2528
+ if (moves.length === 0 && !allTerminal) {
2529
+ c1Findings.push({
2530
+ code: 'C1_COMPOSITION_DEADLOCK',
2531
+ severity: 'error',
2532
+ message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
2533
+ evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state) },
2534
+ })
2535
+ continue
2536
+ }
2537
+ for (const move of moves) {
2538
+ if (move.machines.length > 1) firedCount.set(move.event, (firedCount.get(move.event) ?? 0) + 1)
2539
+ const nextKey = keyOf(move.next)
2540
+ if (visited.has(nextKey)) continue
2541
+ queue.push({ runtimes: move.next.runtimes, path: [...node.path, { event: move.event, machines: move.machines }] })
2542
+ }
2543
+ }
2544
+ const c2Findings: Finding[] = []
2545
+ const allEventsSet = new Set<string>()
2546
+ for (const set of machineEventSets) for (const event of set) allEventsSet.add(event)
2547
+ for (const event of rendezvous) {
2548
+ if (!allEventsSet.has(event)) continue
2549
+ if ((firedCount.get(event) ?? 0) === 0) {
2550
+ c2Findings.push({
2551
+ code: 'C2_RENDEZVOUS_NEVER_FIRES',
2552
+ severity: 'warning',
2553
+ message: 'Rendezvous event ' + event + ' can never fire: fewer than two machines ever jointly enable it.',
2554
+ })
2555
+ }
2556
+ }
2557
+ const errors = c1Findings.length
2558
+ const warnings = c2Findings.length
2559
+ const checks: CheckResult[] = [
2560
+ checkResult('C1', 'Composition Deadlock', c1Findings, c1Findings.length === 0 ? 'No composition deadlock reachable' : 'Composition deadlocks: ' + c1Findings.length),
2561
+ checkResult('C2', 'Rendezvous Sync', c2Findings, c2Findings.length === 0 ? 'All rendezvous events can fire' : 'Rendezvous warnings: ' + c2Findings.length),
2562
+ ]
2563
+ return {
2564
+ ok: errors === 0,
2565
+ summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
2566
+ checks,
2567
+ }
2568
+ }