dsh-logicprobe 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3028 @@
1
+ #!/usr/bin/env python3
2
+ """logicprobe-engine.py — standalone JSON-driven mirror of the logicprobe DSH engine.
3
+
4
+ Reads the same LogicModelV1 JSON schema as the DSH `logicprobe_verify` /
5
+ `logicprobe_compose_verify` / `logicprobe_export` tools and runs the same
6
+ checks (S1-S8 structural, A1-A14 adversarial, D1-D4 before/after regression,
7
+ C1/C2 composition) plus the four external-tool exporters (UPPAAL / TLA+ /
8
+ PRISM / SPIN). Pure stdlib, no third-party imports.
9
+
10
+ Usage:
11
+ python logicprobe-engine.py verify model.json [--before-model before.json]
12
+ [--state-mapping map.json] [--max-states N] [--max-permutation-events N]
13
+ python logicprobe-engine.py compose m1.json m2.json [m3.json ...]
14
+ [--rendezvous ev1,ev2] [--max-states N]
15
+ python logicprobe-engine.py export model.json --format uppaal|tla|prism|spin
16
+
17
+ Output: JSON verification/composition report on stdout (same shape as the DSH
18
+ tool result), or the export result JSON (format / primary / extras / warnings).
19
+ """
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import math
24
+ import re
25
+ import sys
26
+ from collections import deque
27
+
28
+ ENGINE_SCHEMA_VERSION = 1
29
+ DEFAULT_MAX_STATES = 10000
30
+ DEFAULT_MAX_PERMUTATION_EVENTS = 5
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # lossless JS-compatible helpers
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def js_number(value):
38
+ """Render a number the way JavaScript's String(number) does for the value
39
+ ranges logicprobe models use (small integers and finite decimals)."""
40
+ if isinstance(value, bool):
41
+ return '1' if value else '0'
42
+ if isinstance(value, int):
43
+ return str(value)
44
+ # float that is integral renders without a decimal point in JS
45
+ if value.is_integer() and abs(value) < 1e21:
46
+ return str(int(value))
47
+ return repr(value)
48
+
49
+
50
+ def js_stringify(value, sort_keys=False):
51
+ """JSON.stringify with sorted or insertion-order keys (no spaces), kept
52
+ stable so model hashes match the TypeScript engine's stableStringify."""
53
+ if value is None:
54
+ return 'null'
55
+ if value is True:
56
+ return 'true'
57
+ if value is False:
58
+ return 'false'
59
+ if isinstance(value, (int, float)):
60
+ # JS JSON.stringify(1.0) -> '1'; python str(float) differs, so reuse js_number
61
+ return js_number(value) if isinstance(value, float) else str(value)
62
+ if isinstance(value, str):
63
+ return json.dumps(value, ensure_ascii=False)
64
+ if isinstance(value, (list, tuple)):
65
+ return '[' + ','.join(js_stringify(item, sort_keys) for item in value) + ']'
66
+ if isinstance(value, dict):
67
+ keys = sorted(value.keys()) if sort_keys else list(value.keys())
68
+ return '{' + ','.join(js_stringify(str(k), sort_keys) + ':' + js_stringify(value[k], sort_keys) for k in keys) + '}'
69
+ return 'null'
70
+
71
+
72
+ def stable_stringify(value):
73
+ return js_stringify(value, sort_keys=True)
74
+
75
+
76
+ def model_hash(model):
77
+ return hashlib.sha256(stable_stringify(model).encode('utf-8')).hexdigest()
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Validation
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def _is_plain_object(value):
85
+ return isinstance(value, dict)
86
+
87
+
88
+ def validate_model(input_value):
89
+ """Mirror of validateModel: returns (ok, model_or_errors)."""
90
+ errors = []
91
+
92
+ def bad(path, message):
93
+ errors.append(path + ': ' + message)
94
+
95
+ if not _is_plain_object(input_value):
96
+ return (False, ['model: must be an object'])
97
+ root = input_value
98
+ if root.get('schemaVersion') != 1:
99
+ bad('schemaVersion', 'must be 1')
100
+ init = root.get('init')
101
+ if not isinstance(init, str) or len(init) == 0:
102
+ bad('init', 'must be a non-empty string')
103
+ states = root.get('states')
104
+ if not isinstance(states, list) or len(states) == 0:
105
+ bad('states', 'must be a non-empty array')
106
+ else:
107
+ seen = set()
108
+ for index, entry in enumerate(states):
109
+ if not _is_plain_object(entry):
110
+ bad('states[' + str(index) + ']', 'must be an object')
111
+ continue
112
+ sid = entry.get('id')
113
+ if not isinstance(sid, str) or len(sid) == 0:
114
+ bad('states[' + str(index) + '].id', 'must be a non-empty string')
115
+ elif sid in seen:
116
+ bad('states[' + str(index) + '].id', 'duplicate state id ' + str(sid))
117
+ else:
118
+ seen.add(sid)
119
+ terminal = entry.get('terminal')
120
+ if terminal is not None and not isinstance(terminal, bool):
121
+ bad('states[' + str(index) + '].terminal', 'must be a boolean')
122
+ for kind in ('onEntry', 'onExit'):
123
+ actions = entry.get(kind)
124
+ if actions is None:
125
+ continue
126
+ if not isinstance(actions, list):
127
+ bad('states[' + str(index) + '].' + kind, 'must be an array of action names')
128
+ elif len(actions) > 64:
129
+ bad('states[' + str(index) + '].' + kind, 'must not exceed 64 actions')
130
+ else:
131
+ for action_index, action in enumerate(actions):
132
+ if not isinstance(action, str) or len(action) == 0:
133
+ bad('states[' + str(index) + '].' + kind + '[' + str(action_index) + ']', 'must be a non-empty string')
134
+ if isinstance(init, str) and len(init) > 0 and init not in seen:
135
+ bad('init', 'must name a declared state')
136
+ transitions = root.get('transitions')
137
+ if not isinstance(transitions, list):
138
+ bad('transitions', 'must be an array')
139
+ else:
140
+ state_ids = set()
141
+ if isinstance(states, list):
142
+ for state in states:
143
+ if isinstance(state, dict) and isinstance(state.get('id'), str):
144
+ state_ids.add(state['id'])
145
+ for index, entry in enumerate(transitions):
146
+ p = 'transitions[' + str(index) + ']'
147
+ if not _is_plain_object(entry):
148
+ bad(p, 'must be an object')
149
+ continue
150
+ tfrom = entry.get('from')
151
+ if not isinstance(tfrom, str) or len(tfrom) == 0:
152
+ bad(p + '.from', 'must be a non-empty string')
153
+ elif tfrom not in state_ids:
154
+ bad(p + '.from', 'unknown state ' + tfrom)
155
+ tevent = entry.get('event')
156
+ if not isinstance(tevent, str) or len(tevent) == 0:
157
+ bad(p + '.event', 'must be a non-empty string')
158
+ tto = entry.get('to')
159
+ if not isinstance(tto, str) or len(tto) == 0:
160
+ bad(p + '.to', 'must be a non-empty string')
161
+ elif tto not in state_ids:
162
+ bad(p + '.to', 'unknown state ' + tto)
163
+ if entry.get('guard') is not None:
164
+ _validate_guard(entry['guard'], p + '.guard', errors, bad)
165
+ updates = entry.get('updates')
166
+ if updates is not None:
167
+ if not isinstance(updates, list):
168
+ bad(p + '.updates', 'must be an array')
169
+ else:
170
+ for update_index, update in enumerate(updates):
171
+ up = p + '.updates[' + str(update_index) + ']'
172
+ if not _is_plain_object(update):
173
+ bad(up, 'must be an object')
174
+ continue
175
+ variable = update.get('variable')
176
+ if not isinstance(variable, str) or len(variable) == 0:
177
+ bad(up + '.variable', 'must be a non-empty string')
178
+ op = update.get('op')
179
+ if op not in ('set', 'inc', 'dec'):
180
+ bad(up + '.op', "must be 'set', 'inc', or 'dec'")
181
+ value = update.get('value')
182
+ if value is not None and not isinstance(value, (int, float)) or isinstance(value, bool):
183
+ bad(up + '.value', 'must be a number')
184
+ cost = entry.get('cost')
185
+ if cost is not None and (not isinstance(cost, (int, float)) or isinstance(cost, bool) or not math.isfinite(cost) or cost < 0):
186
+ bad(p + '.cost', 'must be a non-negative finite number')
187
+ weight = entry.get('weight')
188
+ if weight is not None and (not isinstance(weight, (int, float)) or isinstance(weight, bool) or not math.isfinite(weight) or weight < 0):
189
+ bad(p + '.weight', 'must be a non-negative finite number')
190
+ if isinstance(states, list):
191
+ for index, state in enumerate(states):
192
+ if not isinstance(state, dict):
193
+ continue
194
+ mt = state.get('maxTicks')
195
+ if mt is not None and (not isinstance(mt, int) or isinstance(mt, bool) or mt < 0):
196
+ bad('states[' + str(index) + '].maxTicks', 'must be a non-negative integer')
197
+ variable_names = set()
198
+ variables = root.get('variables')
199
+ if variables is not None:
200
+ if not isinstance(variables, list):
201
+ bad('variables', 'must be an array')
202
+ else:
203
+ for index, entry in enumerate(variables):
204
+ p = 'variables[' + str(index) + ']'
205
+ if not _is_plain_object(entry):
206
+ bad(p, 'must be an object')
207
+ continue
208
+ name = entry.get('name')
209
+ if not isinstance(name, str) or len(name) == 0:
210
+ bad(p + '.name', 'must be a non-empty string')
211
+ elif name in variable_names:
212
+ bad(p + '.name', 'duplicate variable ' + name)
213
+ else:
214
+ variable_names.add(name)
215
+ kind = entry.get('kind')
216
+ if kind not in ('integer', 'boolean'):
217
+ bad(p + '.kind', "must be 'integer' or 'boolean'")
218
+ expected = 'bool' if kind == 'boolean' else 'number'
219
+ init_val = entry.get('init')
220
+ if expected == 'bool':
221
+ if not isinstance(init_val, bool):
222
+ bad(p + '.init', 'must be a boolean')
223
+ else:
224
+ if not isinstance(init_val, (int, float)) or isinstance(init_val, bool):
225
+ bad(p + '.init', 'must be a number')
226
+ mn = entry.get('min')
227
+ mx = entry.get('max')
228
+ if mn is not None and (not isinstance(mn, (int, float)) or isinstance(mn, bool)):
229
+ bad(p + '.min', 'must be a number')
230
+ if mx is not None and (not isinstance(mx, (int, float)) or isinstance(mx, bool)):
231
+ bad(p + '.max', 'must be a number')
232
+ if isinstance(mn, (int, float)) and not isinstance(mn, bool) and isinstance(mx, (int, float)) and not isinstance(mx, bool) and mn > mx:
233
+ bad(p + '.max', 'must be >= min')
234
+ mono = entry.get('monotonic')
235
+ if mono is not None and mono not in ('inc', 'dec'):
236
+ bad(p + '.monotonic', "must be 'inc' or 'dec'")
237
+ if kind == 'integer' and isinstance(init_val, (int, float)) and not isinstance(init_val, bool):
238
+ if isinstance(mn, (int, float)) and not isinstance(mn, bool) and init_val < mn:
239
+ bad(p + '.init', 'must be >= min')
240
+ if isinstance(mx, (int, float)) and not isinstance(mx, bool) and init_val > mx:
241
+ bad(p + '.init', 'must be <= max')
242
+
243
+ def validate_variable_ref(name, p):
244
+ if not isinstance(name, str) or len(name) == 0:
245
+ bad(p, 'must be a non-empty string')
246
+ elif name not in variable_names:
247
+ bad(p, 'references unknown variable ' + str(name))
248
+
249
+ invariants = root.get('invariants')
250
+ if invariants is not None:
251
+ if not isinstance(invariants, list):
252
+ bad('invariants', 'must be an array')
253
+ else:
254
+ for index, entry in enumerate(invariants):
255
+ p = 'invariants[' + str(index) + ']'
256
+ if not _is_plain_object(entry):
257
+ bad(p, 'must be an object')
258
+ continue
259
+ iid = entry.get('id')
260
+ if not isinstance(iid, str) or len(iid) == 0:
261
+ bad(p + '.id', 'must be a non-empty string')
262
+ desc = entry.get('description')
263
+ if not isinstance(desc, str):
264
+ bad(p + '.description', 'must be a string')
265
+ kind = entry.get('kind')
266
+ if kind == 'never-states':
267
+ s_list = entry.get('states')
268
+ if not isinstance(s_list, list) or len(s_list) == 0:
269
+ bad(p + '.states', 'must be a non-empty array')
270
+ else:
271
+ for s_index, state in enumerate(s_list):
272
+ if not isinstance(state, str) or len(state) == 0:
273
+ bad(p + '.states[' + str(s_index) + ']', 'must be a non-empty string')
274
+ elif kind == 'var-in-range':
275
+ validate_variable_ref(entry.get('variable'), p + '.variable')
276
+ mn = entry.get('min')
277
+ mx = entry.get('max')
278
+ if mn is not None and (not isinstance(mn, (int, float)) or isinstance(mn, bool)):
279
+ bad(p + '.min', 'must be a number')
280
+ if mx is not None and (not isinstance(mx, (int, float)) or isinstance(mx, bool)):
281
+ bad(p + '.max', 'must be a number')
282
+ elif kind == 'event-before-state':
283
+ ev = entry.get('event')
284
+ if not isinstance(ev, str) or len(ev) == 0:
285
+ bad(p + '.event', 'must be a non-empty string')
286
+ st = entry.get('state')
287
+ if not isinstance(st, str) or len(st) == 0:
288
+ bad(p + '.state', 'must be a non-empty string')
289
+ elif kind == 'leads-to':
290
+ lfrom = entry.get('from')
291
+ if not isinstance(lfrom, str) or len(lfrom) == 0:
292
+ bad(p + '.from', 'must be a non-empty string')
293
+ lto = entry.get('to')
294
+ if not isinstance(lto, str) or len(lto) == 0:
295
+ bad(p + '.to', 'must be a non-empty string')
296
+ elif kind == 'sequence':
297
+ evs = entry.get('events')
298
+ if not isinstance(evs, list) or len(evs) == 0:
299
+ bad(p + '.events', 'must be a non-empty array')
300
+ else:
301
+ for e_index, ev in enumerate(evs):
302
+ if not isinstance(ev, str) or len(ev) == 0:
303
+ bad(p + '.events[' + str(e_index) + ']', 'must be a non-empty string')
304
+ elif kind == 'atomicity':
305
+ evs = entry.get('events')
306
+ if not isinstance(evs, list) or len(evs) == 0:
307
+ bad(p + '.events', 'must be a non-empty array')
308
+ else:
309
+ for e_index, ev in enumerate(evs):
310
+ if not isinstance(ev, str) or len(ev) == 0:
311
+ bad(p + '.events[' + str(e_index) + ']', 'must be a non-empty string')
312
+ cm = entry.get('commit')
313
+ if not isinstance(cm, str) or len(cm) == 0:
314
+ bad(p + '.commit', 'must be a non-empty string')
315
+ rb = entry.get('rollback')
316
+ if rb is not None and not isinstance(rb, str):
317
+ bad(p + '.rollback', 'must be a string')
318
+ elif kind == 'budget':
319
+ budget = entry.get('budget')
320
+ if budget is None or not isinstance(budget, (int, float)) or isinstance(budget, bool) or not math.isfinite(budget) or budget < 0:
321
+ bad(p + '.budget', 'must be a non-negative finite number')
322
+ elif kind == 'probability':
323
+ target = entry.get('target')
324
+ if not isinstance(target, str) or len(target) == 0:
325
+ bad(p + '.target', 'must be a non-empty string')
326
+ op = entry.get('op')
327
+ if op not in ('>=', '<=', '>', '<'):
328
+ bad(p + '.op', "must be one of '>=', '<=', '>', '<'")
329
+ pv = entry.get('p')
330
+ if pv is None or not isinstance(pv, (int, float)) or isinstance(pv, bool) or not math.isfinite(pv) or pv < 0 or pv > 1:
331
+ bad(p + '.p', 'must be a number in [0, 1]')
332
+ else:
333
+ bad(p + '.kind', 'unknown invariant kind')
334
+
335
+ concurrent_pairs = root.get('concurrentPairs')
336
+ if concurrent_pairs is not None:
337
+ if not isinstance(concurrent_pairs, list):
338
+ bad('concurrentPairs', 'must be an array')
339
+ else:
340
+ for index, entry in enumerate(concurrent_pairs):
341
+ p = 'concurrentPairs[' + str(index) + ']'
342
+ if not (isinstance(entry, list) and len(entry) == 2 and isinstance(entry[0], str) and isinstance(entry[1], str)):
343
+ bad(p, 'must be a [event, event] string pair')
344
+ boundary_checks = root.get('boundaryChecks')
345
+ if boundary_checks is not None:
346
+ if not isinstance(boundary_checks, list):
347
+ bad('boundaryChecks', 'must be an array')
348
+ else:
349
+ for index, entry in enumerate(boundary_checks):
350
+ p = 'boundaryChecks[' + str(index) + ']'
351
+ if not _is_plain_object(entry):
352
+ bad(p, 'must be an object')
353
+ continue
354
+ validate_variable_ref(entry.get('variable'), p + '.variable')
355
+ values = entry.get('values')
356
+ if not isinstance(values, list) or any(not isinstance(v, (int, float)) or isinstance(v, bool) for v in values):
357
+ bad(p + '.values', 'must be an array of numbers')
358
+ idem_events = root.get('idempotentEvents')
359
+ if idem_events is not None:
360
+ if not isinstance(idem_events, list):
361
+ bad('idempotentEvents', 'must be an array')
362
+ else:
363
+ for index, entry in enumerate(idem_events):
364
+ if not isinstance(entry, str) or len(entry) == 0:
365
+ bad('idempotentEvents[' + str(index) + ']', 'must be a non-empty string')
366
+ tick_events = root.get('tickEvents')
367
+ if tick_events is not None:
368
+ if not isinstance(tick_events, list):
369
+ bad('tickEvents', 'must be an array')
370
+ else:
371
+ for index, entry in enumerate(tick_events):
372
+ if not isinstance(entry, str) or len(entry) == 0:
373
+ bad('tickEvents[' + str(index) + ']', 'must be a non-empty string')
374
+ resource_pairs = root.get('resourcePairs')
375
+ if resource_pairs is not None:
376
+ if not isinstance(resource_pairs, list):
377
+ bad('resourcePairs', 'must be an array')
378
+ else:
379
+ for index, entry in enumerate(resource_pairs):
380
+ p = 'resourcePairs[' + str(index) + ']'
381
+ if not _is_plain_object(entry):
382
+ bad(p, 'must be an object')
383
+ continue
384
+ resource = entry.get('resource')
385
+ if not isinstance(resource, str) or len(resource) == 0:
386
+ bad(p + '.resource', 'must be a non-empty string')
387
+ aq = entry.get('acquireEvent')
388
+ if not isinstance(aq, str) or len(aq) == 0:
389
+ bad(p + '.acquireEvent', 'must be a non-empty string')
390
+ rel = entry.get('releaseEvent')
391
+ if not isinstance(rel, str) or len(rel) == 0:
392
+ bad(p + '.releaseEvent', 'must be a non-empty string')
393
+ fail = entry.get('failEvent')
394
+ if fail is not None and not isinstance(fail, str):
395
+ bad(p + '.failEvent', 'must be a string')
396
+ narrative = root.get('narrative')
397
+ if narrative is not None:
398
+ np_ = 'narrative'
399
+ if not _is_plain_object(narrative):
400
+ bad(np_, 'must be an object')
401
+ else:
402
+ state_ids = set()
403
+ if isinstance(states, list):
404
+ for state in states:
405
+ if isinstance(state, dict) and isinstance(state.get('id'), str):
406
+ state_ids.add(state['id'])
407
+ event_ids = set()
408
+ if isinstance(transitions, list):
409
+ for transition in transitions:
410
+ if isinstance(transition, dict) and isinstance(transition.get('event'), str):
411
+ event_ids.add(transition['event'])
412
+ from_event_groups = set()
413
+ if isinstance(transitions, list):
414
+ for transition in transitions:
415
+ if isinstance(transition, dict) and isinstance(transition.get('from'), str) and isinstance(transition.get('event'), str):
416
+ from_event_groups.add(transition['from'] + '|' + transition['event'])
417
+ nstates = narrative.get('states')
418
+ if not _is_plain_object(nstates):
419
+ bad(np_ + '.states', 'must be an object mapping state id -> natural-language description')
420
+ else:
421
+ for sid, description in nstates.items():
422
+ if sid not in state_ids:
423
+ bad(np_ + '.states', 'references unknown state ' + str(sid))
424
+ if not isinstance(description, str) or len(description) == 0:
425
+ bad(np_ + '.states.' + str(sid), 'must be a non-empty string')
426
+ for sid in state_ids:
427
+ if not isinstance(nstates.get(sid), str):
428
+ bad(np_ + '.states', 'missing description for state ' + str(sid))
429
+ nevents = narrative.get('events')
430
+ if not _is_plain_object(nevents):
431
+ bad(np_ + '.events', 'must be an object mapping event id -> natural-language description')
432
+ else:
433
+ for eid, description in nevents.items():
434
+ if eid not in event_ids:
435
+ bad(np_ + '.events', 'references unknown event ' + str(eid))
436
+ if not isinstance(description, str) or len(description) == 0:
437
+ bad(np_ + '.events.' + str(eid), 'must be a non-empty string')
438
+ for eid in event_ids:
439
+ if not isinstance(nevents.get(eid), str):
440
+ bad(np_ + '.events', 'missing description for event ' + str(eid))
441
+ scenarios = narrative.get('scenarios')
442
+ if not isinstance(scenarios, list):
443
+ bad(np_ + '.scenarios', 'must be an array of { from, event, scenario }')
444
+ else:
445
+ seen = set()
446
+ for index, entry in enumerate(scenarios):
447
+ sp = np_ + '.scenarios[' + str(index) + ']'
448
+ if not _is_plain_object(entry):
449
+ bad(sp, 'must be an object')
450
+ continue
451
+ sfrom = entry.get('from')
452
+ if not isinstance(sfrom, str) or len(sfrom) == 0:
453
+ bad(sp + '.from', 'must be a non-empty string')
454
+ elif sfrom not in state_ids:
455
+ bad(sp + '.from', 'unknown state ' + str(sfrom))
456
+ sevent = entry.get('event')
457
+ if not isinstance(sevent, str) or len(sevent) == 0:
458
+ bad(sp + '.event', 'must be a non-empty string')
459
+ elif sevent not in event_ids:
460
+ bad(sp + '.event', 'unknown event ' + str(sevent))
461
+ scenario = entry.get('scenario')
462
+ if not isinstance(scenario, str) or len(scenario) == 0:
463
+ bad(sp + '.scenario', 'must be a non-empty string')
464
+ key = str(sfrom) + '|' + str(sevent)
465
+ if key in seen:
466
+ bad(sp, 'duplicate scenario for (' + str(sfrom) + ', ' + str(sevent) + ')')
467
+ seen.add(key)
468
+ for key in from_event_groups:
469
+ if key not in seen:
470
+ sep = key.find('|')
471
+ bad(np_ + '.scenarios', 'missing scenario for (' + key[:sep] + ', ' + key[sep + 1:] + ')')
472
+ # guard/update variable reference walk
473
+ if isinstance(transitions, list):
474
+ for entry in transitions:
475
+ if not isinstance(entry, dict):
476
+ continue
477
+ _walk_guard_references(entry.get('guard'), variable_names, errors, bad)
478
+ for update in entry.get('updates') or []:
479
+ if not isinstance(update, dict):
480
+ continue
481
+ variable = update.get('variable')
482
+ if variable not in variable_names:
483
+ bad('transitions.updates', 'references unknown variable ' + str(variable))
484
+ if errors:
485
+ return (False, errors)
486
+ return (True, root)
487
+
488
+
489
+ def _validate_guard(input_value, p, errors, bad):
490
+ if not _is_plain_object(input_value):
491
+ bad(p, 'must be an object')
492
+ return
493
+ guard = input_value
494
+ if 'variable' in guard:
495
+ variable = guard.get('variable')
496
+ if not isinstance(variable, str) or len(variable) == 0:
497
+ bad(p + '.variable', 'must be a non-empty string')
498
+ op = guard.get('op')
499
+ if op not in ('==', '!=', '<', '<=', '>', '>='):
500
+ bad(p + '.op', 'must be a comparison operator')
501
+ value = guard.get('value')
502
+ if not isinstance(value, (int, float, bool)):
503
+ bad(p + '.value', 'must be a number or boolean')
504
+ return
505
+ if 'all' in guard:
506
+ all_list = guard.get('all')
507
+ if not isinstance(all_list, list):
508
+ bad(p + '.all', 'must be an array')
509
+ else:
510
+ for index, child in enumerate(all_list):
511
+ _validate_guard(child, p + '.all[' + str(index) + ']', errors, bad)
512
+ return
513
+ if 'any' in guard:
514
+ any_list = guard.get('any')
515
+ if not isinstance(any_list, list):
516
+ bad(p + '.any', 'must be an array')
517
+ else:
518
+ for index, child in enumerate(any_list):
519
+ _validate_guard(child, p + '.any[' + str(index) + ']', errors, bad)
520
+ return
521
+ if 'not' in guard:
522
+ _validate_guard(guard.get('not'), p + '.not', errors, bad)
523
+ return
524
+ bad(p, 'must be a leaf ({ variable, op, value }), { all }, { any }, or { not }')
525
+
526
+
527
+ def _walk_guard_references(guard, variable_names, errors, bad):
528
+ if guard is None:
529
+ return
530
+ if 'variable' in guard:
531
+ variable = guard.get('variable')
532
+ if variable not in variable_names:
533
+ bad('guard', 'references unknown variable ' + str(variable))
534
+ return
535
+ if 'all' in guard:
536
+ for child in guard['all']:
537
+ _walk_guard_references(child, variable_names, errors, bad)
538
+ return
539
+ if 'any' in guard:
540
+ for child in guard['any']:
541
+ _walk_guard_references(child, variable_names, errors, bad)
542
+ return
543
+ if 'not' in guard:
544
+ _walk_guard_references(guard['not'], variable_names, errors, bad)
545
+
546
+
547
+ # ---------------------------------------------------------------------------
548
+ # execution
549
+ # ---------------------------------------------------------------------------
550
+
551
+ def _is_leaf_guard(guard):
552
+ return 'variable' in guard
553
+
554
+
555
+ def guard_variables(guard):
556
+ if guard is None:
557
+ return []
558
+ if _is_leaf_guard(guard):
559
+ return [guard['variable']]
560
+ if 'all' in guard:
561
+ out = []
562
+ for child in guard['all']:
563
+ out.extend(guard_variables(child))
564
+ return out
565
+ if 'any' in guard:
566
+ out = []
567
+ for child in guard['any']:
568
+ out.extend(guard_variables(child))
569
+ return out
570
+ if 'not' in guard:
571
+ return guard_variables(guard['not'])
572
+ return []
573
+
574
+
575
+ def _eval_guard(guard, vars_map):
576
+ if _is_leaf_guard(guard):
577
+ actual = vars_map.get(guard['variable'])
578
+ op = guard['op']
579
+ value = guard['value']
580
+ if op == '==':
581
+ return actual == value
582
+ if op == '!=':
583
+ return actual != value
584
+ if op == '<':
585
+ return actual < value
586
+ if op == '<=':
587
+ return actual <= value
588
+ if op == '>':
589
+ return actual > value
590
+ if op == '>=':
591
+ return actual >= value
592
+ return False
593
+ if 'all' in guard:
594
+ return all(_eval_guard(child, vars_map) for child in guard['all'])
595
+ if 'any' in guard:
596
+ return any(_eval_guard(child, vars_map) for child in guard['any'])
597
+ if 'not' in guard:
598
+ return not _eval_guard(guard['not'], vars_map)
599
+ return False
600
+
601
+
602
+ def _initial_state(model):
603
+ vars_map = {}
604
+ for variable in model.get('variables') or []:
605
+ vars_map[variable['name']] = variable['init']
606
+ return {'state': model['init'], 'vars': vars_map}
607
+
608
+
609
+ def _runtime_key(runtime):
610
+ return runtime['state'] + '|' + stable_stringify(runtime['vars'])
611
+
612
+
613
+ def _apply_updates(model, transition, runtime):
614
+ vars_map = dict(runtime['vars'])
615
+ for update in transition.get('updates') or []:
616
+ variable = update['variable']
617
+ current = vars_map.get(variable)
618
+ op = update['op']
619
+ if op == 'set':
620
+ vars_map[variable] = update.get('value', 0)
621
+ elif op == 'inc':
622
+ vars_map[variable] = (current if isinstance(current, (int, float)) and not isinstance(current, bool) else 0) + update.get('value', 1)
623
+ else:
624
+ vars_map[variable] = (current if isinstance(current, (int, float)) and not isinstance(current, bool) else 0) - update.get('value', 1)
625
+ return {'state': transition['to'], 'vars': vars_map}
626
+
627
+
628
+ def _group_transitions(model):
629
+ groups = {}
630
+ for transition in model.get('transitions') or []:
631
+ key = transition['from'] + '|' + transition['event']
632
+ groups.setdefault(key, []).append(transition)
633
+ return groups
634
+
635
+
636
+ def _applicable_transitions(group, runtime):
637
+ guarded = []
638
+ unguarded = []
639
+ for transition in group:
640
+ if transition.get('guard') is None:
641
+ unguarded.append(transition)
642
+ else:
643
+ guarded.append(transition)
644
+ matched = [t for t in guarded if _eval_guard(t['guard'], runtime['vars'])]
645
+ if matched:
646
+ return matched
647
+ return unguarded
648
+
649
+
650
+ def _step_runtime(model, runtime, event):
651
+ group = _group_transitions(model).get(runtime['state'] + '|' + event)
652
+ if group is None or len(group) == 0:
653
+ return []
654
+ seen = set()
655
+ outcomes = []
656
+ for transition in _applicable_transitions(group, runtime):
657
+ next_state = _apply_updates(model, transition, runtime)
658
+ key = _runtime_key(next_state)
659
+ if key not in seen:
660
+ seen.add(key)
661
+ outcomes.append(next_state)
662
+ return outcomes
663
+
664
+
665
+ def _all_events(model):
666
+ return sorted(set(t['event'] for t in model.get('transitions') or []))
667
+
668
+
669
+ def _state_by_id(model, sid):
670
+ for state in model.get('states') or []:
671
+ if state['id'] == sid:
672
+ return state
673
+ return None
674
+
675
+
676
+ def _is_terminal(model, sid):
677
+ state = _state_by_id(model, sid)
678
+ return state is not None and state.get('terminal') is True
679
+
680
+
681
+ def _unique_targets(transitions):
682
+ # JS [...new Set(...)] keeps first-encounter order
683
+ seen = set()
684
+ out = []
685
+ for t in transitions:
686
+ target = t['to']
687
+ if target not in seen:
688
+ seen.add(target)
689
+ out.append(target)
690
+ return out
691
+
692
+
693
+ def _dedupe_preserve(items):
694
+ seen = set()
695
+ out = []
696
+ for item in items:
697
+ if item not in seen:
698
+ seen.add(item)
699
+ out.append(item)
700
+ return out
701
+
702
+
703
+ def _explore(model, max_states):
704
+ init = _initial_state(model)
705
+ reachable = []
706
+ reachable_keys = set()
707
+ queue = deque([init])
708
+ truncated = False
709
+ while queue:
710
+ runtime = queue.popleft()
711
+ key = _runtime_key(runtime)
712
+ if key in reachable_keys:
713
+ continue
714
+ reachable_keys.add(key)
715
+ reachable.append(runtime)
716
+ if len(reachable) > max_states:
717
+ truncated = True
718
+ break
719
+ for event in _all_events(model):
720
+ for nxt in _step_runtime(model, runtime, event):
721
+ if _runtime_key(nxt) not in reachable_keys:
722
+ queue.append(nxt)
723
+ return {'reachable': reachable, 'reachable_keys': reachable_keys, 'truncated': truncated, 'initial_state': init}
724
+
725
+
726
+ def _check_result(cid, name, findings, detail):
727
+ errors = sum(1 for f in findings if f.get('severity') == 'error')
728
+ warnings = sum(1 for f in findings if f.get('severity') == 'warning')
729
+ suffix = ''
730
+ if errors > 0:
731
+ suffix = ' (' + str(errors) + ' errors' + (', ' + str(warnings) + ' warnings' if warnings > 0 else '') + ')'
732
+ elif warnings > 0:
733
+ suffix = ' (' + str(warnings) + ' warnings)'
734
+ return {'id': cid, 'name': name, 'status': 'pass' if len(findings) == 0 else 'fail', 'detail': detail + suffix, 'findings': findings}
735
+
736
+
737
+ # ---------------------------------------------------------------------------
738
+ # S1-S7
739
+ # ---------------------------------------------------------------------------
740
+
741
+ def S1_reachability(model, exploration):
742
+ reached = set(r['state'] for r in exploration['reachable'])
743
+ unreachable = [s['id'] for s in model.get('states') or [] if s['id'] not in reached]
744
+ findings = [{
745
+ 'code': 'S1_UNREACHABLE_STATE',
746
+ 'severity': 'warning',
747
+ 'message': 'State ' + s + ' is not reachable from init.',
748
+ } for s in unreachable]
749
+ return _check_result('S1', 'Reachability', findings,
750
+ 'All states reachable' if not unreachable else 'Unreachable states: ' + ', '.join(unreachable))
751
+
752
+
753
+ def S2_deadlock(model):
754
+ outgoing = set(t['from'] for t in model.get('transitions') or [])
755
+ findings = []
756
+ for state in model.get('states') or []:
757
+ if state.get('terminal') is True:
758
+ continue
759
+ if state['id'] not in outgoing:
760
+ findings.append({
761
+ 'code': 'S2_NO_TRANSITIONS',
762
+ 'severity': 'error',
763
+ 'message': 'Non-terminal state ' + state['id'] + ' has no outgoing transitions.',
764
+ })
765
+ detail = 'No deadlocks' if not findings else 'Deadlocks: ' + '; '.join(f['message'] for f in findings)
766
+ return _check_result('S2', 'Deadlock', findings, detail)
767
+
768
+
769
+ def _sccs(model):
770
+ states = [s['id'] for s in model.get('states') or []]
771
+ edges = {}
772
+ for state in states:
773
+ edges[state] = []
774
+ for transition in model.get('transitions') or []:
775
+ edges.setdefault(transition['from'], []).append(transition['to'])
776
+ index = 0
777
+ indices = {}
778
+ low = {}
779
+ on_stack = set()
780
+ stack = []
781
+ components = []
782
+
783
+ def visit(node):
784
+ nonlocal index
785
+ indices[node] = index
786
+ low[node] = index
787
+ index += 1
788
+ stack.append(node)
789
+ on_stack.add(node)
790
+ for nxt in edges.get(node) or []:
791
+ if nxt not in indices:
792
+ visit(nxt)
793
+ low[node] = min(low[node], low[nxt])
794
+ elif nxt in on_stack:
795
+ low[node] = min(low[node], indices[nxt])
796
+ if low[node] == indices[node]:
797
+ component = []
798
+ while stack:
799
+ member = stack.pop()
800
+ on_stack.discard(member)
801
+ component.append(member)
802
+ if member == node:
803
+ break
804
+ components.append(component)
805
+
806
+ for state in states:
807
+ if state not in indices:
808
+ visit(state)
809
+ return components
810
+
811
+
812
+ def S3_liveness(model):
813
+ components = _sccs(model)
814
+ findings = []
815
+ for component in components:
816
+ component_set = set(component)
817
+ internal_edges = [t for t in model.get('transitions') or [] if t['from'] in component_set and t['to'] in component_set]
818
+ escaping_edges = [t for t in model.get('transitions') or [] if t['from'] in component_set and t['to'] not in component_set]
819
+ if not internal_edges or escaping_edges:
820
+ continue
821
+ if any(_is_terminal(model, s) for s in component):
822
+ continue
823
+ sorted_component = sorted(component)
824
+ findings.append({
825
+ 'code': 'S3_CLOSED_SCC',
826
+ 'severity': 'error',
827
+ 'message': 'Closed SCC has no exit and contains no terminal state: ' + ', '.join(sorted_component),
828
+ 'evidence': {'states': sorted_component, 'transitions': len(internal_edges)},
829
+ })
830
+ return _check_result('S3', 'Liveness', findings,
831
+ 'No harmful closed SCCs' if not findings else 'Closed SCCs: ' + str(len(findings)))
832
+
833
+
834
+ def _guard_leaves(model):
835
+ leaves = []
836
+
837
+ def visit(guard):
838
+ if 'variable' in guard:
839
+ leaves.append(guard)
840
+ elif 'all' in guard:
841
+ for child in guard['all']:
842
+ visit(child)
843
+ elif 'any' in guard:
844
+ for child in guard['any']:
845
+ visit(child)
846
+ elif 'not' in guard:
847
+ visit(guard['not'])
848
+
849
+ for transition in model.get('transitions') or []:
850
+ if transition.get('guard') is not None:
851
+ visit(transition['guard'])
852
+ return leaves
853
+
854
+
855
+ def _collect_guard_constants(model):
856
+ return [leaf['value'] for leaf in _guard_leaves(model) if isinstance(leaf['value'], (int, float)) and not isinstance(leaf['value'], bool)]
857
+
858
+
859
+ def _assignments_for(model, variables):
860
+ specs = [v for v in model.get('variables') or [] if v['name'] in variables]
861
+ combinations = [{}]
862
+ for spec in specs:
863
+ if spec['kind'] == 'boolean':
864
+ values = [False, True]
865
+ else:
866
+ constants = [value for value in _collect_guard_constants(model)
867
+ if any(leaf['variable'] == spec['name'] and isinstance(leaf['value'], (int, float)) and not isinstance(leaf['value'], bool) and leaf['value'] == value
868
+ for leaf in _guard_leaves(model))]
869
+ mn = spec.get('min')
870
+ mx = spec.get('max')
871
+ if mn is not None and mx is not None:
872
+ values = []
873
+ value = mn
874
+ while value <= mx and value <= mn + 200:
875
+ values.append(value)
876
+ value += 1
877
+ else:
878
+ values = sorted(set([-1, 0, 1] + constants))
879
+ nxt = []
880
+ for assignment in combinations:
881
+ for value in values:
882
+ copy = dict(assignment)
883
+ copy[spec['name']] = value
884
+ nxt.append(copy)
885
+ if len(nxt) > 10000:
886
+ return None
887
+ combinations = nxt
888
+ return combinations
889
+
890
+
891
+ def _analyze_guards(model):
892
+ determinism = []
893
+ completeness = []
894
+ truncated = False
895
+ groups = _group_transitions(model)
896
+ for key, group in groups.items():
897
+ sep = key.find('|')
898
+ frm = key[:sep]
899
+ event = key[sep + 1:]
900
+ unguarded = [t for t in group if t.get('guard') is None]
901
+ guarded = [t for t in group if t.get('guard') is not None]
902
+ if len(unguarded) > 1:
903
+ determinism.append({
904
+ 'code': 'S4_AMBIGUOUS_DEFAULT',
905
+ 'severity': 'error',
906
+ 'message': frm + ' + ' + event + ' has multiple unconditional transitions: ' + ', '.join(_unique_targets(unguarded)),
907
+ })
908
+ if len(guarded) == 0:
909
+ continue
910
+ variables = []
911
+ for t in group:
912
+ for v in guard_variables(t.get('guard')):
913
+ if v not in variables:
914
+ variables.append(v)
915
+ assignments = _assignments_for(model, variables)
916
+ if assignments is None:
917
+ completeness.append({'code': 'S6_UNBOUNDED_DOMAIN', 'severity': 'warning',
918
+ 'message': frm + ' + ' + event + ': guard domain too large to enumerate; exhaustive check skipped.'})
919
+ truncated = True
920
+ continue
921
+ targets = _unique_targets(group)
922
+ for assignment in assignments:
923
+ true_branches = [t for t in guarded if _eval_guard(t['guard'], assignment)]
924
+ if len(true_branches) > 1:
925
+ determinism.append({
926
+ 'code': 'S4_NONDETERMINISTIC_GUARDS',
927
+ 'severity': 'error',
928
+ 'message': frm + ' + ' + event + ' has ' + str(len(true_branches)) + ' simultaneously true guards for assignment ' + stable_stringify(assignment) + ': ' + ', '.join(_unique_targets(true_branches)),
929
+ 'evidence': {'assignment': assignment},
930
+ })
931
+ if len(true_branches) == 0 and len(unguarded) == 0:
932
+ completeness.append({
933
+ 'code': 'S6_INCOMPLETE_GUARD',
934
+ 'severity': 'error',
935
+ 'message': frm + ' + ' + event + ' has no true guard and no default branch for assignment ' + stable_stringify(assignment),
936
+ 'evidence': {'assignment': assignment, 'branches': targets},
937
+ })
938
+ if len(determinism) + len(completeness) > 200:
939
+ truncated = True
940
+ break
941
+ if truncated:
942
+ break
943
+ return {'determinism': determinism, 'completeness': completeness, 'truncated': truncated}
944
+
945
+
946
+ def S4_determinism(model):
947
+ analysis = _analyze_guards(model)
948
+ return _check_result('S4', 'Determinism', analysis['determinism'],
949
+ 'Deterministic' if not analysis['determinism'] else 'Nondeterminism findings: ' + str(len(analysis['determinism'])))
950
+
951
+
952
+ def S6_guard_completeness(model):
953
+ analysis = _analyze_guards(model)
954
+ return _check_result('S6', 'Guard Completeness', analysis['completeness'],
955
+ 'All guard branches defined' if not analysis['completeness'] else 'Guard findings: ' + str(len(analysis['completeness'])))
956
+
957
+
958
+ def S5_event_completeness(model):
959
+ events = _all_events(model)
960
+ findings = []
961
+ for state in model.get('states') or []:
962
+ if state.get('terminal') is True:
963
+ continue
964
+ handled = set(t['event'] for t in model.get('transitions') or [] if t['from'] == state['id'])
965
+ for event in events:
966
+ if event not in handled:
967
+ findings.append({
968
+ 'code': 'S5_UNHANDLED_EVENT',
969
+ 'severity': 'warning',
970
+ 'message': state['id'] + ' silently ignores event ' + event,
971
+ })
972
+ return _check_result('S5', 'Event Completeness', findings,
973
+ 'All states handle all relevant events' if not findings else str(len(findings)) + ' unhandled (state, event) pairs')
974
+
975
+
976
+ def _invariant_holds(invariant, runtime):
977
+ kind = invariant['kind']
978
+ if kind == 'never-states':
979
+ return runtime['state'] not in invariant['states']
980
+ if kind == 'var-in-range':
981
+ value = runtime['vars'].get(invariant['variable'])
982
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
983
+ return False
984
+ if invariant.get('min') is not None and value < invariant['min']:
985
+ return False
986
+ if invariant.get('max') is not None and value > invariant['max']:
987
+ return False
988
+ return True
989
+ return True
990
+
991
+
992
+ def _shortest_event_before_state_violation(model, max_states, invariant):
993
+ init = _initial_state(model)
994
+ if init['state'] == invariant['state']:
995
+ return {'invariant': invariant, 'path': [],
996
+ 'reason': 'Target state ' + invariant['state'] + ' is initial and the required event ' + invariant['event'] + ' has not occurred.'}
997
+ key = lambda runtime, seen: _runtime_key(runtime) + '|' + ('1' if seen else '0')
998
+ visited = set([key(init, False)])
999
+ queue = deque([{'runtime': init, 'seen': False, 'path': []}])
1000
+ steps = 0
1001
+ while queue:
1002
+ entry = queue.popleft()
1003
+ steps += 1
1004
+ if steps > max_states:
1005
+ break
1006
+ for event in _all_events(model):
1007
+ for nxt in _step_runtime(model, entry['runtime'], event):
1008
+ seen = entry['seen'] or event == invariant['event']
1009
+ nk = key(nxt, seen)
1010
+ if nk in visited:
1011
+ continue
1012
+ p = list(entry['path'])
1013
+ p.append({'from': entry['runtime']['state'], 'event': event, 'to': nxt['state']})
1014
+ if nxt['state'] == invariant['state'] and not seen:
1015
+ return {'invariant': invariant, 'path': p,
1016
+ 'reason': 'Target state ' + invariant['state'] + ' is reachable without event ' + invariant['event'] + ' in ' + str(len(p)) + ' steps.'}
1017
+ visited.add(nk)
1018
+ queue.append({'runtime': nxt, 'seen': seen, 'path': p})
1019
+ return None
1020
+
1021
+
1022
+ def _shortest_violation_for_invariant(model, max_states, invariant):
1023
+ if invariant['kind'] == 'event-before-state':
1024
+ return _shortest_event_before_state_violation(model, max_states, invariant)
1025
+ init = _initial_state(model)
1026
+ if not _invariant_holds(invariant, init):
1027
+ return {'invariant': invariant, 'path': [], 'reason': 'Initial state violates the invariant.'}
1028
+ visited = set([_runtime_key(init)])
1029
+ queue = deque([{'runtime': init, 'path': []}])
1030
+ steps = 0
1031
+ while queue:
1032
+ entry = queue.popleft()
1033
+ steps += 1
1034
+ if steps > max_states:
1035
+ break
1036
+ for event in _all_events(model):
1037
+ for nxt in _step_runtime(model, entry['runtime'], event):
1038
+ k = _runtime_key(nxt)
1039
+ if k in visited:
1040
+ continue
1041
+ p = list(entry['path'])
1042
+ p.append({'from': entry['runtime']['state'], 'event': event, 'to': nxt['state']})
1043
+ if not _invariant_holds(invariant, nxt):
1044
+ return {'invariant': invariant, 'path': p, 'reason': 'Invariant violated after ' + str(len(p)) + ' events.'}
1045
+ visited.add(k)
1046
+ queue.append({'runtime': nxt, 'path': p})
1047
+ return None
1048
+
1049
+
1050
+ def _run_invariants(model, max_states):
1051
+ violations = []
1052
+ for invariant in model.get('invariants') or []:
1053
+ violation = _shortest_violation_for_invariant(model, max_states, invariant)
1054
+ if violation is not None:
1055
+ violations.append(violation)
1056
+ return violations
1057
+
1058
+
1059
+ def S7_invariants(model, max_states):
1060
+ violations = _run_invariants(model, max_states)
1061
+ findings = []
1062
+ for violation in violations:
1063
+ invariant = violation['invariant']
1064
+ findings.append({
1065
+ 'code': 'S7_INVARIANT_VIOLATION',
1066
+ 'severity': 'error',
1067
+ 'message': 'Invariant "' + invariant['id'] + '" (' + invariant['description'] + ') violated: ' + violation['reason'],
1068
+ 'path': violation['path'],
1069
+ 'evidence': {'invariant': invariant},
1070
+ })
1071
+ return _check_result('S7', 'Invariant Validity', findings,
1072
+ 'All invariants hold' if not violations else 'Invariant violations: ' + str(len(violations)))
1073
+
1074
+
1075
+ # ---------------------------------------------------------------------------
1076
+ # A1-A7
1077
+ # ---------------------------------------------------------------------------
1078
+
1079
+ def A1_unexpected_events(model):
1080
+ events = _all_events(model)
1081
+ findings = []
1082
+ for state in model.get('states') or []:
1083
+ if state.get('terminal') is True:
1084
+ continue
1085
+ handled = set(t['event'] for t in model.get('transitions') or [] if t['from'] == state['id'])
1086
+ for event in events:
1087
+ if event not in handled:
1088
+ findings.append({
1089
+ 'code': 'A1_UNHANDLED_EVENT',
1090
+ 'severity': 'warning',
1091
+ 'message': 'Event ' + event + ' in state ' + state['id'] + ' has no defined transition (silent ignore).',
1092
+ })
1093
+ return _check_result('A1', 'Unexpected Event Injection', findings,
1094
+ 'All event/state combinations defined' if not findings else str(len(findings)) + ' unhandled combinations')
1095
+
1096
+
1097
+ def _first_outcome(runtime, outcomes):
1098
+ return outcomes[0] if outcomes else None
1099
+
1100
+
1101
+ def A2_race_interleaving(model, exploration):
1102
+ findings = []
1103
+ for pair in model.get('concurrentPairs') or []:
1104
+ e1, e2 = pair[0], pair[1]
1105
+ for runtime in exploration['reachable']:
1106
+ first = _first_outcome(runtime, _step_runtime(model, runtime, e1))
1107
+ second = _first_outcome(runtime, _step_runtime(model, runtime, e2))
1108
+ if first is None and second is None:
1109
+ continue
1110
+ final12 = runtime if first is None else (_first_outcome(first, _step_runtime(model, first, e2)) or first)
1111
+ final21 = runtime if second is None else (_first_outcome(second, _step_runtime(model, second, e1)) or second)
1112
+ if _runtime_key(final12) != _runtime_key(final21):
1113
+ findings.append({
1114
+ 'code': 'A2_ORDER_DEPENDENT',
1115
+ 'severity': 'warning',
1116
+ 'message': 'Events ' + e1 + ' and ' + e2 + ' produce order-dependent outcomes from state ' + runtime['state'],
1117
+ 'evidence': {'e1ThenE2': final12, 'e2ThenE1': final21},
1118
+ })
1119
+ return _check_result('A2', 'Race Interleaving', findings,
1120
+ 'No race conditions detected' if not findings else 'Order-dependent outcomes: ' + str(len(findings)))
1121
+
1122
+
1123
+ def _step_sequence(model, init, events):
1124
+ runtime = init
1125
+ for event in events:
1126
+ nxt = _first_outcome(runtime, _step_runtime(model, runtime, event))
1127
+ if nxt is None:
1128
+ continue
1129
+ runtime = nxt
1130
+ return runtime
1131
+
1132
+
1133
+ def _permutations(items):
1134
+ if len(items) == 0:
1135
+ return [[]]
1136
+ result = []
1137
+ for index in range(len(items)):
1138
+ rest = items[:index] + items[index + 1:]
1139
+ for suffix in _permutations(rest):
1140
+ result.append([items[index]] + suffix)
1141
+ return result
1142
+
1143
+
1144
+ def A3_order_permutation(model, max_permutation_events):
1145
+ events = _all_events(model)[:max_permutation_events]
1146
+ if len(events) < 2:
1147
+ return _check_result('A3', 'Order Permutation', [], 'Fewer than 2 events — skipped')
1148
+ init = _initial_state(model)
1149
+ outcomes = {}
1150
+ for permutation in _permutations(events):
1151
+ final = _step_sequence(model, init, permutation)
1152
+ k = _runtime_key(final)
1153
+ lst = outcomes.setdefault(k, [])
1154
+ if len(lst) < 3:
1155
+ lst.append(','.join(permutation))
1156
+ if len(outcomes) > 1:
1157
+ examples = [{'final': k.split('|')[0], 'example': lst[0]} for k, lst in outcomes.items()]
1158
+ findings = [{
1159
+ 'code': 'A3_ORDER_DEPENDENT',
1160
+ 'severity': 'warning',
1161
+ 'message': 'Same event set produces ' + str(len(outcomes)) + ' different outcomes depending on order.',
1162
+ 'evidence': {'examples': examples},
1163
+ }]
1164
+ return _check_result('A3', 'Order Permutation', findings, 'Order-dependent outcomes: ' + str(len(outcomes)))
1165
+ return _check_result('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + str(len(events)) + ' events)')
1166
+
1167
+
1168
+ def _state_action_list(model, state_id, kind):
1169
+ state = _state_by_id(model, state_id)
1170
+ if state is None:
1171
+ return []
1172
+ return list(state.get(kind) or [])
1173
+
1174
+
1175
+ def _actions_declared(model):
1176
+ for state in model.get('states') or []:
1177
+ if len(state.get('onEntry') or []) > 0 or len(state.get('onExit') or []) > 0:
1178
+ return True
1179
+ return False
1180
+
1181
+
1182
+ def _apply_action_list(action_list, acquire_event, release_event, held):
1183
+ nxt = held
1184
+ reacquired = False
1185
+ for action in action_list:
1186
+ if action == acquire_event:
1187
+ if nxt:
1188
+ reacquired = True
1189
+ nxt = True
1190
+ elif action == release_event:
1191
+ nxt = False
1192
+ return {'held': nxt, 'reacquired': reacquired}
1193
+
1194
+
1195
+ def _state_graph_edges(model):
1196
+ edges = {}
1197
+ for state in model.get('states') or []:
1198
+ edges[state['id']] = []
1199
+ for transition in model.get('transitions') or []:
1200
+ edges.setdefault(transition['from'], []).append(transition)
1201
+ return edges
1202
+
1203
+
1204
+ def _state_graph_reachable(model, start):
1205
+ edges = {}
1206
+ for state in model.get('states') or []:
1207
+ edges[state['id']] = []
1208
+ for transition in model.get('transitions') or []:
1209
+ edges.setdefault(transition['from'], []).append(transition['to'])
1210
+ visited = set()
1211
+ queue = deque([start])
1212
+ while queue:
1213
+ current = queue.popleft()
1214
+ if current in visited:
1215
+ continue
1216
+ visited.add(current)
1217
+ for nxt in edges.get(current) or []:
1218
+ queue.append(nxt)
1219
+ return visited
1220
+
1221
+
1222
+ def A4_pair_symmetry(model):
1223
+ findings = []
1224
+ has_actions = _actions_declared(model)
1225
+ transition_events = set(t['event'] for t in model.get('transitions') or [])
1226
+ action_events = set()
1227
+ if has_actions:
1228
+ for state in model.get('states') or []:
1229
+ for action in _state_action_list(model, state['id'], 'onEntry'):
1230
+ action_events.add(action)
1231
+ for action in _state_action_list(model, state['id'], 'onExit'):
1232
+ action_events.add(action)
1233
+
1234
+ def pair_has_event(event):
1235
+ return event in transition_events or event in action_events
1236
+
1237
+ def release_reachable_from(state_id, release_event):
1238
+ reachable = _state_graph_reachable(model, state_id)
1239
+ for state in reachable:
1240
+ if any(t['from'] == state and t['event'] == release_event for t in model.get('transitions') or []):
1241
+ return True
1242
+ if has_actions:
1243
+ if release_event in _state_action_list(model, state, 'onEntry') or release_event in _state_action_list(model, state, 'onExit'):
1244
+ return True
1245
+ return False
1246
+
1247
+ edges = _state_graph_edges(model)
1248
+ for pair in model.get('resourcePairs') or []:
1249
+ acquire_event = pair['acquireEvent']
1250
+ release_event = pair['releaseEvent']
1251
+ acquire_transitions = [t for t in model.get('transitions') or [] if t['event'] == acquire_event]
1252
+ action_acquire = False
1253
+ if has_actions:
1254
+ for state in model.get('states') or []:
1255
+ if acquire_event in _state_action_list(model, state['id'], 'onEntry') or acquire_event in _state_action_list(model, state['id'], 'onExit'):
1256
+ action_acquire = True
1257
+ break
1258
+ release_exists = pair_has_event(release_event)
1259
+ if not acquire_transitions and not action_acquire and not release_exists:
1260
+ continue
1261
+ if (acquire_transitions or action_acquire) and not release_exists:
1262
+ findings.append({
1263
+ 'code': 'A4_NO_RELEASE_EVENT',
1264
+ 'severity': 'error',
1265
+ 'message': 'Resource "' + pair['resource'] + '": acquire event ' + acquire_event + ' exists but release event ' + release_event + ' is never defined.',
1266
+ })
1267
+ continue
1268
+ seeds = []
1269
+ for acquire in acquire_transitions:
1270
+ if not release_reachable_from(acquire['to'], release_event):
1271
+ findings.append({
1272
+ 'code': 'A4_NO_RELEASE_REACHABLE',
1273
+ 'severity': 'error',
1274
+ 'message': 'Resource "' + pair['resource'] + '": after ' + acquire_event + ' into ' + acquire['to'] + ', no ' + release_event + ' is reachable.',
1275
+ 'evidence': {'acquireTransition': acquire},
1276
+ })
1277
+ continue
1278
+ seeds.append({'state': acquire['to'], 'held': True, 'path': []})
1279
+ if has_actions:
1280
+ for state in model.get('states') or []:
1281
+ entry = _state_action_list(model, state['id'], 'onEntry')
1282
+ if acquire_event in entry:
1283
+ sim = _apply_action_list(entry, acquire_event, release_event, False)
1284
+ if sim['reacquired']:
1285
+ findings.append({
1286
+ 'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
1287
+ 'severity': 'warning',
1288
+ 'message': 'Resource "' + pair['resource'] + '" is acquired more than once inside onEntry of ' + state['id'] + ' before ' + release_event + '.',
1289
+ })
1290
+ if sim['held']:
1291
+ seeds.append({'state': state['id'], 'held': True, 'path': []})
1292
+ exit_list = _state_action_list(model, state['id'], 'onExit')
1293
+ if acquire_event in exit_list and state.get('terminal') is not True:
1294
+ sim = _apply_action_list(exit_list, acquire_event, release_event, False)
1295
+ if sim['reacquired']:
1296
+ findings.append({
1297
+ 'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
1298
+ 'severity': 'warning',
1299
+ 'message': 'Resource "' + pair['resource'] + '" is acquired more than once inside onExit of ' + state['id'] + ' before ' + release_event + '.',
1300
+ })
1301
+ if sim['held']:
1302
+ seeds.append({'state': state['id'], 'held': False, 'path': []})
1303
+ for seed in seeds:
1304
+ visited = set()
1305
+ queue = deque([seed])
1306
+ steps = 0
1307
+ while queue:
1308
+ entry = queue.popleft()
1309
+ steps += 1
1310
+ if steps > 1000:
1311
+ break
1312
+ k = entry['state'] + '|' + ('1' if entry['held'] else '0')
1313
+ if k in visited:
1314
+ continue
1315
+ visited.add(k)
1316
+ if entry['held'] and _is_terminal(model, entry['state']):
1317
+ findings.append({
1318
+ 'code': 'A4_TERMINAL_WITH_RESOURCE',
1319
+ 'severity': 'error',
1320
+ 'message': 'Resource "' + pair['resource'] + '" is still held when entering terminal state ' + entry['state'] + '.',
1321
+ 'path': entry['path'],
1322
+ })
1323
+ continue
1324
+ for transition in edges.get(entry['state']) or []:
1325
+ held = entry['held']
1326
+ if has_actions:
1327
+ sim = _apply_action_list(_state_action_list(model, entry['state'], 'onExit'), acquire_event, release_event, held)
1328
+ if sim['reacquired']:
1329
+ findings.append({
1330
+ 'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
1331
+ 'severity': 'warning',
1332
+ 'message': 'Resource "' + pair['resource'] + '" is acquired again in onExit of ' + entry['state'] + ' before ' + release_event + '.',
1333
+ 'path': entry['path'],
1334
+ })
1335
+ continue
1336
+ held = sim['held']
1337
+ if transition['event'] == release_event:
1338
+ held = False
1339
+ elif transition['event'] == acquire_event:
1340
+ if held:
1341
+ findings.append({
1342
+ 'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
1343
+ 'severity': 'warning',
1344
+ 'message': 'Resource "' + pair['resource'] + '" is acquired again in state ' + entry['state'] + ' before ' + release_event + '.',
1345
+ 'path': list(entry['path']) + [{'from': entry['state'], 'event': transition['event'], 'to': transition['to']}],
1346
+ })
1347
+ continue
1348
+ held = True
1349
+ if has_actions:
1350
+ sim = _apply_action_list(_state_action_list(model, transition['to'], 'onEntry'), acquire_event, release_event, held)
1351
+ if sim['reacquired']:
1352
+ findings.append({
1353
+ 'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
1354
+ 'severity': 'warning',
1355
+ 'message': 'Resource "' + pair['resource'] + '" is acquired again inside onEntry of ' + transition['to'] + ' before ' + release_event + '.',
1356
+ 'path': list(entry['path']) + [{'from': entry['state'], 'event': transition['event'], 'to': transition['to']}],
1357
+ })
1358
+ continue
1359
+ held = sim['held']
1360
+ queue.append({'state': transition['to'], 'held': held,
1361
+ 'path': list(entry['path']) + [{'from': entry['state'], 'event': transition['event'], 'to': transition['to']}]})
1362
+ return _check_result('A4', 'Pair Symmetry', findings,
1363
+ 'All pairs balanced' if not findings else 'Asymmetric pairs: ' + str(len(findings)))
1364
+
1365
+
1366
+ def A5_boundary_blast(model):
1367
+ findings = []
1368
+ for check in model.get('boundaryChecks') or []:
1369
+ variable = None
1370
+ for v in model.get('variables') or []:
1371
+ if v['name'] == check['variable']:
1372
+ variable = v
1373
+ break
1374
+ if variable is None:
1375
+ continue
1376
+ for value in check['values']:
1377
+ mn = variable.get('min')
1378
+ mx = variable.get('max')
1379
+ if mn is not None and value < mn:
1380
+ findings.append({'code': 'A5_OUT_OF_DOMAIN', 'severity': 'warning',
1381
+ 'message': 'Boundary value ' + js_number(value) + ' for ' + check['variable'] + ' is below min ' + js_number(mn)})
1382
+ if mx is not None and value > mx:
1383
+ findings.append({'code': 'A5_OUT_OF_DOMAIN', 'severity': 'warning',
1384
+ 'message': 'Boundary value ' + js_number(value) + ' for ' + check['variable'] + ' is above max ' + js_number(mx)})
1385
+ assignment = {}
1386
+ for v in model.get('variables') or []:
1387
+ assignment[v['name']] = v['init']
1388
+ assignment[check['variable']] = value
1389
+ for key, group in _group_transitions(model).items():
1390
+ sep = key.find('|')
1391
+ frm = key[:sep]
1392
+ event = key[sep + 1:]
1393
+ if not any(check['variable'] in guard_variables(t.get('guard')) for t in group):
1394
+ continue
1395
+ guarded = [t for t in group if t.get('guard') is not None]
1396
+ unguarded = [t for t in group if t.get('guard') is None]
1397
+ matched = [t for t in guarded if _eval_guard(t['guard'], assignment)]
1398
+ if not matched and not unguarded:
1399
+ findings.append({
1400
+ 'code': 'A5_GUARD_HOLE',
1401
+ 'severity': 'warning',
1402
+ 'message': frm + ' + ' + event + ' has no branch for ' + check['variable'] + '=' + js_number(value),
1403
+ 'evidence': {'assignment': assignment},
1404
+ })
1405
+ if len(matched) > 1:
1406
+ findings.append({
1407
+ 'code': 'A5_GUARD_OVERLAP',
1408
+ 'severity': 'warning',
1409
+ 'message': frm + ' + ' + event + ' has ' + str(len(matched)) + ' true branches for ' + check['variable'] + '=' + js_number(value),
1410
+ 'evidence': {'assignment': assignment},
1411
+ })
1412
+ return _check_result('A5', 'Boundary Blast', findings,
1413
+ 'Boundary checks passed' if not findings else 'Boundary findings: ' + str(len(findings)))
1414
+
1415
+
1416
+ def A6_resource_injection(model):
1417
+ findings = []
1418
+ for pair in model.get('resourcePairs') or []:
1419
+ if pair.get('failEvent') is None:
1420
+ continue
1421
+ acquire_states = set(t['from'] for t in model.get('transitions') or [] if t['event'] == pair['acquireEvent'])
1422
+ for state in acquire_states:
1423
+ handled = any(t['from'] == state and t['event'] == pair['failEvent'] for t in model.get('transitions') or [])
1424
+ if not handled:
1425
+ findings.append({
1426
+ 'code': 'A6_NO_FAILURE_HANDLER',
1427
+ 'severity': 'warning',
1428
+ 'message': 'State ' + state + ' can ' + pair['acquireEvent'] + ' for "' + pair['resource'] + '" but has no ' + pair['failEvent'] + ' transition.',
1429
+ })
1430
+ return _check_result('A6', 'Resource Injection', findings,
1431
+ 'No resource vulnerabilities detected' if not findings else 'Resource failure paths missing: ' + str(len(findings)))
1432
+
1433
+
1434
+ def A7_shortest_violations(model, max_states):
1435
+ violations = _run_invariants(model, max_states)
1436
+ findings = []
1437
+ for violation in violations:
1438
+ invariant = violation['invariant']
1439
+ findings.append({
1440
+ 'code': 'A7_SHORTEST_COUNTEREXAMPLE',
1441
+ 'severity': 'warning',
1442
+ 'message': 'Invariant "' + invariant['id'] + '" shortest violating path length: ' + str(len(violation['path'])) + (' (initial state)' if len(violation['path']) == 0 else ''),
1443
+ 'path': violation['path'],
1444
+ 'evidence': {'invariant': invariant['id']},
1445
+ })
1446
+ return _check_result('A7', 'Minimal Counter-Example', findings,
1447
+ 'All invariants hold for all reachable paths' if not violations else 'Violated invariants: ' + str(len(findings)))
1448
+
1449
+
1450
+ def _map_state_id(mapping, state):
1451
+ return mapping.get(state, state)
1452
+
1453
+
1454
+ def D1_behavioral_preservation(before, after, mapping):
1455
+ findings = []
1456
+ after_states = set(s['id'] for s in after.get('states') or [])
1457
+ after_by_from_event = {}
1458
+ for transition in after.get('transitions') or []:
1459
+ k = transition['from'] + '|' + transition['event']
1460
+ after_by_from_event.setdefault(k, set()).add(transition['to'])
1461
+ before_init_mapped = _map_state_id(mapping, before['init'])
1462
+ if before_init_mapped != after['init']:
1463
+ findings.append({
1464
+ 'code': 'D1_INIT_MISMATCH',
1465
+ 'severity': 'warning',
1466
+ 'message': 'Mapped BEFORE init ' + before_init_mapped + ' does not match AFTER init ' + after['init'] + '.',
1467
+ 'evidence': {'beforeInit': before['init'], 'mappedInit': before_init_mapped, 'afterInit': after['init']},
1468
+ })
1469
+ for state in before.get('states') or []:
1470
+ mapped = _map_state_id(mapping, state['id'])
1471
+ if mapped not in after_states:
1472
+ findings.append({
1473
+ 'code': 'D1_MAPPED_STATE_MISSING',
1474
+ 'severity': 'error',
1475
+ 'message': 'BEFORE state ' + state['id'] + ' maps to ' + mapped + ', which is not declared in AFTER.',
1476
+ 'evidence': {'beforeState': state['id'], 'mappedState': mapped},
1477
+ })
1478
+ continue
1479
+ for transition in [t for t in before.get('transitions') or [] if t['from'] == state['id']]:
1480
+ k = mapped + '|' + transition['event']
1481
+ targets = after_by_from_event.get(k)
1482
+ if targets is None or len(targets) == 0:
1483
+ findings.append({
1484
+ 'code': 'D1_EVENT_DISABLED',
1485
+ 'severity': 'error',
1486
+ 'message': 'BEFORE can fire event ' + transition['event'] + ' from ' + state['id'] + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
1487
+ 'path': [{'from': state['id'], 'event': transition['event'], 'to': transition['to']}],
1488
+ 'evidence': {'beforeState': state['id'], 'mappedState': mapped, 'event': transition['event']},
1489
+ })
1490
+ return _check_result('D1', 'Behavioral Preservation', findings,
1491
+ 'BEFORE event behavior is preserved in AFTER' if not findings else 'Behavioral preservation findings: ' + str(len(findings)))
1492
+
1493
+
1494
+ def _map_invariant_for_comparison(invariant, mapping):
1495
+ out = dict(invariant)
1496
+ out['id'] = invariant['id'] + ':before'
1497
+ out['description'] = invariant['description'] + ' (from BEFORE)'
1498
+ if invariant['kind'] == 'never-states':
1499
+ out['states'] = [_map_state_id(mapping, s) for s in invariant['states']]
1500
+ elif invariant['kind'] == 'event-before-state':
1501
+ out['state'] = _map_state_id(mapping, invariant['state'])
1502
+ return out
1503
+
1504
+
1505
+ def D2_invariant_continuity(before, after, max_states, mapping):
1506
+ findings = []
1507
+ after_states = set(s['id'] for s in after.get('states') or [])
1508
+ after_variables = set(v['name'] for v in after.get('variables') or [])
1509
+ for invariant in before.get('invariants') or []:
1510
+ mapped = _map_invariant_for_comparison(invariant, mapping)
1511
+ if mapped['kind'] == 'never-states':
1512
+ for state in mapped['states']:
1513
+ if state not in after_states:
1514
+ findings.append({
1515
+ 'code': 'D2_MAPPED_STATE_MISSING',
1516
+ 'severity': 'warning',
1517
+ 'message': 'BEFORE invariant "' + invariant['id'] + '" maps to state ' + state + ', which is not declared in AFTER.',
1518
+ 'evidence': {'invariant': invariant['id'], 'state': state},
1519
+ })
1520
+ elif mapped['kind'] == 'event-before-state':
1521
+ if mapped['state'] not in after_states:
1522
+ findings.append({
1523
+ 'code': 'D2_MAPPED_STATE_MISSING',
1524
+ 'severity': 'warning',
1525
+ 'message': 'BEFORE invariant "' + invariant['id'] + '" maps to state ' + mapped['state'] + ', which is not declared in AFTER.',
1526
+ 'evidence': {'invariant': invariant['id'], 'state': mapped['state']},
1527
+ })
1528
+ elif mapped['kind'] == 'var-in-range':
1529
+ if mapped['variable'] not in after_variables:
1530
+ findings.append({
1531
+ 'code': 'D2_VARIABLE_MISSING',
1532
+ 'severity': 'warning',
1533
+ 'message': 'BEFORE invariant "' + invariant['id'] + '" references variable ' + mapped['variable'] + ', which is not declared in AFTER.',
1534
+ 'evidence': {'invariant': invariant['id'], 'variable': mapped['variable']},
1535
+ })
1536
+ continue
1537
+ violation = _shortest_violation_for_invariant(after, max_states, mapped)
1538
+ if violation is not None:
1539
+ findings.append({
1540
+ 'code': 'D2_INVARIANT_REGRESSION',
1541
+ 'severity': 'error',
1542
+ 'message': 'BEFORE invariant "' + invariant['id'] + '" no longer holds in AFTER: ' + violation['reason'],
1543
+ 'path': violation['path'],
1544
+ 'evidence': {'beforeInvariant': invariant, 'afterInvariant': mapped},
1545
+ })
1546
+ return _check_result('D2', 'Invariant Continuity', findings,
1547
+ 'All BEFORE invariants continue to hold' if not findings else 'Invariant continuity findings: ' + str(len(findings)))
1548
+
1549
+
1550
+ def D3_regression_delta(before, after, mapping):
1551
+ after_state_ids = set(s['id'] for s in after.get('states') or [])
1552
+ before_mapped_ids = set(_map_state_id(mapping, s['id']) for s in before.get('states') or [])
1553
+ added_states = sorted([s['id'] for s in after.get('states') or [] if s['id'] not in before_mapped_ids])
1554
+ removed_states = sorted([s['id'] for s in before.get('states') or [] if _map_state_id(mapping, s['id']) not in after_state_ids])
1555
+ before_events = set(t['event'] for t in before.get('transitions') or [])
1556
+ after_events = set(t['event'] for t in after.get('transitions') or [])
1557
+ added_events = sorted([e for e in after_events if e not in before_events])
1558
+ removed_events = sorted([e for e in before_events if e not in after_events])
1559
+
1560
+ def _same_after(candidate):
1561
+ return any(c.get('from') == _map_state_id(mapping, t.get('from')) and c.get('event') == t.get('event') and c.get('to') == _map_state_id(mapping, t.get('to'))
1562
+ for t in before.get('transitions') or [] for c in [candidate])
1563
+
1564
+ removed_transitions = [t for t in before.get('transitions') or [] if not any(
1565
+ c.get('from') == _map_state_id(mapping, t.get('from')) and c.get('event') == t.get('event') and c.get('to') == _map_state_id(mapping, t.get('to'))
1566
+ for c in after.get('transitions') or [])]
1567
+ added_transitions = [t for t in after.get('transitions') or [] if not any(
1568
+ _map_state_id(mapping, c.get('from')) == t.get('from') and c.get('event') == t.get('event') and _map_state_id(mapping, c.get('to')) == t.get('to')
1569
+ for c in before.get('transitions') or [])]
1570
+ findings = []
1571
+ for state in removed_states:
1572
+ findings.append({'code': 'D3_REMOVED_STATE', 'severity': 'warning',
1573
+ 'message': 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', 'evidence': {'state': state}})
1574
+ for event in removed_events:
1575
+ findings.append({'code': 'D3_REMOVED_EVENT', 'severity': 'warning',
1576
+ 'message': 'BEFORE event ' + event + ' is not present in AFTER.', 'evidence': {'event': event}})
1577
+ for transition in removed_transitions:
1578
+ findings.append({
1579
+ 'code': 'D3_REMOVED_TRANSITION',
1580
+ 'severity': 'warning',
1581
+ 'message': 'BEFORE transition ' + transition['from'] + ' -' + transition['event'] + '-> ' + transition['to'] + ' has no exact AFTER counterpart.',
1582
+ 'evidence': {'transition': transition},
1583
+ })
1584
+ detail = ('Delta: +' + str(len(added_states)) + ' states, -' + str(len(removed_states)) + ' states, +' + str(len(added_events)) + ' events, -' + str(len(removed_events))
1585
+ + ' events, +' + str(len(added_transitions)) + ' transitions, -' + str(len(removed_transitions)) + ' transitions')
1586
+ return _check_result('D3', 'Regression Delta', findings, detail)
1587
+
1588
+
1589
+ def _deadlock_state_ids(model):
1590
+ outgoing = set(t['from'] for t in model.get('transitions') or [])
1591
+ return [s['id'] for s in model.get('states') or [] if s.get('terminal') is not True and s['id'] not in outgoing]
1592
+
1593
+
1594
+ def _closed_scc_state_sets(model):
1595
+ out = []
1596
+ for component in _sccs(model):
1597
+ component_set = set(component)
1598
+ internal_edges = any(t['from'] in component_set and t['to'] in component_set for t in model.get('transitions') or [])
1599
+ escaping_edges = any(t['from'] in component_set and t['to'] not in component_set for t in model.get('transitions') or [])
1600
+ if internal_edges and not escaping_edges and not any(_is_terminal(model, s) for s in component):
1601
+ out.append(sorted(component))
1602
+ return out
1603
+
1604
+
1605
+ def D4_deadlock_liveness_regression(before, after, mapping):
1606
+ findings = []
1607
+ before_deadlock = set(_map_state_id(mapping, s) for s in _deadlock_state_ids(before))
1608
+ after_deadlock = _deadlock_state_ids(after)
1609
+ for state in after_deadlock:
1610
+ if state not in before_deadlock:
1611
+ findings.append({
1612
+ 'code': 'D4_DEADLOCK_REGRESSION',
1613
+ 'severity': 'error',
1614
+ 'message': 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
1615
+ 'evidence': {'state': state},
1616
+ })
1617
+ before_scc = set(','.join(sorted([_map_state_id(mapping, s) for s in component])) for component in _closed_scc_state_sets(before))
1618
+ after_scc = _closed_scc_state_sets(after)
1619
+ for component in after_scc:
1620
+ key = ','.join(component)
1621
+ if key not in before_scc:
1622
+ findings.append({
1623
+ 'code': 'D4_LIVENESS_REGRESSION',
1624
+ 'severity': 'error',
1625
+ 'message': 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
1626
+ 'evidence': {'states': component},
1627
+ })
1628
+ return _check_result('D4', 'Deadlock/Liveness Regression', findings,
1629
+ 'No new deadlock or liveness regressions' if not findings else 'Regression findings: ' + str(len(findings)))
1630
+
1631
+
1632
+ def _build_comparison_summary(before, after, mapping):
1633
+ after_state_ids = set(s['id'] for s in after.get('states') or [])
1634
+ before_mapped_ids = set(_map_state_id(mapping, s['id']) for s in before.get('states') or [])
1635
+ added_states = sorted([s['id'] for s in after.get('states') or [] if s['id'] not in before_mapped_ids])
1636
+ removed_states = sorted([s['id'] for s in before.get('states') or [] if _map_state_id(mapping, s['id']) not in after_state_ids])
1637
+ before_events = set(t['event'] for t in before.get('transitions') or [])
1638
+ after_events = set(t['event'] for t in after.get('transitions') or [])
1639
+ added_events = sorted([e for e in after_events if e not in before_events])
1640
+ removed_events = sorted([e for e in before_events if e not in after_events])
1641
+ removed_transitions = [t for t in before.get('transitions') or [] if not any(
1642
+ c.get('from') == _map_state_id(mapping, t.get('from')) and c.get('event') == t.get('event') and c.get('to') == _map_state_id(mapping, t.get('to'))
1643
+ for c in after.get('transitions') or [])]
1644
+ added_transitions = [t for t in after.get('transitions') or [] if not any(
1645
+ _map_state_id(mapping, c.get('from')) == t.get('from') and c.get('event') == t.get('event') and _map_state_id(mapping, c.get('to')) == t.get('to')
1646
+ for c in before.get('transitions') or [])]
1647
+ return {
1648
+ 'beforeModelHash': model_hash(before),
1649
+ 'afterModelHash': model_hash(after),
1650
+ 'stateMapping': mapping,
1651
+ 'beforeStates': len(before.get('states') or []),
1652
+ 'beforeTransitions': len(before.get('transitions') or []),
1653
+ 'afterStates': len(after.get('states') or []),
1654
+ 'afterTransitions': len(after.get('transitions') or []),
1655
+ 'addedStates': added_states,
1656
+ 'removedStates': removed_states,
1657
+ 'addedEvents': added_events,
1658
+ 'removedEvents': removed_events,
1659
+ 'addedTransitions': added_transitions,
1660
+ 'removedTransitions': removed_transitions,
1661
+ }
1662
+
1663
+
1664
+ def A8_idempotent_replay(model, exploration):
1665
+ events = model.get('idempotentEvents') or []
1666
+ findings = []
1667
+ for event in events:
1668
+ for runtime in exploration['reachable']:
1669
+ once_options = _step_runtime(model, runtime, event)
1670
+ if not once_options:
1671
+ continue
1672
+ for once in once_options:
1673
+ twice_options = _step_runtime(model, once, event)
1674
+ if not twice_options:
1675
+ findings.append({
1676
+ 'code': 'A8_NOT_REPLAYABLE',
1677
+ 'severity': 'warning',
1678
+ 'message': 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime['state'] + '.',
1679
+ 'path': [{'from': runtime['state'], 'event': event, 'to': once['state']}],
1680
+ 'evidence': {'state': runtime['state'], 'event': event},
1681
+ })
1682
+ continue
1683
+ for twice in twice_options:
1684
+ if _runtime_key(twice) != _runtime_key(once):
1685
+ findings.append({
1686
+ 'code': 'A8_NOT_IDEMPOTENT',
1687
+ 'severity': 'error',
1688
+ 'message': 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime['state'] + '.',
1689
+ 'path': [{'from': runtime['state'], 'event': event, 'to': once['state']},
1690
+ {'from': once['state'], 'event': event, 'to': twice['state']}],
1691
+ 'evidence': {'state': runtime['state'], 'event': event, 'afterOnce': once, 'afterTwice': twice},
1692
+ })
1693
+ break
1694
+ return _check_result('A8', 'Idempotent Replay', findings,
1695
+ 'Idempotent events are replay-safe' if not findings else 'Idempotent replay findings: ' + str(len(findings)))
1696
+
1697
+
1698
+ def S8_monotonic_variables(model):
1699
+ findings = []
1700
+ for variable in model.get('variables') or []:
1701
+ if variable.get('monotonic') is None:
1702
+ continue
1703
+ for transition in model.get('transitions') or []:
1704
+ for update in transition.get('updates') or []:
1705
+ if update['variable'] != variable['name']:
1706
+ continue
1707
+ if variable['monotonic'] == 'inc' and update['op'] == 'dec':
1708
+ findings.append({'code': 'S8_MONOTONIC_DECREASE', 'severity': 'error',
1709
+ 'message': 'Monotonic (inc) variable ' + variable['name'] + ' is decreased by ' + transition['event'] + '.',
1710
+ 'evidence': {'variable': variable['name'], 'transition': transition}})
1711
+ elif variable['monotonic'] == 'dec' and update['op'] == 'inc':
1712
+ findings.append({'code': 'S8_MONOTONIC_INCREASE', 'severity': 'error',
1713
+ 'message': 'Monotonic (dec) variable ' + variable['name'] + ' is increased by ' + transition['event'] + '.',
1714
+ 'evidence': {'variable': variable['name'], 'transition': transition}})
1715
+ elif update['op'] == 'set':
1716
+ findings.append({'code': 'S8_MONOTONIC_SET_REVIEW', 'severity': 'warning',
1717
+ 'message': 'Monotonic variable ' + variable['name'] + ' uses set in ' + transition['event'] + '; verify it cannot move backwards.',
1718
+ 'evidence': {'variable': variable['name'], 'transition': transition}})
1719
+ return _check_result('S8', 'Monotonic Variables', findings,
1720
+ 'Monotonic variables are respected' if not findings else 'Monotonic findings: ' + str(len(findings)))
1721
+
1722
+
1723
+ def _find_leads_to_bad_path(model, start, target):
1724
+ if start['state'] == target:
1725
+ return None
1726
+ visited = set()
1727
+ queue = deque([{'runtime': start, 'path': []}])
1728
+ while queue:
1729
+ entry = queue.popleft()
1730
+ k = _runtime_key(entry['runtime'])
1731
+ if entry['runtime']['state'] == target:
1732
+ continue
1733
+ if k in visited:
1734
+ return {'path': entry['path'], 'reason': 'Cycle avoids target ' + target}
1735
+ visited.add(k)
1736
+ nexts = []
1737
+ for event in _all_events(model):
1738
+ for nxt in _step_runtime(model, entry['runtime'], event):
1739
+ nexts.append({'next': nxt, 'event': event})
1740
+ if not nexts:
1741
+ return {'path': entry['path'], 'reason': 'Dead end before target ' + target}
1742
+ for item in nexts:
1743
+ queue.append({'runtime': item['next'],
1744
+ 'path': list(entry['path']) + [{'from': entry['runtime']['state'], 'event': item['event'], 'to': item['next']['state']}]})
1745
+ return {'path': [], 'reason': 'No path reaches target ' + target}
1746
+
1747
+
1748
+ def A9_leads_to(model, exploration):
1749
+ findings = []
1750
+ for invariant in model.get('invariants') or []:
1751
+ if invariant['kind'] != 'leads-to':
1752
+ continue
1753
+ for runtime in exploration['reachable']:
1754
+ if runtime['state'] != invariant['from']:
1755
+ continue
1756
+ bad = _find_leads_to_bad_path(model, runtime, invariant['to'])
1757
+ if bad is not None:
1758
+ findings.append({
1759
+ 'code': 'A9_LEADS_TO_VIOLATION',
1760
+ 'severity': 'error',
1761
+ 'message': 'Leads-to invariant "' + invariant['id'] + '" violated from ' + invariant['from'] + ': ' + bad['reason'],
1762
+ 'path': bad['path'],
1763
+ 'evidence': {'invariant': invariant},
1764
+ })
1765
+ break
1766
+ return _check_result('A9', 'Leads-To', findings,
1767
+ 'All leads-to invariants hold' if not findings else 'Leads-to findings: ' + str(len(findings)))
1768
+
1769
+
1770
+ def _find_sequence_violation(model, max_states, invariant):
1771
+ events = invariant['events']
1772
+ init = _initial_state(model)
1773
+
1774
+ def key(runtime, progress):
1775
+ return _runtime_key(runtime) + '|' + str(progress)
1776
+
1777
+ visited = set([key(init, 0)])
1778
+ queue = deque([{'runtime': init, 'progress': 0, 'path': []}])
1779
+ steps = 0
1780
+ while queue:
1781
+ entry = queue.popleft()
1782
+ steps += 1
1783
+ if steps > max_states:
1784
+ break
1785
+ for event in _all_events(model):
1786
+ for nxt in _step_runtime(model, entry['runtime'], event):
1787
+ progress = entry['progress']
1788
+ violation = False
1789
+ if progress < len(events) and event == events[progress]:
1790
+ progress += 1
1791
+ else:
1792
+ if event in events:
1793
+ index = events.index(event)
1794
+ if index > progress:
1795
+ violation = True
1796
+ p = list(entry['path'])
1797
+ p.append({'from': entry['runtime']['state'], 'event': event, 'to': nxt['state']})
1798
+ if violation:
1799
+ return {'invariant': invariant, 'path': p, 'reason': 'Event ' + event + ' occurred before ' + events[progress]}
1800
+ nk = key(nxt, progress)
1801
+ if nk not in visited:
1802
+ visited.add(nk)
1803
+ queue.append({'runtime': nxt, 'progress': progress, 'path': p})
1804
+ return None
1805
+
1806
+
1807
+ def A10_sequence_order(model, max_states):
1808
+ findings = []
1809
+ for invariant in model.get('invariants') or []:
1810
+ if invariant['kind'] != 'sequence':
1811
+ continue
1812
+ violation = _find_sequence_violation(model, max_states, invariant)
1813
+ if violation is not None:
1814
+ findings.append({
1815
+ 'code': 'A10_SEQUENCE_VIOLATION',
1816
+ 'severity': 'error',
1817
+ 'message': 'Sequence invariant "' + invariant['id'] + '" violated: ' + violation['reason'],
1818
+ 'path': violation['path'],
1819
+ 'evidence': {'invariant': invariant},
1820
+ })
1821
+ return _check_result('A10', 'Sequence Order', findings,
1822
+ 'All sequence invariants hold' if not findings else 'Sequence findings: ' + str(len(findings)))
1823
+
1824
+
1825
+ def _find_atomicity_violation(model, max_states, invariant):
1826
+ atomic = set(invariant['events'])
1827
+ init = _initial_state(model)
1828
+
1829
+ def key(runtime, started, closed):
1830
+ return _runtime_key(runtime) + '|' + ('1' if started else '0') + '|' + ('1' if closed else '0')
1831
+
1832
+ visited = set([key(init, False, False)])
1833
+ queue = deque([{'runtime': init, 'started': False, 'closed': False, 'path': []}])
1834
+ steps = 0
1835
+ while queue:
1836
+ entry = queue.popleft()
1837
+ steps += 1
1838
+ if steps > max_states:
1839
+ break
1840
+ for event in _all_events(model):
1841
+ for nxt in _step_runtime(model, entry['runtime'], event):
1842
+ started = entry['started'] or event in atomic
1843
+ closed = entry['closed'] or event == invariant['commit'] or (invariant.get('rollback') is not None and event == invariant['rollback'])
1844
+ p = list(entry['path'])
1845
+ p.append({'from': entry['runtime']['state'], 'event': event, 'to': nxt['state']})
1846
+ if entry['started'] and not entry['closed'] and event not in atomic and event != invariant['commit'] and event != invariant.get('rollback'):
1847
+ return {'invariant': invariant, 'path': p, 'reason': 'Left atomic scope via ' + event + ' without commit/rollback'}
1848
+ if started and not closed and _is_terminal(model, nxt['state']):
1849
+ return {'invariant': invariant, 'path': p, 'reason': 'Terminal state reached with incomplete atomic group'}
1850
+ nk = key(nxt, started, closed)
1851
+ if nk not in visited:
1852
+ visited.add(nk)
1853
+ queue.append({'runtime': nxt, 'started': started, 'closed': closed, 'path': p})
1854
+ return None
1855
+
1856
+
1857
+ def A11_atomicity(model, max_states):
1858
+ findings = []
1859
+ for invariant in model.get('invariants') or []:
1860
+ if invariant['kind'] != 'atomicity':
1861
+ continue
1862
+ violation = _find_atomicity_violation(model, max_states, invariant)
1863
+ if violation is not None:
1864
+ findings.append({
1865
+ 'code': 'A11_ATOMICITY_VIOLATION',
1866
+ 'severity': 'error',
1867
+ 'message': 'Atomicity invariant "' + invariant['id'] + '" violated: ' + violation['reason'],
1868
+ 'path': violation['path'],
1869
+ 'evidence': {'invariant': invariant},
1870
+ })
1871
+ return _check_result('A11', 'Atomicity', findings,
1872
+ 'All atomicity invariants hold' if not findings else 'Atomicity findings: ' + str(len(findings)))
1873
+
1874
+
1875
+ # ---------------------------------------------------------------------------
1876
+ # A12 — Budget (worst-case path cost)
1877
+ # ---------------------------------------------------------------------------
1878
+
1879
+ def _transition_cost(transition):
1880
+ return transition.get('cost', 1)
1881
+
1882
+
1883
+ def _runtime_graph(model, max_states):
1884
+ group_map = _group_transitions(model)
1885
+ exploration = _explore(model, max_states)
1886
+ edges = {}
1887
+ for runtime in exploration['reachable']:
1888
+ out = []
1889
+ for event in _all_events(model):
1890
+ group = group_map.get(runtime['state'] + '|' + event)
1891
+ if group is None or len(group) == 0:
1892
+ continue
1893
+ for transition in _applicable_transitions(group, runtime):
1894
+ nxt = _apply_updates(model, transition, runtime)
1895
+ out.append({
1896
+ 'to': _runtime_key(nxt),
1897
+ 'step': {'from': runtime['state'], 'event': transition['event'], 'to': nxt['state']},
1898
+ 'cost': _transition_cost(transition),
1899
+ })
1900
+ edges[_runtime_key(runtime)] = out
1901
+ return {
1902
+ 'edges': edges,
1903
+ 'keys': [_runtime_key(r) for r in exploration['reachable']],
1904
+ 'initKey': _runtime_key(exploration['initial_state']),
1905
+ }
1906
+
1907
+
1908
+ def _key_of_from_step(graph, step, target_key):
1909
+ for frm, out_edges in graph['edges'].items():
1910
+ for edge in out_edges:
1911
+ if (edge['to'] == target_key and edge['step'].get('event') == step.get('event')
1912
+ and edge['step'].get('from') == step.get('from') and edge['step'].get('to') == step.get('to')):
1913
+ return frm
1914
+ return graph['initKey']
1915
+
1916
+
1917
+ def _runtime_path_to_key(graph, target_key):
1918
+ parent = {}
1919
+ visited = set([graph['initKey']])
1920
+ queue = deque([graph['initKey']])
1921
+ while queue:
1922
+ current = queue.popleft()
1923
+ if current == target_key:
1924
+ steps = []
1925
+ k = target_key
1926
+ while k != graph['initKey']:
1927
+ step = parent.get(k)
1928
+ if step is None:
1929
+ break
1930
+ steps.insert(0, step)
1931
+ k = _key_of_from_step(graph, step, k)
1932
+ return steps
1933
+ for edge in graph['edges'].get(current) or []:
1934
+ if edge['to'] in visited:
1935
+ continue
1936
+ visited.add(edge['to'])
1937
+ parent[edge['to']] = edge['step']
1938
+ queue.append(edge['to'])
1939
+ return None
1940
+
1941
+
1942
+ def _find_unbounded_cycle(model, max_states):
1943
+ graph = _runtime_graph(model, max_states)
1944
+ color = {}
1945
+ for start in graph['keys']:
1946
+ if start in color:
1947
+ continue
1948
+ node_stack = [start]
1949
+ idx_stack = [0]
1950
+ depth_of = {start: 0}
1951
+ cost_at = {start: 0}
1952
+ prev_key = {}
1953
+ prev_step = {}
1954
+ color[start] = 1
1955
+ while node_stack:
1956
+ node = node_stack[-1]
1957
+ outs = graph['edges'].get(node) or []
1958
+ if idx_stack[-1] < len(outs):
1959
+ edge = outs[idx_stack[-1]]
1960
+ idx_stack[-1] += 1
1961
+ seen = color.get(edge['to'])
1962
+ if seen is None:
1963
+ color[edge['to']] = 1
1964
+ depth_of[edge['to']] = depth_of[node] + 1
1965
+ cost_at[edge['to']] = cost_at[node] + edge['cost']
1966
+ prev_key[edge['to']] = node
1967
+ prev_step[edge['to']] = edge['step']
1968
+ node_stack.append(edge['to'])
1969
+ idx_stack.append(0)
1970
+ elif seen == 1:
1971
+ cycle_cost = cost_at[node] + edge['cost'] - cost_at[edge['to']]
1972
+ if cycle_cost > 0:
1973
+ entry = _runtime_path_to_key(graph, edge['to']) or []
1974
+ round_steps = []
1975
+ cursor = node
1976
+ while cursor != edge['to'] and cursor in prev_step:
1977
+ round_steps.insert(0, prev_step[cursor])
1978
+ cursor = prev_key[cursor]
1979
+ round_steps.append(edge['step'])
1980
+ return list(entry) + round_steps
1981
+ else:
1982
+ color[node] = 2
1983
+ node_stack.pop()
1984
+ idx_stack.pop()
1985
+ depth_of.pop(node, None)
1986
+ cost_at.pop(node, None)
1987
+ prev_key.pop(node, None)
1988
+ prev_step.pop(node, None)
1989
+ return None
1990
+
1991
+
1992
+ def _find_budget_violation(model, max_states, invariant):
1993
+ group_map = _group_transitions(model)
1994
+ init = _initial_state(model)
1995
+ best_cost = {_runtime_key(init): 0}
1996
+ queue = deque([{'runtime': init, 'cost': 0, 'path': []}])
1997
+ steps = 0
1998
+ while queue:
1999
+ entry = queue.popleft()
2000
+ steps += 1
2001
+ if steps > max_states:
2002
+ break
2003
+ for event in _all_events(model):
2004
+ group = group_map.get(entry['runtime']['state'] + '|' + event)
2005
+ if group is None or len(group) == 0:
2006
+ continue
2007
+ for transition in _applicable_transitions(group, entry['runtime']):
2008
+ nxt = _apply_updates(model, transition, entry['runtime'])
2009
+ cost = entry['cost'] + _transition_cost(transition)
2010
+ p = list(entry['path'])
2011
+ p.append({'from': entry['runtime']['state'], 'event': transition['event'], 'to': nxt['state']})
2012
+ if cost > invariant['budget']:
2013
+ return {'path': p, 'cost': cost, 'unbounded': False}
2014
+ k = _runtime_key(nxt)
2015
+ best = best_cost.get(k)
2016
+ if best is None or cost < best:
2017
+ best_cost[k] = cost
2018
+ queue.append({'runtime': nxt, 'cost': cost, 'path': p})
2019
+ cycle = _find_unbounded_cycle(model, max_states)
2020
+ if cycle is not None:
2021
+ return {'path': cycle, 'unbounded': True}
2022
+ return None
2023
+
2024
+
2025
+ def A12_budget(model, max_states):
2026
+ budgets = [inv for inv in model.get('invariants') or [] if inv['kind'] == 'budget']
2027
+ uses_cost = any(t.get('cost') is not None for t in model.get('transitions') or [])
2028
+ findings = []
2029
+ if uses_cost and not budgets:
2030
+ findings.append({
2031
+ 'code': 'A12_COST_WITHOUT_BUDGET',
2032
+ 'severity': 'warning',
2033
+ '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.',
2034
+ })
2035
+ for invariant in budgets:
2036
+ violation = _find_budget_violation(model, max_states, invariant)
2037
+ if violation is not None:
2038
+ if violation['unbounded']:
2039
+ message = ('Budget invariant "' + invariant['id'] + '" (' + invariant['description']
2040
+ + ') exceeded: a reachable positive-cost cycle lets path cost grow without bound, so no finite budget '
2041
+ + js_number(invariant['budget']) + ' holds.')
2042
+ evidence = {'invariant': invariant, 'unbounded': True}
2043
+ else:
2044
+ message = ('Budget invariant "' + invariant['id'] + '" (' + invariant['description']
2045
+ + ') exceeded: worst-case path cost ' + js_number(violation['cost']) + ' is over budget '
2046
+ + js_number(invariant['budget']) + '.')
2047
+ evidence = {'invariant': invariant, 'totalCost': violation['cost'], 'unbounded': False}
2048
+ findings.append({'code': 'A12_BUDGET_OVER', 'severity': 'error', 'message': message,
2049
+ 'path': violation['path'], 'evidence': evidence})
2050
+ if not findings:
2051
+ detail = 'No budget invariants declared' if not budgets else 'All budget invariants hold'
2052
+ else:
2053
+ detail = 'Budget findings: ' + str(len(findings))
2054
+ return _check_result('A12', 'Budget', findings, detail)
2055
+
2056
+
2057
+ # ---------------------------------------------------------------------------
2058
+ # A13 — Probability reachability (DTMC)
2059
+ # ---------------------------------------------------------------------------
2060
+
2061
+ def _probability_outcomes(model, runtime):
2062
+ group_map = _group_transitions(model)
2063
+ outcomes = {}
2064
+ for event in _all_events(model):
2065
+ group = group_map.get(runtime['state'] + '|' + event)
2066
+ if group is None or len(group) == 0:
2067
+ continue
2068
+ for transition in _applicable_transitions(group, runtime):
2069
+ weight = transition.get('weight', 1)
2070
+ if not (weight > 0):
2071
+ continue
2072
+ nxt = _apply_updates(model, transition, runtime)
2073
+ k = _runtime_key(nxt)
2074
+ if k not in outcomes:
2075
+ outcomes[k] = {'state': nxt['state'], 'weight': weight}
2076
+ else:
2077
+ outcomes[k]['weight'] += weight
2078
+ return outcomes
2079
+
2080
+
2081
+ def _compute_hit_probability(model, max_states, target_state):
2082
+ exploration = _explore(model, max_states)
2083
+ reachable = exploration['reachable']
2084
+ n = len(reachable)
2085
+ key_index = {}
2086
+ for i, runtime in enumerate(reachable):
2087
+ key_index[_runtime_key(runtime)] = i
2088
+ fixed_value = [None] * n
2089
+ chains = []
2090
+ for i in range(n):
2091
+ runtime = reachable[i]
2092
+ if runtime['state'] == target_state:
2093
+ fixed_value[i] = 1
2094
+ chains.append([])
2095
+ continue
2096
+ outs = _probability_outcomes(model, runtime)
2097
+ total = 0.0
2098
+ lst = []
2099
+ for k, entry in outs.items():
2100
+ j = key_index.get(k)
2101
+ if j is None:
2102
+ continue
2103
+ lst.append({'j': j, 'w': entry['weight']})
2104
+ total += entry['weight']
2105
+ if total <= 0:
2106
+ fixed_value[i] = 0
2107
+ chains.append([])
2108
+ continue
2109
+ chains.append([{'j': item['j'], 'w': item['w'] / total} for item in lst])
2110
+ p = [0.0] * n
2111
+ converged = False
2112
+ for _iter in range(20000):
2113
+ max_delta = 0.0
2114
+ for i in range(n):
2115
+ if fixed_value[i] is not None:
2116
+ continue
2117
+ lst = chains[i]
2118
+ acc = 0.0
2119
+ for item in lst:
2120
+ fixed = fixed_value[item['j']]
2121
+ acc += item['w'] * (p[item['j']] if fixed is None else fixed)
2122
+ delta = abs(acc - p[i])
2123
+ if delta > max_delta:
2124
+ max_delta = delta
2125
+ p[i] = acc
2126
+ if max_delta < 1e-9:
2127
+ converged = True
2128
+ break
2129
+ init_index = key_index.get(_runtime_key(exploration['initial_state']), 0)
2130
+ fixed = fixed_value[init_index]
2131
+ return {'probability': p[init_index] if fixed is None else fixed, 'converged': converged}
2132
+
2133
+
2134
+ def A13_probability(model, max_states):
2135
+ findings = []
2136
+ eps = 1e-9
2137
+ for invariant in model.get('invariants') or []:
2138
+ if invariant['kind'] != 'probability':
2139
+ continue
2140
+ result = _compute_hit_probability(model, max_states, invariant['target'])
2141
+ probability = result['probability']
2142
+ converged = result['converged']
2143
+ op = invariant['op']
2144
+ p_bound = invariant['p']
2145
+ violated = False
2146
+ if op == '>=':
2147
+ violated = probability < p_bound - eps
2148
+ elif op == '>':
2149
+ violated = probability <= p_bound + eps
2150
+ elif op == '<=':
2151
+ violated = probability > p_bound + eps
2152
+ else:
2153
+ violated = probability >= p_bound - eps
2154
+ if violated:
2155
+ findings.append({
2156
+ 'code': 'A13_PROBABILITY_VIOLATION',
2157
+ 'severity': 'error',
2158
+ 'message': ('Probability invariant "' + invariant['id'] + '" (' + invariant['description']
2159
+ + ') violated: P(hit ' + invariant['target'] + ') = ' + format(probability, '.6f')
2160
+ + ' does not satisfy ' + op + ' ' + js_number(p_bound) + '.'),
2161
+ 'evidence': {'invariant': invariant, 'computed': probability, 'converged': converged},
2162
+ })
2163
+ elif not converged:
2164
+ findings.append({
2165
+ 'code': 'A13_NO_CONVERGENCE',
2166
+ 'severity': 'warning',
2167
+ 'message': ('Probability invariant "' + invariant['id'] + '" passes, but value iteration did not fully converge within the iteration cap.'),
2168
+ 'evidence': {'invariant': invariant, 'computed': probability, 'converged': converged},
2169
+ })
2170
+ return _check_result('A13', 'Probability Reachability', findings,
2171
+ 'No probability invariants declared or all hold' if not findings else 'Probability findings: ' + str(len(findings)))
2172
+
2173
+
2174
+ # ---------------------------------------------------------------------------
2175
+ # A14 — Deadline (discrete tick clock)
2176
+ # ---------------------------------------------------------------------------
2177
+
2178
+ def A14_deadline(model, max_states):
2179
+ findings = []
2180
+ tick_events = set(model.get('tickEvents') or [])
2181
+ limits = {}
2182
+ has_limit = False
2183
+ for state in model.get('states') or []:
2184
+ if state.get('maxTicks') is not None:
2185
+ limits[state['id']] = state['maxTicks']
2186
+ has_limit = True
2187
+ if not has_limit:
2188
+ return _check_result('A14', 'Deadline', [], 'No state declares maxTicks')
2189
+ if not tick_events:
2190
+ return _check_result('A14', 'Deadline', [{
2191
+ 'code': 'A14_NO_TICK_EVENTS',
2192
+ 'severity': 'warning',
2193
+ 'message': 'States declare maxTicks, but no tickEvents are declared, so deadline compliance cannot be verified.',
2194
+ }], 'Missing tick events')
2195
+ group_map = _group_transitions(model)
2196
+ init = _initial_state(model)
2197
+ queue = deque([{'runtime': init, 'ticks': 0, 'path': []}])
2198
+ seen = set()
2199
+ steps = 0
2200
+ while queue:
2201
+ entry = queue.popleft()
2202
+ steps += 1
2203
+ if steps > max_states:
2204
+ break
2205
+ k = _runtime_key(entry['runtime']) + '|' + str(entry['ticks'])
2206
+ if k in seen:
2207
+ continue
2208
+ seen.add(k)
2209
+ for event in _all_events(model):
2210
+ group = group_map.get(entry['runtime']['state'] + '|' + event)
2211
+ if group is None or len(group) == 0:
2212
+ continue
2213
+ for transition in _applicable_transitions(group, entry['runtime']):
2214
+ nxt = _apply_updates(model, transition, entry['runtime'])
2215
+ is_tick = event in tick_events
2216
+ stays = nxt['state'] == entry['runtime']['state']
2217
+ p = list(entry['path'])
2218
+ p.append({'from': entry['runtime']['state'], 'event': transition['event'], 'to': nxt['state']})
2219
+ if is_tick and stays:
2220
+ lim = limits.get(nxt['state'])
2221
+ if lim is not None and entry['ticks'] + 1 > lim:
2222
+ findings.append({
2223
+ 'code': 'A14_DEADLINE_MISS',
2224
+ 'severity': 'error',
2225
+ 'message': 'Deadline missed: state ' + nxt['state'] + ' can remain resident for more than ' + js_number(lim) + ' tick(s).',
2226
+ 'path': p,
2227
+ 'evidence': {'state': nxt['state'], 'maxTicks': lim, 'ticks': entry['ticks'] + 1},
2228
+ })
2229
+ continue
2230
+ ticks = entry['ticks'] + 1
2231
+ elif is_tick and not stays:
2232
+ ticks = 0
2233
+ elif not is_tick and stays:
2234
+ ticks = entry['ticks']
2235
+ else:
2236
+ ticks = 0
2237
+ nk = _runtime_key(nxt) + '|' + str(ticks)
2238
+ if nk not in seen:
2239
+ queue.append({'runtime': nxt, 'ticks': ticks, 'path': p})
2240
+ return _check_result('A14', 'Deadline', findings,
2241
+ 'All deadlines respected' if not findings else 'Deadline findings: ' + str(len(findings)))
2242
+
2243
+
2244
+ # ---------------------------------------------------------------------------
2245
+ # Coverage notes (informational gap notices)
2246
+ # ---------------------------------------------------------------------------
2247
+
2248
+ _TIMING_RE = re.compile(r'(?<![a-z0-9])(timeout|watchdog|timer|tick|deadline|period|delay|elapsed|latency)(?![a-z0-9])', re.IGNORECASE)
2249
+ _PREEMPTION_RE = re.compile(r'(?<![a-z0-9])(isr|irq|interrupt|task|thread|preempt|rtos)(?![a-z0-9])', re.IGNORECASE)
2250
+ _HYBRID_RE = re.compile(r'(?<![a-z0-9])(pid|plant|feedback|control.?loop|stability|stable|settling|damping|oscillat|chatter|kalman|foc|field.?weaken|motor|torque)(?![a-z0-9])', re.IGNORECASE)
2251
+ _PROBABILISTIC_RE = re.compile(r'(?<![a-z0-9])(mtbf|mttf|failure.?rate|reliability|probability|probabilistic|markov|stochastic|fault.?tree|fmea)(?![a-z0-9])', re.IGNORECASE)
2252
+
2253
+
2254
+ def compute_coverage_notes(model):
2255
+ names = []
2256
+ for state in model.get('states') or []:
2257
+ names.append(state['id'])
2258
+ for action in state.get('onEntry') or []:
2259
+ names.append(action)
2260
+ for action in state.get('onExit') or []:
2261
+ names.append(action)
2262
+ for transition in model.get('transitions') or []:
2263
+ names.append(transition['event'])
2264
+ corpus = ' '.join(names)
2265
+ notes = []
2266
+ if _TIMING_RE.search(corpus):
2267
+ notes.append('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).')
2268
+ if _PREEMPTION_RE.search(corpus):
2269
+ notes.append('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+).')
2270
+ if _HYBRID_RE.search(corpus):
2271
+ notes.append('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).')
2272
+ if _PROBABILISTIC_RE.search(corpus):
2273
+ notes.append('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.')
2274
+ return notes
2275
+
2276
+
2277
+ # ---------------------------------------------------------------------------
2278
+ # main entry
2279
+ # ---------------------------------------------------------------------------
2280
+
2281
+ def run_verification(input_value, options=None):
2282
+ if options is None:
2283
+ options = {}
2284
+ max_states = options.get('maxStates', DEFAULT_MAX_STATES)
2285
+ max_permutation_events = options.get('maxPermutationEvents', DEFAULT_MAX_PERMUTATION_EVENTS)
2286
+ ok_model, model_or_errors = validate_model(input_value)
2287
+ if not ok_model:
2288
+ errors = model_or_errors
2289
+ return {
2290
+ 'ok': False,
2291
+ 'schemaVersion': 1,
2292
+ 'modelHash': '',
2293
+ 'summary': {'states': 0, 'transitions': 0, 'errors': len(errors), 'warnings': 0, 'checksRun': 0},
2294
+ 'checks': [{
2295
+ 'id': 'MODEL',
2296
+ 'name': 'Model Validation',
2297
+ 'status': 'fail',
2298
+ 'detail': 'Model schema validation failed: ' + str(len(errors)) + ' errors',
2299
+ 'findings': [{'code': 'MODEL_INVALID', 'severity': 'error', 'message': message} for message in errors],
2300
+ }],
2301
+ }
2302
+ model = model_or_errors
2303
+ exploration = _explore(model, max_states)
2304
+ checks = [
2305
+ S1_reachability(model, exploration),
2306
+ S2_deadlock(model),
2307
+ S3_liveness(model),
2308
+ S4_determinism(model),
2309
+ S5_event_completeness(model),
2310
+ S6_guard_completeness(model),
2311
+ S7_invariants(model, max_states),
2312
+ S8_monotonic_variables(model),
2313
+ A1_unexpected_events(model),
2314
+ A2_race_interleaving(model, exploration),
2315
+ A3_order_permutation(model, max_permutation_events),
2316
+ A4_pair_symmetry(model),
2317
+ A5_boundary_blast(model),
2318
+ A6_resource_injection(model),
2319
+ A7_shortest_violations(model, max_states),
2320
+ A8_idempotent_replay(model, exploration),
2321
+ A9_leads_to(model, exploration),
2322
+ A10_sequence_order(model, max_states),
2323
+ A11_atomicity(model, max_states),
2324
+ A12_budget(model, max_states),
2325
+ A13_probability(model, max_states),
2326
+ A14_deadline(model, max_states),
2327
+ ]
2328
+ comparison = None
2329
+ before_model = options.get('beforeModel')
2330
+ if before_model is not None:
2331
+ before_ok, before_or_errors = validate_model(before_model)
2332
+ if not before_ok:
2333
+ before_errors = before_or_errors
2334
+ return {
2335
+ 'ok': False,
2336
+ 'schemaVersion': 1,
2337
+ 'modelHash': model_hash(model),
2338
+ 'summary': {
2339
+ 'states': len(model.get('states') or []),
2340
+ 'transitions': len(model.get('transitions') or []),
2341
+ 'errors': len(before_errors),
2342
+ 'warnings': 0,
2343
+ 'checksRun': len(checks) + 1,
2344
+ 'truncated': exploration['truncated'],
2345
+ },
2346
+ 'checks': list(checks) + [{
2347
+ 'id': 'BEFORE_MODEL',
2348
+ 'name': 'Before Model Validation',
2349
+ 'status': 'fail',
2350
+ 'detail': 'Before model schema validation failed: ' + str(len(before_errors)) + ' errors',
2351
+ 'findings': [{'code': 'BEFORE_MODEL_INVALID', 'severity': 'error', 'message': message} for message in before_errors],
2352
+ }],
2353
+ }
2354
+ before = before_or_errors
2355
+ mapping = options.get('stateMapping') or {}
2356
+ checks.append(D1_behavioral_preservation(before, model, mapping))
2357
+ checks.append(D2_invariant_continuity(before, model, max_states, mapping))
2358
+ checks.append(D3_regression_delta(before, model, mapping))
2359
+ checks.append(D4_deadlock_liveness_regression(before, model, mapping))
2360
+ comparison = _build_comparison_summary(before, model, mapping)
2361
+ errors = sum(1 for check in checks for f in check['findings'] if f.get('severity') == 'error')
2362
+ warnings = sum(1 for check in checks for f in check['findings'] if f.get('severity') == 'warning')
2363
+ coverage_notes = compute_coverage_notes(model)
2364
+ report = {
2365
+ 'ok': True,
2366
+ 'schemaVersion': 1,
2367
+ 'modelHash': model_hash(model),
2368
+ 'summary': {
2369
+ 'states': len(model.get('states') or []),
2370
+ 'transitions': len(model.get('transitions') or []),
2371
+ 'errors': errors,
2372
+ 'warnings': warnings,
2373
+ 'checksRun': len(checks),
2374
+ 'truncated': exploration['truncated'],
2375
+ },
2376
+ 'checks': checks,
2377
+ }
2378
+ if model.get('narrative') is not None:
2379
+ report['narrative'] = model['narrative']
2380
+ if comparison is not None:
2381
+ report['comparison'] = comparison
2382
+ if coverage_notes:
2383
+ report['coverageNotes'] = coverage_notes
2384
+ return report
2385
+
2386
+
2387
+ # ---------------------------------------------------------------------------
2388
+ # Composition verification (N machines)
2389
+ # ---------------------------------------------------------------------------
2390
+
2391
+ def run_composition_verification(machines_input, options=None):
2392
+ if options is None:
2393
+ options = {}
2394
+ rendezvous_set = set(options.get('rendezvous') or [])
2395
+ max_states = options.get('maxStates', DEFAULT_MAX_STATES)
2396
+ models = []
2397
+ hashes = []
2398
+ model_findings = []
2399
+ for index, input_value in enumerate(machines_input):
2400
+ ok_model, model_or_errors = validate_model(input_value)
2401
+ if not ok_model:
2402
+ model_findings.append({'code': 'MODEL_INVALID', 'severity': 'error',
2403
+ 'message': 'machine ' + str(index) + ' invalid: ' + '; '.join(model_or_errors)})
2404
+ else:
2405
+ models.append(model_or_errors)
2406
+ hashes.append(model_hash(model_or_errors))
2407
+ machine_summary = [{'modelHash': hashes[i] if i < len(hashes) else '', 'states': len(m['states'] or []), 'transitions': len(m['transitions'] or [])}
2408
+ for i, m in enumerate(models)]
2409
+ if model_findings or len(models) < 2:
2410
+ if len(models) < 2 and not model_findings:
2411
+ model_findings.append({'code': 'MODEL_INVALID', 'severity': 'error',
2412
+ 'message': 'composition requires at least two machines'})
2413
+ return {
2414
+ 'ok': False,
2415
+ 'summary': {'machineCount': len(models), 'machines': machine_summary, 'compositeStates': 0,
2416
+ 'errors': len(model_findings), 'warnings': 0, 'truncated': False},
2417
+ 'checks': [{'id': 'MODEL', 'name': 'Machine Validation', 'status': 'fail',
2418
+ 'detail': 'composition input validation failed', 'findings': model_findings}],
2419
+ }
2420
+ machine_event_sets = [set(t['event'] for t in m.get('transitions') or []) for m in models]
2421
+
2422
+ def composition_moves(node):
2423
+ moves = []
2424
+ for i in range(len(models)):
2425
+ if _is_terminal(models[i], node['runtimes'][i]['state']):
2426
+ continue
2427
+ for event in _all_events(models[i]):
2428
+ if event in rendezvous_set:
2429
+ continue
2430
+ for nxt in _step_runtime(models[i], node['runtimes'][i], event):
2431
+ runtimes = list(node['runtimes'])
2432
+ runtimes[i] = nxt
2433
+ moves.append({'next': {'runtimes': runtimes, 'path': []}, 'event': event, 'machines': [i]})
2434
+ for event in rendezvous_set:
2435
+ participants = []
2436
+ for i in range(len(models)):
2437
+ if _is_terminal(models[i], node['runtimes'][i]['state']):
2438
+ continue
2439
+ if event not in machine_event_sets[i]:
2440
+ continue
2441
+ participants.append(i)
2442
+ if len(participants) < 2:
2443
+ continue
2444
+ outcomes = [_step_runtime(models[i], node['runtimes'][i], event) for i in participants]
2445
+ if any(len(lst) == 0 for lst in outcomes):
2446
+ continue
2447
+ combos = [list(node['runtimes'])]
2448
+ for slot, i in enumerate(participants):
2449
+ next_combos = []
2450
+ for combo in combos:
2451
+ for outcome in outcomes[slot]:
2452
+ copy = list(combo)
2453
+ copy[i] = outcome
2454
+ next_combos.append(copy)
2455
+ combos = next_combos
2456
+ for runtimes in combos:
2457
+ moves.append({'next': {'runtimes': runtimes, 'path': []}, 'event': event, 'machines': participants})
2458
+ return moves
2459
+
2460
+ def key_of(node):
2461
+ return '|'.join(_runtime_key(r) for r in node['runtimes'])
2462
+
2463
+ init = {'runtimes': [_initial_state(m) for m in models], 'path': []}
2464
+ visited = set()
2465
+ queue = deque([init])
2466
+ composite_states = 0
2467
+ truncated = False
2468
+ fired_count = {}
2469
+ for event in rendezvous_set:
2470
+ fired_count[event] = 0
2471
+ c1_findings = []
2472
+ while queue:
2473
+ node = queue.popleft()
2474
+ k = key_of(node)
2475
+ if k in visited:
2476
+ continue
2477
+ visited.add(k)
2478
+ composite_states += 1
2479
+ if composite_states > max_states:
2480
+ truncated = True
2481
+ break
2482
+ moves = composition_moves(node)
2483
+ all_terminal = all(_is_terminal(models[i], r['state']) for i, r in enumerate(node['runtimes']))
2484
+ if not moves and not all_terminal:
2485
+ c1_findings.append({
2486
+ 'code': 'C1_COMPOSITION_DEADLOCK',
2487
+ 'severity': 'error',
2488
+ 'message': 'Composition deadlock: no machine can advance from (' + ', '.join(r['state'] for r in node['runtimes']) + ') while at least one is not terminal.',
2489
+ 'evidence': {'steps': node['path'], 'states': [r['state'] for r in node['runtimes']]},
2490
+ })
2491
+ continue
2492
+ for move in moves:
2493
+ if len(move['machines']) > 1:
2494
+ fired_count[move['event']] = fired_count.get(move['event'], 0) + 1
2495
+ nk = key_of(move['next'])
2496
+ if nk in visited:
2497
+ continue
2498
+ queue.append({'runtimes': move['next']['runtimes'],
2499
+ 'path': list(node['path']) + [{'event': move['event'], 'machines': move['machines']}]})
2500
+ c2_findings = []
2501
+ all_events_set = set()
2502
+ for s in machine_event_sets:
2503
+ all_events_set.update(s)
2504
+ for event in rendezvous_set:
2505
+ if event not in all_events_set:
2506
+ continue
2507
+ if fired_count.get(event, 0) == 0:
2508
+ c2_findings.append({
2509
+ 'code': 'C2_RENDEZVOUS_NEVER_FIRES',
2510
+ 'severity': 'warning',
2511
+ 'message': 'Rendezvous event ' + event + ' can never fire: fewer than two machines ever jointly enable it.',
2512
+ })
2513
+ errors = len(c1_findings)
2514
+ warnings = len(c2_findings)
2515
+ checks = [
2516
+ _check_result('C1', 'Composition Deadlock', c1_findings,
2517
+ 'No composition deadlock reachable' if not c1_findings else 'Composition deadlocks: ' + str(len(c1_findings))),
2518
+ _check_result('C2', 'Rendezvous Sync', c2_findings,
2519
+ 'All rendezvous events can fire' if not c2_findings else 'Rendezvous warnings: ' + str(len(c2_findings))),
2520
+ ]
2521
+ return {
2522
+ 'ok': errors == 0,
2523
+ 'summary': {'machineCount': len(models), 'machines': machine_summary, 'compositeStates': composite_states,
2524
+ 'errors': errors, 'warnings': warnings, 'truncated': truncated},
2525
+ 'checks': checks,
2526
+ }
2527
+
2528
+
2529
+ # ---------------------------------------------------------------------------
2530
+ # External-tool exporters (UPPAAL / TLA+ / PRISM / SPIN)
2531
+ # ---------------------------------------------------------------------------
2532
+
2533
+ def _prepare_model(input_value):
2534
+ ok_model, model_or_errors = validate_model(input_value)
2535
+ if not ok_model:
2536
+ raise ValueError('model invalid: ' + '; '.join(model_or_errors))
2537
+ model = model_or_errors
2538
+ used = set()
2539
+ state_ids = []
2540
+ index_of = {}
2541
+ state_id_of = {}
2542
+
2543
+ def sanitize(raw):
2544
+ out = re.sub(r'[^A-Za-z0-9_]', '_', raw)
2545
+ if len(out) == 0 or re.match(r'^[0-9]', out):
2546
+ out = 's_' + out
2547
+ return out
2548
+
2549
+ for index, state in enumerate(model.get('states') or []):
2550
+ candidate = sanitize(state['id'])
2551
+ if candidate in used:
2552
+ candidate = candidate + '_' + str(index)
2553
+ used.add(candidate)
2554
+ state_ids.append(candidate)
2555
+ index_of[state['id']] = index
2556
+ state_id_of[state['id']] = candidate
2557
+ return {'model': model, 'stateIds': state_ids, 'indexOf': index_of,
2558
+ 'initId': state_id_of.get(model['init'], state_ids[0] if state_ids else ''), 'stateIdOf': state_id_of}
2559
+
2560
+
2561
+ def _num_value(value):
2562
+ return (1 if value else 0) if isinstance(value, bool) else value
2563
+
2564
+
2565
+ def _leaf_expr(node, var_name):
2566
+ return var_name(node['variable']) + ' ' + node['op'] + ' ' + str(_num_value(node['value']))
2567
+
2568
+
2569
+ def _guard_expr(node, and_op, or_op, not_op, var_name, op_for=None):
2570
+ if op_for is None:
2571
+ op_for = lambda op: op
2572
+ if node is None:
2573
+ return ''
2574
+ if 'variable' in node:
2575
+ return var_name(node['variable']) + ' ' + op_for(node['op']) + ' ' + str(_num_value(node['value']))
2576
+ if 'all' in node:
2577
+ return '(' + _join_exprs(node['all'], and_op, or_op, not_op, var_name, op_for, and_op) + ')'
2578
+ if 'any' in node:
2579
+ return '(' + _join_exprs(node['any'], and_op, or_op, not_op, var_name, op_for, or_op) + ')'
2580
+ if 'not' in node:
2581
+ return not_op + '(' + _guard_expr(node['not'], and_op, or_op, not_op, var_name, op_for) + ')'
2582
+ return 'true'
2583
+
2584
+
2585
+ def _join_exprs(children, and_op, or_op, not_op, var_name, op_for, sep):
2586
+ parts = []
2587
+ for child in children:
2588
+ parts.append(_guard_expr(child, and_op, or_op, not_op, var_name, op_for))
2589
+ return (' ' + sep + ' ').join(parts)
2590
+
2591
+
2592
+ def _update_assignments(updates, var_name):
2593
+ out = []
2594
+ for update in updates or []:
2595
+ name = var_name(update['variable'])
2596
+ value = update.get('value', 1)
2597
+ op = update['op']
2598
+ if op == 'set':
2599
+ out.append(name + ' := ' + str(value))
2600
+ elif op == 'inc':
2601
+ out.append(name + ' := ' + name + ' + ' + str(value))
2602
+ else:
2603
+ out.append(name + ' := ' + name + ' - ' + str(value))
2604
+ return out
2605
+
2606
+
2607
+ def _forbidden_indexes(ex):
2608
+ out = []
2609
+ for invariant in ex['model'].get('invariants') or []:
2610
+ if invariant['kind'] == 'never-states':
2611
+ for s in invariant['states']:
2612
+ index = ex['indexOf'].get(s)
2613
+ if index is not None:
2614
+ out.append(index)
2615
+ return out
2616
+
2617
+
2618
+ def _export_uppaal(ex):
2619
+ model = ex['model']
2620
+ state_ids = ex['stateIds']
2621
+ index_of = ex['indexOf']
2622
+ warnings = []
2623
+
2624
+ def xml(s):
2625
+ return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
2626
+
2627
+ def safe_event(event):
2628
+ return xml(re.sub(r'[^A-Za-z0-9_]', '_', event))
2629
+
2630
+ globals_lines = []
2631
+ for v in model.get('variables') or []:
2632
+ globals_lines.append('int ' + v['name'] + ' = ' + str(_num_value(v['init'])) + ';')
2633
+ globals_str = '\n'.join(globals_lines)
2634
+ locs = []
2635
+ for i, state in enumerate(model.get('states') or []):
2636
+ name_el = '\n <name x="16" y="16">' + xml(ex['stateIdOf'].get(state['id'], state['id'])) + '</name>'
2637
+ locs.append(' <location id="id' + str(i) + '" x="' + str(i * 120) + '" y="0">' + name_el + '\n </location>')
2638
+ locs_str = '\n'.join(locs)
2639
+ edges = []
2640
+ for i, tr in enumerate(model.get('transitions') or []):
2641
+ guard = _guard_expr(tr.get('guard'), '&&', '||', '!', lambda v: v)
2642
+ assigns = _update_assignments(tr.get('updates'), lambda v: v)
2643
+ labels = []
2644
+ if guard != '':
2645
+ labels.append(' <label kind="guard" x="16" y="16">' + xml(guard) + '</label>')
2646
+ if len(assigns) > 0:
2647
+ labels.append(' <label kind="assignment" x="16" y="16">' + xml(',\n'.join(assigns)) + '</label>')
2648
+ labels.append(' <label kind="synchronisation" x="16" y="16">' + safe_event(tr['event']) + '!</label>')
2649
+ edges.append(' <transition id="id' + str(1000 + i) + '">\n <source ref="id' + str(index_of.get(tr['from'])) + '"/>\n <target ref="id' + str(index_of.get(tr['to'])) + '"/>\n' + '\n'.join(labels) + '\n </transition>')
2650
+ edges_str = '\n'.join(edges)
2651
+ queries = []
2652
+ for state in model.get('states') or []:
2653
+ if state.get('terminal'):
2654
+ queries.append('E<> LogicProbe.' + ex['stateIdOf'].get(state['id'], state['id']))
2655
+ for index in _forbidden_indexes(ex):
2656
+ queries.append('A[] not LogicProbe.' + state_ids[index])
2657
+ for invariant in model.get('invariants') or []:
2658
+ if invariant['kind'] != 'never-states':
2659
+ warnings.append('UPPAAL export carries invariant kind ' + invariant['kind'] + ' only as a comment (not expressible in the exported XML).')
2660
+ init_index = index_of.get(model['init'], 0)
2661
+ xta = ('<?xml version="1.0" encoding="utf-8"?>\n'
2662
+ + '<!DOCTYPE nta PUBLIC \'-//Uppaal Team//DTD Flat System 1.6//EN\' \'http://www.it.uu.se/research/group/darts/uppaal/flat-1_6.dtd\'>\n'
2663
+ + '<nta>\n'
2664
+ + ' <declaration>// Generated by logicprobe (UPPAAL XML export; booleans as int).\n' + (globals_str + '\n' if globals_str != '' else '') + '</declaration>\n'
2665
+ + ' <template>\n'
2666
+ + ' <name x="5" y="5">LogicProbe</name>\n'
2667
+ + ' <declaration/>\n'
2668
+ + locs_str + '\n'
2669
+ + ' <init ref="id' + str(init_index) + '"/>\n'
2670
+ + edges_str + '\n'
2671
+ + ' </template>\n'
2672
+ + ' <system>system LogicProbe;</system>\n'
2673
+ + '</nta>\n')
2674
+ extras = {'queries': '\n'.join(queries) + ('\n' if queries else '')}
2675
+ return {'format': 'uppaal', 'primary': xta, 'extras': extras, 'warnings': warnings}
2676
+
2677
+
2678
+ def _export_tla(ex):
2679
+ model = ex['model']
2680
+ state_ids = ex['stateIds']
2681
+ state_id_of = ex['stateIdOf']
2682
+ init_id = ex['initId']
2683
+ warnings = []
2684
+ bs = chr(92)
2685
+ CONJ = '/' + bs
2686
+ DISJ = bs + '/'
2687
+ MEM = bs + 'in'
2688
+ NOTIN = bs + 'notin'
2689
+ variables = ['pc'] + [v['name'] for v in model.get('variables') or []]
2690
+ type_var_parts = []
2691
+ for v in model.get('variables') or []:
2692
+ lo = _num_value(v.get('min', 0))
2693
+ hi = max(1, _num_value(v.get('max', 1)))
2694
+ type_var_parts.append(v['name'] + ' ' + MEM + ' ' + str(lo) + '..' + str(hi))
2695
+ type_var = (' ' + CONJ + ' ').join(type_var_parts)
2696
+ init_parts = ['pc = "' + init_id + '"'] + [v['name'] + ' = ' + str(_num_value(v['init'])) for v in model.get('variables') or []]
2697
+ init_str = (' ' + CONJ + ' ').join(init_parts)
2698
+ next_parts = []
2699
+ for tr in model.get('transitions') or []:
2700
+ guard = _guard_expr(tr.get('guard'), CONJ, DISJ, '~', lambda v: v)
2701
+ guard = guard.replace(' == ', ' = ').replace(' != ', ' /= ')
2702
+ updates = []
2703
+ for u in tr.get('updates') or []:
2704
+ value = u.get('value', 1)
2705
+ if u['op'] == 'set':
2706
+ updates.append(u['variable'] + ' = ' + str(value))
2707
+ elif u['op'] == 'inc':
2708
+ updates.append(u['variable'] + ' = ' + u['variable'] + ' + ' + str(value))
2709
+ else:
2710
+ updates.append(u['variable'] + ' = ' + u['variable'] + ' - ' + str(value))
2711
+ pc_part = "pc'" + ' = "' + state_id_of.get(tr['to'], tr['to']) + '"'
2712
+ rest = (' ' + CONJ + ' ' + ' '.join(updates)) if updates else ''
2713
+ if guard == '':
2714
+ guard_part = 'pc = "' + state_id_of.get(tr['from'], tr['from']) + '"'
2715
+ else:
2716
+ guard_part = 'pc = "' + state_id_of.get(tr['from'], tr['from']) + '" ' + CONJ + ' ' + guard
2717
+ next_parts.append(DISJ + ' ' + guard_part + ' ' + CONJ + ' ' + pc_part + rest)
2718
+ next_str = '\n'.join(next_parts)
2719
+ forbids = ['"' + state_ids[i] + '"' for i in _forbidden_indexes(ex)]
2720
+ props = []
2721
+ if forbids:
2722
+ props.append('CheckSafety == [] (pc ' + NOTIN + ' {' + ', '.join(forbids) + '})')
2723
+ for invariant in model.get('invariants') or []:
2724
+ if invariant['kind'] != 'never-states':
2725
+ warnings.append('TLA+ v1 does not translate invariant kind ' + invariant['kind'] + '; only never-states becomes a property.')
2726
+ state_lit = ', '.join('"' + s + '"' for s in state_ids)
2727
+ spec = ('---- MODULE LogicProbe ----\n'
2728
+ + 'EXTENDS Integers\n'
2729
+ + 'VARIABLES ' + ', '.join(variables) + '\n'
2730
+ + 'States == {' + state_lit + '}\n'
2731
+ + 'TypeOK == pc ' + MEM + ' States' + ((' ' + CONJ + ' ' + type_var) if type_var != '' else '') + '\n'
2732
+ + 'Init == ' + init_str + '\n'
2733
+ + 'Next ==\n' + next_str + '\n'
2734
+ + (('\n' + '\n\n'.join(props) + '\n') if props else '')
2735
+ + '====\n')
2736
+ extras = {'properties': '\n'.join(props) + '\n'} if props else {}
2737
+ return {'format': 'tla', 'primary': spec, 'extras': extras, 'warnings': warnings}
2738
+
2739
+
2740
+ def _export_prism(ex):
2741
+ model = ex['model']
2742
+ index_of = ex['indexOf']
2743
+ state_id_of = ex['stateIdOf']
2744
+ warnings = []
2745
+ ranges = []
2746
+ for v in model.get('variables') or []:
2747
+ ranges.append({'name': v['name'],
2748
+ 'lo': _num_value(v.get('min', 0)),
2749
+ 'hi': max(1, _num_value(v.get('max', 3))),
2750
+ 'init': _num_value(v['init'])})
2751
+ valuations = []
2752
+
2753
+ def build(i, acc):
2754
+ if i == len(ranges):
2755
+ valuations.append(acc)
2756
+ return
2757
+ for value in range(ranges[i]['lo'], ranges[i]['hi'] + 1):
2758
+ copy = dict(acc)
2759
+ copy[ranges[i]['name']] = value
2760
+ build(i + 1, copy)
2761
+
2762
+ build(0, {})
2763
+ size = len(model.get('states') or []) * max(1, len(valuations))
2764
+ if size > 20000:
2765
+ raise ValueError('PRISM enumeration too large: ' + str(len(model.get('states') or [])) + ' x ' + str(len(valuations)))
2766
+
2767
+ def eval_guard(guard, val):
2768
+ if guard is None:
2769
+ return True
2770
+ if 'variable' in guard:
2771
+ if guard['variable'] in val:
2772
+ left = val[guard['variable']]
2773
+ else:
2774
+ init_val = 0
2775
+ for v in model.get('variables') or []:
2776
+ if v['name'] == guard['variable']:
2777
+ init_val = _num_value(v.get('init', 0))
2778
+ left = init_val
2779
+ right = _num_value(guard['value'])
2780
+ op = guard['op']
2781
+ if op == '==':
2782
+ return left == right
2783
+ if op == '!=':
2784
+ return left != right
2785
+ if op == '<':
2786
+ return left < right
2787
+ if op == '<=':
2788
+ return left <= right
2789
+ if op == '>':
2790
+ return left > right
2791
+ if op == '>=':
2792
+ return left >= right
2793
+ return False
2794
+ if 'all' in guard:
2795
+ return all(eval_guard(g, val) for g in guard['all'])
2796
+ if 'any' in guard:
2797
+ return any(eval_guard(g, val) for g in guard['any'])
2798
+ if 'not' in guard:
2799
+ return not eval_guard(guard['not'], val)
2800
+ return False
2801
+
2802
+ def apply_updates(t, val):
2803
+ out = dict(val)
2804
+ for u in t.get('updates') or []:
2805
+ cur = out.get(u['variable'], 0)
2806
+ value = u.get('value', 1)
2807
+ if u['op'] == 'set':
2808
+ out[u['variable']] = value
2809
+ elif u['op'] == 'inc':
2810
+ out[u['variable']] = cur + value
2811
+ else:
2812
+ out[u['variable']] = cur - value
2813
+ return out
2814
+
2815
+ commands = []
2816
+ for state in model.get('states') or []:
2817
+ for val in valuations:
2818
+ groups = {}
2819
+ for t in model.get('transitions') or []:
2820
+ if t['from'] == state['id']:
2821
+ groups.setdefault(t['event'], []).append(t)
2822
+ outcomes = {}
2823
+ total_w = 0
2824
+ for group in groups.values():
2825
+ guarded = [t for t in group if t.get('guard') is not None and eval_guard(t['guard'], val)]
2826
+ chosen = guarded if guarded else [t for t in group if t.get('guard') is None]
2827
+ for t in chosen:
2828
+ weight = t.get('weight', 1)
2829
+ nv = apply_updates(t, val)
2830
+ key = str(index_of.get(t['to'])) + '|' + js_stringify(nv)
2831
+ if key not in outcomes:
2832
+ outcomes[key] = {'weight': weight, 'pc': index_of.get(t['to'], 0), 'val': nv}
2833
+ total_w += weight
2834
+ else:
2835
+ outcomes[key]['weight'] += weight
2836
+ if len(outcomes) == 0:
2837
+ continue
2838
+ guard_parts = ['pc = ' + str(index_of.get(state['id'], 0))]
2839
+ for r in ranges:
2840
+ guard_parts.append(r['name'] + ' = ' + str(val.get(r['name'], r['init'])))
2841
+ terms = []
2842
+ for item in outcomes.values():
2843
+ p = item['weight'] / total_w if total_w > 0 else 0
2844
+ prob = format(p, '.6f').rstrip('0').rstrip('.')
2845
+ upd = ["(pc' = " + str(item['pc']) + ")"]
2846
+ for r in ranges:
2847
+ v = item['val'].get(r['name'], r['init'])
2848
+ if v != val.get(r['name'], r['init']):
2849
+ upd.append("(" + r['name'] + "' = " + str(v) + ")")
2850
+ terms.append(prob + ' : ' + ' & '.join(upd))
2851
+ commands.append('[] ' + ' & '.join(guard_parts) + ' ->\n ' + ' +\n '.join(terms))
2852
+ pc_hi = len(model.get('states') or []) - 1
2853
+ range_decls = '\n'.join(' ' + r['name'] + ' : [' + str(r['lo']) + '..' + str(r['hi']) + '] init ' + str(r['init']) + ';' for r in ranges)
2854
+ module = ('// Generated by logicprobe (DTMC export; booleans as 0/1 integers).\n'
2855
+ + 'dtmc\n\n'
2856
+ + 'module LogicProbe\n'
2857
+ + ' pc : [0..' + str(pc_hi) + '] init ' + str(index_of.get(model['init'], 0)) + ';\n'
2858
+ + range_decls + '\n\n'
2859
+ + '\n\n'.join(commands) + '\n'
2860
+ + 'endmodule\n')
2861
+ labels = []
2862
+ for state in model.get('states') or []:
2863
+ if state.get('terminal'):
2864
+ labels.append('label "term_' + (state_id_of.get(state['id'], 's')) + '" = pc = ' + str(index_of.get(state['id'])))
2865
+ forb = _forbidden_indexes(ex)
2866
+ if forb:
2867
+ labels.append('label "forbidden" = ' + ' | '.join('pc = ' + str(i) for i in forb))
2868
+ pctl = []
2869
+ for invariant in model.get('invariants') or []:
2870
+ if invariant['kind'] == 'probability':
2871
+ target = index_of.get(invariant['target'])
2872
+ if target is not None:
2873
+ labels.append('label "target" = pc = ' + str(target))
2874
+ pctl.append('P' + invariant['op'] + js_number(invariant['p']) + ' [ F "target" ]')
2875
+ if forb:
2876
+ pctl.append('P>=1 [ G !"forbidden" ]')
2877
+ module = module + '\n' + '\n'.join(labels) + '\n'
2878
+ extras = {'properties': '\n'.join(pctl) + '\n'}
2879
+ for invariant in model.get('invariants') or []:
2880
+ if invariant['kind'] != 'probability' and invariant['kind'] != 'never-states':
2881
+ warnings.append('PRISM v1 does not translate invariant kind ' + invariant['kind'] + '.')
2882
+ return {'format': 'prism', 'primary': module, 'extras': extras, 'warnings': warnings}
2883
+
2884
+
2885
+ def _export_spin(ex):
2886
+ model = ex['model']
2887
+ index_of = ex['indexOf']
2888
+ warnings = []
2889
+ var_decl_parts = []
2890
+ for v in model.get('variables') or []:
2891
+ var_decl_parts.append('int ' + v['name'] + ' = ' + str(_num_value(v['init'])) + ';')
2892
+ var_decl = '\n'.join(var_decl_parts)
2893
+ lines = []
2894
+
2895
+ def prom_assigns(updates):
2896
+ out = []
2897
+ for u in updates or []:
2898
+ value = u.get('value', 1)
2899
+ if u['op'] == 'set':
2900
+ out.append(u['variable'] + ' = ' + str(value))
2901
+ elif u['op'] == 'inc':
2902
+ out.append(u['variable'] + ' = ' + u['variable'] + ' + ' + str(value))
2903
+ else:
2904
+ out.append(u['variable'] + ' = ' + u['variable'] + ' - ' + str(value))
2905
+ return out
2906
+
2907
+ for t in model.get('transitions') or []:
2908
+ guard = _guard_expr(t.get('guard'), '&&', '||', '!', lambda v: v)
2909
+ assigns = prom_assigns(t.get('updates'))
2910
+ if guard == '':
2911
+ cond = 'pc == ' + str(index_of.get(t['from']))
2912
+ else:
2913
+ cond = 'pc == ' + str(index_of.get(t['from'])) + ' && ' + guard
2914
+ prefix = (' ' + '; '.join(assigns) + ';') if assigns else ''
2915
+ lines.append(' :: (' + cond + ') ->' + prefix + ' pc = ' + str(index_of.get(t['to'])) + ';')
2916
+ for state in model.get('states') or []:
2917
+ if state.get('terminal'):
2918
+ lines.append(' :: (pc == ' + str(index_of.get(state['id'])) + ') -> goto done;')
2919
+ promela = ('// Generated by logicprobe (Promela v1; booleans as 0/1 integers).\n'
2920
+ + 'int pc = ' + str(index_of.get(model['init'], 0)) + ';\n'
2921
+ + (var_decl + '\n' if var_decl != '' else '')
2922
+ + 'active proctype LogicProbe() {\n'
2923
+ + ' do\n'
2924
+ + '\n'.join(lines) + '\n'
2925
+ + ' od\n'
2926
+ + 'done: skip\n'
2927
+ + '}\n')
2928
+ extras = {}
2929
+ forb = _forbidden_indexes(ex)
2930
+ if forb:
2931
+ extras['properties'] = 'ltl safety { [] (!(pc == ' + ' && !(pc == '.join(str(i) for i in forb) + ')) }\n'
2932
+ for invariant in model.get('invariants') or []:
2933
+ if invariant['kind'] != 'never-states':
2934
+ warnings.append('SPIN v1 does not translate invariant kind ' + invariant['kind'] + '.')
2935
+ return {'format': 'spin', 'primary': promela, 'extras': extras, 'warnings': warnings}
2936
+
2937
+
2938
+ def export_model(input_value, fmt):
2939
+ ex = _prepare_model(input_value)
2940
+ if fmt == 'uppaal':
2941
+ return _export_uppaal(ex)
2942
+ if fmt == 'tla':
2943
+ return _export_tla(ex)
2944
+ if fmt == 'prism':
2945
+ return _export_prism(ex)
2946
+ return _export_spin(ex)
2947
+
2948
+
2949
+ # ---------------------------------------------------------------------------
2950
+ # CLI
2951
+ # ---------------------------------------------------------------------------
2952
+
2953
+ def _load_json_file(file_path):
2954
+ with open(file_path, 'r', encoding='utf-8') as handle:
2955
+ return json.load(handle)
2956
+
2957
+
2958
+ def _cmd_verify(args):
2959
+ model = _load_json_file(args.model)
2960
+ options = {}
2961
+ if args.max_states:
2962
+ options['maxStates'] = args.max_states
2963
+ if args.max_permutation_events:
2964
+ options['maxPermutationEvents'] = args.max_permutation_events
2965
+ if args.before_model:
2966
+ options['beforeModel'] = _load_json_file(args.before_model)
2967
+ if args.state_mapping:
2968
+ options['stateMapping'] = _load_json_file(args.state_mapping)
2969
+ report = run_verification(model, options)
2970
+ print(json.dumps(report, indent=2))
2971
+ sys.exit(0 if report['ok'] and report['summary'].get('errors', 0) == 0 else 2)
2972
+
2973
+
2974
+ def _cmd_compose(args):
2975
+ machines = [_load_json_file(m) for m in args.machines]
2976
+ options = {}
2977
+ if args.rendezvous:
2978
+ options['rendezvous'] = [e for e in args.rendezvous.split(',') if e]
2979
+ if args.max_states:
2980
+ options['maxStates'] = args.max_states
2981
+ report = run_composition_verification(machines, options)
2982
+ print(json.dumps(report, indent=2))
2983
+ sys.exit(0 if report['ok'] else 2)
2984
+
2985
+
2986
+ def _cmd_export(args):
2987
+ model = _load_json_file(args.model)
2988
+ try:
2989
+ result = export_model(model, args.format)
2990
+ except ValueError as exc:
2991
+ print(json.dumps({'ok': False, 'error': str(exc)}))
2992
+ sys.exit(2)
2993
+ out = {'format': result['format'], 'primary': result['primary'],
2994
+ 'extras': result['extras'], 'warnings': result['warnings']}
2995
+ print(json.dumps(out, indent=2))
2996
+
2997
+
2998
+ def _build_parser():
2999
+ parser = argparse.ArgumentParser(prog='logicprobe-engine.py',
3000
+ description='Standalone LogicModelV1 verification + composition + export (non-DSH mirror)')
3001
+ sub = parser.add_subparsers(dest='command', required=True)
3002
+ p_verify = sub.add_parser('verify', help='verify a LogicModelV1 JSON model (S1-S8/A1-A14/D1-D4)')
3003
+ p_verify.add_argument('model')
3004
+ p_verify.add_argument('--before-model')
3005
+ p_verify.add_argument('--state-mapping')
3006
+ p_verify.add_argument('--max-states', type=int)
3007
+ p_verify.add_argument('--max-permutation-events', type=int)
3008
+ p_verify.set_defaults(func=_cmd_verify)
3009
+ p_compose = sub.add_parser('compose', help='compose two or more machines (C1/C2)')
3010
+ p_compose.add_argument('machines', nargs='+')
3011
+ p_compose.add_argument('--rendezvous', help='comma-separated handshake events')
3012
+ p_compose.add_argument('--max-states', type=int)
3013
+ p_compose.set_defaults(func=_cmd_compose)
3014
+ p_export = sub.add_parser('export', help='export a model to UPPAAL/TLA+/PRISM/SPIN')
3015
+ p_export.add_argument('model')
3016
+ p_export.add_argument('--format', required=True, choices=['uppaal', 'tla', 'prism', 'spin'])
3017
+ p_export.set_defaults(func=_cmd_export)
3018
+ return parser
3019
+
3020
+
3021
+ def main(argv=None):
3022
+ parser = _build_parser()
3023
+ args = parser.parse_args(argv)
3024
+ args.func(args)
3025
+
3026
+
3027
+ if __name__ == '__main__':
3028
+ main()