dsh-logicprobe 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.en-US.md +10 -3
  2. package/README.md +10 -3
  3. package/lib/concurrency-tool.js +34 -0
  4. package/lib/concurrency.js +76 -0
  5. package/lib/data-engine.js +930 -0
  6. package/lib/data-tool.js +61 -0
  7. package/lib/engine.js +258 -0
  8. package/lib/index.js +37 -23
  9. package/lib/tool.js +1 -1
  10. package/lib/types/concurrency-tool.d.ts +8 -0
  11. package/lib/types/concurrency.d.ts +20 -0
  12. package/lib/types/data-engine.d.ts +199 -0
  13. package/lib/types/data-tool.d.ts +10 -0
  14. package/lib/types/engine.d.ts +20 -0
  15. package/package.json +82 -81
  16. package/skills/logicprobe/SKILL.md +285 -268
  17. package/skills/logicprobe/references/__pycache__/verification-harness.cpython-312.pyc +0 -0
  18. package/skills/logicprobe/references/concurrency-risk-guide.md +54 -0
  19. package/skills/logicprobe/references/dsh-model-schema.md +145 -129
  20. package/skills/logicprobe/references/logic-verification-guide.md +463 -413
  21. package/skills/logicprobe/references/verification-harness.py +806 -582
  22. package/skills/logicprobe-datamodel/SKILL.md +124 -0
  23. package/skills/logicprobe-datamodel/references/__pycache__/data-model-harness.cpython-312.pyc +0 -0
  24. package/skills/logicprobe-datamodel/references/data-model-guide.md +62 -0
  25. package/skills/logicprobe-datamodel/references/data-model-harness.py +528 -0
  26. package/skills/logicprobe-datamodel/references/data-model-schema.md +128 -0
  27. package/src/concurrency-tool.ts +37 -0
  28. package/src/concurrency.ts +102 -0
  29. package/src/data-engine.ts +1001 -0
  30. package/src/data-tool.ts +65 -0
  31. package/src/engine.ts +234 -0
  32. package/src/index.ts +315 -301
  33. package/src/tool.ts +60 -60
@@ -1,582 +1,806 @@
1
- #!/usr/bin/env python3
2
- """State Machine Verification Harness — generic template for logicprobe Phase 2a/2b.
3
-
4
- Fill in the MODEL section below with states, transitions, invariants extracted from the plan.
5
- Run: python3 verification-harness.py
6
- Output: structured verification report for Phase 3 gap analysis.
7
- """
8
- import sys
9
- from collections import deque
10
- from itertools import permutations
11
-
12
- # =============================================================================
13
- # MODEL — fill in from plan extraction
14
- # =============================================================================
15
-
16
- STATES: dict[str, dict[str, str]] = {
17
- # "STATE_NAME": {
18
- # "event_or_condition": "NEXT_STATE",
19
- # "another_event": "ANOTHER_STATE",
20
- # },
21
- # For guarded transitions, encode guard in event name:
22
- # "timeout (retry_count==0)": "RETRY",
23
- # "timeout (retry_count>=1)": "FATAL",
24
- }
25
-
26
- INIT: str = "INIT"
27
-
28
- TERMINALS: set[str] = set() # States where machine intentionally stops
29
-
30
- # Invariants: list of {"desc": "human description", "check": lambda states, reachable: bool}
31
- INVARIANTS: list[dict] = []
32
-
33
- # Event pairs that can arrive concurrently (for A2 race interleaving)
34
- CONCURRENT_PAIRS: list[tuple[str, str]] = []
35
-
36
- # Counter/timer variables mentioned in guards (for A5 boundary blast)
37
- # Format: {"name": "variable_name", "max_valid": max_value, "type": "counter|timestamp"}
38
- BOUNDARY_VARS: list[dict] = []
39
-
40
- # Paired operations (for A4 pair symmetry)
41
- # Format: ("acquire_event_name", "release_event_name")
42
- PAIRS: list[tuple[str, str]] = [
43
- # ("lock", "unlock"),
44
- # ("alloc", "free"),
45
- # ("start", "stop"),
46
- ]
47
-
48
- # =============================================================================
49
- # PHASE 2a: STRUCTURAL PRIMITIVES
50
- # =============================================================================
51
-
52
- def all_states():
53
- return set(STATES.keys())
54
-
55
- def all_events():
56
- events = set()
57
- for trans in STATES.values():
58
- events.update(trans.keys())
59
- return events
60
-
61
- def S1_reachability():
62
- """BFS from INIT — find unreachable states."""
63
- visited = set()
64
- queue = deque([INIT])
65
- while queue:
66
- s = queue.popleft()
67
- if s in visited:
68
- continue
69
- visited.add(s)
70
- for nxt in STATES.get(s, {}).values():
71
- if nxt not in visited:
72
- queue.append(nxt)
73
- unreachable = all_states() - visited
74
- return {
75
- "pass": len(unreachable) == 0,
76
- "reachable": sorted(visited),
77
- "unreachable": sorted(unreachable),
78
- "detail": f"{len(unreachable)} unreachable: {sorted(unreachable)}" if unreachable else "All states reachable",
79
- }
80
-
81
- def S2_deadlock():
82
- """Any non-terminal state with zero outgoing transitions?"""
83
- deadlocks = []
84
- for s in sorted(all_states()):
85
- if s not in TERMINALS and len(STATES.get(s, {})) == 0:
86
- deadlocks.append(s)
87
- return {
88
- "pass": len(deadlocks) == 0,
89
- "deadlocks": deadlocks,
90
- "detail": f"Deadlocks: {deadlocks}" if deadlocks else "No deadlocks",
91
- }
92
-
93
- def S3_liveness():
94
- """Detect absorbing cycles that exclude expected terminal/recovery states."""
95
- # Build reverse graph to find cycles
96
- cycles = []
97
- for start in all_states():
98
- # DFS from each state to find cycles
99
- def find_cycle(s, path, visited_cycle):
100
- if s in visited_cycle:
101
- idx = path.index(s)
102
- return path[idx:]
103
- visited_cycle.add(s)
104
- for nxt in STATES.get(s, {}).values():
105
- result = find_cycle(nxt, path + [s], visited_cycle.copy())
106
- if result:
107
- return result
108
- return None
109
-
110
- cycle = find_cycle(start, [], set())
111
- if cycle and cycle not in cycles:
112
- cycles.append(cycle)
113
-
114
- # An absorbing cycle is one where ALL transitions from cycle states stay in the cycle
115
- absorbing = []
116
- for cycle in cycles:
117
- cycle_set = set(cycle)
118
- is_absorbing = True
119
- for s in cycle:
120
- for nxt in STATES.get(s, {}).values():
121
- if nxt not in cycle_set:
122
- is_absorbing = False
123
- break
124
- if not is_absorbing:
125
- break
126
- if is_absorbing:
127
- # Check if cycle excludes terminal/recovery states
128
- if not (cycle_set & TERMINALS):
129
- absorbing.append(cycle)
130
-
131
- return {
132
- "pass": len(absorbing) == 0,
133
- "absorbing_cycles": absorbing,
134
- "all_cycles": cycles,
135
- "detail": f"Absorbing cycles (no exit, no terminal): {absorbing}" if absorbing else "No harmful absorbing cycles",
136
- }
137
-
138
- def S4_determinism():
139
- """Same (state, event) → multiple different targets?"""
140
- # The dict-of-dicts structure is inherently deterministic per event key.
141
- # This check verifies: for each state, event names are unambiguous (no duplicates).
142
- ambiguous = []
143
- for s in sorted(all_states()):
144
- seen = {}
145
- for event, target in STATES.get(s, {}).items():
146
- base = event.split("(")[0].strip() # Strip guard suffix for comparison
147
- if base in seen and seen[base] != target:
148
- ambiguous.append((s, base, seen[base], target))
149
- seen[base] = target
150
- return {
151
- "pass": len(ambiguous) == 0,
152
- "ambiguous": ambiguous,
153
- "detail": f"Ambiguous transitions: {ambiguous}" if ambiguous else "Deterministic",
154
- }
155
-
156
- def S5_event_completeness():
157
- """States missing handlers for events that other states handle."""
158
- events = all_events()
159
- warnings = []
160
- for s in sorted(all_states()):
161
- if s in TERMINALS:
162
- continue
163
- handled = set(STATES.get(s, {}).keys())
164
- # Only flag if a state is missing events that are relevant (handled elsewhere)
165
- relevant = set()
166
- for e in events - handled:
167
- # Check if this event type appears in guard variants
168
- base = e.split("(")[0].strip()
169
- if any(base in h for h in handled):
170
- pass # Already handled via guard variant
171
- else:
172
- relevant.add(e)
173
- if relevant:
174
- warnings.append((s, sorted(relevant)))
175
- return {
176
- "pass": len(warnings) == 0,
177
- "warnings": warnings,
178
- "detail": f"Missing event handlers: {warnings}" if warnings else "All states handle all relevant events",
179
- }
180
-
181
- def S6_guard_completeness():
182
- """For each transition with a guard condition, are ALL branch outcomes defined?"""
183
- # Group transitions by (state, base_event)
184
- guard_groups = {}
185
- for s in sorted(all_states()):
186
- for event in STATES.get(s, {}):
187
- base = event.split("(")[0].strip()
188
- key = (s, base)
189
- if key not in guard_groups:
190
- guard_groups[key] = []
191
- guard_groups[key].append(event)
192
-
193
- incomplete = []
194
- for (s, base), variants in guard_groups.items():
195
- if len(variants) > 1:
196
- # Guard exists — check if there's a default/else path
197
- has_default = any("else" in v.lower() or "default" in v.lower() for v in variants)
198
- has_explicit = len(variants) >= 2 # At minimum, two guard branches
199
- # Heuristic: if we have guard variants but no explicit "else", flag it
200
- if not has_default:
201
- incomplete.append({
202
- "state": s,
203
- "event": base,
204
- "variants": variants,
205
- "missing": "else/default branch",
206
- })
207
- return {
208
- "pass": len(incomplete) == 0,
209
- "incomplete_guards": incomplete,
210
- "detail": f"Incomplete guards: {incomplete}" if incomplete else "All guard branches defined or single-path",
211
- }
212
-
213
- def S7_invariants():
214
- """Verify each claimed invariant against all reachable states."""
215
- # First compute reachable set
216
- visited = set()
217
- queue = deque([INIT])
218
- while queue:
219
- s = queue.popleft()
220
- if s in visited:
221
- continue
222
- visited.add(s)
223
- for nxt in STATES.get(s, {}).values():
224
- if nxt not in visited:
225
- queue.append(nxt)
226
-
227
- violations = []
228
- for inv in INVARIANTS:
229
- try:
230
- if not inv["check"](STATES, visited):
231
- violations.append(inv["desc"])
232
- except Exception as e:
233
- violations.append(f"{inv['desc']} — ERROR: {e}")
234
-
235
- return {
236
- "pass": len(violations) == 0,
237
- "violations": violations,
238
- "detail": f"Invariant violations: {violations}" if violations else "All invariants hold",
239
- }
240
-
241
- # =============================================================================
242
- # PHASE 2b: ADVERSARIAL PROBES
243
- # =============================================================================
244
-
245
- def step(current, events):
246
- """Simulate a sequence of events from current state. Returns final state."""
247
- s = current
248
- for e in events:
249
- if s in TERMINALS:
250
- break
251
- trans = STATES.get(s, {})
252
- # Exact match first
253
- if e in trans:
254
- s = trans[e]
255
- else:
256
- # Try matching guard variants — pick the first matching base event
257
- base_match = None
258
- for evt, target in trans.items():
259
- if evt.startswith(e) or e.startswith(evt.split("(")[0].strip()):
260
- base_match = target
261
- break
262
- if base_match:
263
- s = base_match
264
- # else: event ignored (unhandled) — stay in current state
265
- return s
266
-
267
- def A1_unexpected_event():
268
- """Inject every event into every state that doesn't handle it."""
269
- events = all_events()
270
- findings = []
271
- for s in sorted(all_states()):
272
- if s in TERMINALS:
273
- continue
274
- handled = set(STATES.get(s, {}).keys())
275
- unhandled = events - handled
276
- if unhandled:
277
- findings.append({
278
- "state": s,
279
- "unhandled": sorted(unhandled),
280
- "risk": "Event silently ignored — may represent undefined behavior",
281
- })
282
- return {
283
- "pass": len(findings) == 0,
284
- "findings": findings,
285
- "detail": f"{len(findings)} states with unhandled events" if findings else "All event/state combinations defined",
286
- }
287
-
288
- def A2_race_interleaving():
289
- """For each concurrent pair, test both arrival orders."""
290
- if not CONCURRENT_PAIRS:
291
- return {"pass": True, "findings": [], "detail": "No concurrent pairs defined — skipped"}
292
-
293
- findings = []
294
- for e1, e2 in CONCURRENT_PAIRS:
295
- # Test from each state where both events are possible
296
- for s in sorted(all_states()):
297
- trans = STATES.get(s, {})
298
- if e1 not in trans and e2 not in trans:
299
- continue
300
- final_e1e2 = step(s, [e1, e2])
301
- final_e2e1 = step(s, [e2, e1])
302
- if final_e1e2 != final_e2e1:
303
- findings.append({
304
- "state": s,
305
- "pair": (e1, e2),
306
- "final_e1_then_e2": final_e1e2,
307
- "final_e2_then_e1": final_e2e1,
308
- "risk": "Order-dependent outcome",
309
- })
310
- return {
311
- "pass": len(findings) == 0,
312
- "findings": findings,
313
- "detail": f"{len(findings)} order-dependent race conditions" if findings else "No race conditions detected",
314
- }
315
-
316
- def A3_order_permutation():
317
- """Test if different event orderings produce different terminal states."""
318
- events = sorted(all_events())
319
- if len(events) > 5:
320
- # Too many permutations — sample subset
321
- events = events[:5]
322
-
323
- # Find event sequences that reach different terminals
324
- terminal_sets = []
325
- for perm in permutations(events):
326
- final = step(INIT, list(perm))
327
- terminal_sets.append((list(perm), final))
328
-
329
- unique_terminals = set(t[1] for t in terminal_sets)
330
-
331
- findings = []
332
- if len(unique_terminals) > 1:
333
- # Find the sequences producing each terminal
334
- by_terminal = {}
335
- for seq, term in terminal_sets:
336
- by_terminal.setdefault(term, []).append(seq)
337
- findings.append({
338
- "terminal_states": sorted(unique_terminals),
339
- "sequences": {t: seqs[0] for t, seqs in by_terminal.items()},
340
- "risk": f"Same events produce {len(unique_terminals)} different outcomes",
341
- })
342
-
343
- return {
344
- "pass": len(findings) == 0,
345
- "findings": findings,
346
- "detail": f"Order-dependent: {len(unique_terminals)} different outcomes" if findings else "Order-independent",
347
- }
348
-
349
- def A4_pair_symmetry():
350
- """Check lock/unlock, alloc/free, start/stop symmetry."""
351
- if not PAIRS:
352
- return {"pass": True, "findings": [], "detail": "No paired operations defined — skipped"}
353
-
354
- events = all_events()
355
- findings = []
356
-
357
- for acquire, release in PAIRS:
358
- # Check if this pair type is even used in the model
359
- acquire_events = [e for e in events if acquire in e.lower()]
360
- release_events = [e for e in events if release in e.lower()]
361
-
362
- if not acquire_events and not release_events:
363
- continue
364
-
365
- # Simple check: for each acquire event, is there a corresponding release?
366
- # More sophisticated: every path that contains acquire must contain release
367
- # before reaching a terminal state or another acquire.
368
-
369
- # Quick heuristic: count occurrences in transition targets
370
- acquire_targets = set()
371
- release_sources = set()
372
- for s, trans in STATES.items():
373
- for e, t in trans.items():
374
- if any(ae in e.lower() for ae in acquire_events):
375
- acquire_targets.add(t)
376
- if any(re in e.lower() for re in release_events):
377
- release_sources.add(s)
378
-
379
- if acquire_targets and not release_sources:
380
- findings.append({
381
- "pair": (acquire, release),
382
- "risk": f"'{acquire}' used but no '{release}' found — resource leak likely",
383
- })
384
-
385
- return {
386
- "pass": len(findings) == 0,
387
- "findings": findings,
388
- "detail": f"Asymmetric pairs: {findings}" if findings else "All pairs balanced",
389
- }
390
-
391
- def A5_boundary_blast():
392
- """Probe counter/timer boundary values."""
393
- if not BOUNDARY_VARS:
394
- return {"pass": True, "findings": [], "detail": "No boundary variables defined — skipped"}
395
-
396
- findings = []
397
- for var in BOUNDARY_VARS:
398
- name = var["name"]
399
- max_val = var.get("max_valid", 255)
400
- vtype = var.get("type", "counter")
401
-
402
- test_values = [0, 1, max_val - 1, max_val, max_val + 1]
403
- if vtype == "counter":
404
- test_values += [2**8 - 1, 2**16 - 1, 2**32 - 1]
405
-
406
- for tv in test_values:
407
- if tv < 0 or tv > max_val:
408
- findings.append({
409
- "variable": name,
410
- "tested_value": tv,
411
- "max_valid": max_val,
412
- "risk": f"Value {tv} exceeds max valid {max_val} — overflow possible",
413
- })
414
-
415
- if vtype == "timestamp":
416
- findings.append({
417
- "variable": name,
418
- "risk": "Timestamp wraparound — verify elapsed_ms() / elapsed_ticks() handle wraparound correctly",
419
- })
420
-
421
- return {
422
- "pass": len(findings) == 0,
423
- "findings": findings,
424
- "detail": f"Boundary issues: {len(findings)}" if findings else "Boundary checks passed",
425
- }
426
-
427
- def A6_resource_injection():
428
- """Simulate resource failures at each state."""
429
- # Heuristic: identify states that likely allocate resources
430
- findings = []
431
- for s in sorted(all_states()):
432
- if s in TERMINALS:
433
- continue
434
- state_lower = s.lower()
435
- events_lower = [e.lower() for e in STATES.get(s, {}).keys()]
436
-
437
- # Does this state look like it allocates resources?
438
- alloc_keywords = ["alloc", "create", "init", "start", "open", "connect", "begin"]
439
- has_alloc = any(kw in state_lower or any(kw in e for e in events_lower) for kw in alloc_keywords)
440
-
441
- if not has_alloc:
442
- continue
443
-
444
- # Does it have an error recovery path?
445
- error_keywords = ["error", "fail", "retry", "timeout", "recover", "fatal"]
446
- has_recovery = any(any(kw in e for e in events_lower) for kw in error_keywords)
447
-
448
- if not has_recovery:
449
- findings.append({
450
- "state": s,
451
- "risk": f"State '{s}' may allocate resources but has no visible error recovery path",
452
- })
453
-
454
- return {
455
- "pass": len(findings) == 0,
456
- "findings": findings,
457
- "detail": f"Resource vulnerability: {len(findings)} states" if findings else "No resource vulnerabilities detected",
458
- }
459
-
460
- def A7_shortest_violation(invariant_results):
461
- """Find shortest violating path for each failed invariant (requires re-running with path tracking)."""
462
- if not INVARIANTS:
463
- return {"pass": True, "findings": [], "detail": "No invariants defined — skipped"}
464
-
465
- findings = []
466
- for inv in INVARIANTS:
467
- # BFS to find shortest path to violation
468
- queue = deque([(INIT, [])])
469
- visited = set()
470
- found = None
471
-
472
- while queue and not found:
473
- s, path = queue.popleft()
474
- if s in visited:
475
- continue
476
- visited.add(s)
477
-
478
- try:
479
- if not inv["check"](STATES, {s}):
480
- found = path
481
- break
482
- except Exception:
483
- found = path
484
- break
485
-
486
- for event, nxt in STATES.get(s, {}).items():
487
- if nxt not in visited:
488
- queue.append((nxt, path + [(s, event, nxt)]))
489
-
490
- if found:
491
- findings.append({
492
- "invariant": inv["desc"],
493
- "violating_path": found,
494
- "path_length": len(found),
495
- })
496
-
497
- return {
498
- "pass": len(findings) == 0,
499
- "findings": findings,
500
- "detail": f"Violated invariants: {len(findings)}" if findings else "All invariants hold for all reachable paths",
501
- }
502
-
503
- # =============================================================================
504
- # MAIN
505
- # =============================================================================
506
-
507
- def run_all():
508
- results = {}
509
- errors = 0
510
- warnings = 0
511
-
512
- print("=" * 60)
513
- print("PHASE 2a: STRUCTURAL PRIMITIVES")
514
- print("=" * 60)
515
-
516
- checks_2a = [
517
- ("S1 Reachability", S1_reachability),
518
- ("S2 Deadlock", S2_deadlock),
519
- ("S3 Liveness", S3_liveness),
520
- ("S4 Determinism", S4_determinism),
521
- ("S5 Event Completeness", S5_event_completeness),
522
- ("S6 Guard Completeness", S6_guard_completeness),
523
- ("S7 Invariants", S7_invariants),
524
- ]
525
-
526
- for name, check_fn in checks_2a:
527
- result = check_fn()
528
- results[name] = result
529
- status = "PASS" if result["pass"] else "FAIL"
530
- prefix = " " if result["pass"] else " [!] "
531
- print(f"{prefix}[{status}] {name}: {result['detail']}")
532
- if not result["pass"]:
533
- if "Warning" in str(type(check_fn)):
534
- warnings += 1
535
- else:
536
- errors += 1
537
-
538
- print()
539
- print("=" * 60)
540
- print("PHASE 2b: ADVERSARIAL PROBES")
541
- print("=" * 60)
542
-
543
- probes_2b = [
544
- ("A1 Unexpected Event", A1_unexpected_event),
545
- ("A2 Race Interleaving", A2_race_interleaving),
546
- ("A3 Order Permutation", A3_order_permutation),
547
- ("A4 Pair Symmetry", A4_pair_symmetry),
548
- ("A5 Boundary Blast", A5_boundary_blast),
549
- ("A6 Resource Injection", A6_resource_injection),
550
- ("A7 Shortest Violation", lambda: A7_shortest_violation(results.get("S7 Invariants", {}))),
551
- ]
552
-
553
- for name, probe_fn in probes_2b:
554
- result = probe_fn()
555
- results[name] = result
556
- status = "PASS" if result["pass"] else "FAIL"
557
- prefix = " " if result["pass"] else " [!] "
558
- print(f"{prefix}[{status}] {name}: {result['detail']}")
559
- if not result["pass"]:
560
- warnings += 1 # Probe failures are warnings by default (may be false positives)
561
-
562
- print()
563
- print("=" * 60)
564
- print(f"SUMMARY: {errors} structural errors, {warnings} probe/other warnings")
565
- print("=" * 60)
566
-
567
- if errors > 0:
568
- print()
569
- print("ACTION: Fix structural errors before proceeding to Phase 3.")
570
- print("Structural errors indicate the plan's logic is incomplete or inconsistent.")
571
-
572
- if warnings > 0 and errors == 0:
573
- print()
574
- print("ACTION: Review probe warnings — may be false positives or acceptable risks.")
575
- print("Escalate confirmed findings to Phase 3 gap analysis.")
576
-
577
- return errors, warnings, results
578
-
579
-
580
- if __name__ == "__main__":
581
- errors, warnings, results = run_all()
582
- sys.exit(1 if errors > 0 else 0)
1
+ #!/usr/bin/env python3
2
+ """State Machine Verification Harness — generic template for logicprobe Phase 2a/2b.
3
+
4
+ Fill in the MODEL section below with states, transitions, invariants extracted from the plan.
5
+ Run: python3 verification-harness.py
6
+ Output: structured verification report for Phase 3 gap analysis.
7
+ """
8
+ import sys
9
+ from collections import deque
10
+ from itertools import permutations
11
+
12
+ # =============================================================================
13
+ # MODEL — fill in from plan extraction
14
+ # =============================================================================
15
+
16
+ STATES: dict[str, dict[str, str]] = {
17
+ # "STATE_NAME": {
18
+ # "event_or_condition": "NEXT_STATE",
19
+ # "another_event": "ANOTHER_STATE",
20
+ # },
21
+ # For guarded transitions, encode guard in event name:
22
+ # "timeout (retry_count==0)": "RETRY",
23
+ # "timeout (retry_count>=1)": "FATAL",
24
+ }
25
+
26
+ INIT: str = "INIT"
27
+
28
+ TERMINALS: set[str] = set() # States where machine intentionally stops
29
+
30
+ # Invariants: list of {"desc": "human description", "check": lambda states, reachable: bool}
31
+ INVARIANTS: list[dict] = []
32
+
33
+ # Event pairs that can arrive concurrently (for A2 race interleaving)
34
+ CONCURRENT_PAIRS: list[tuple[str, str]] = []
35
+
36
+ # Counter/timer variables mentioned in guards (for A5 boundary blast)
37
+ # Format: {"name": "variable_name", "max_valid": max_value, "type": "counter|timestamp"}
38
+ BOUNDARY_VARS: list[dict] = []
39
+
40
+ # Paired operations (for A4 pair symmetry)
41
+ # Format: ("acquire_event_name", "release_event_name")
42
+ PAIRS: list[tuple[str, str]] = [
43
+ # ("lock", "unlock"),
44
+ # ("alloc", "free"),
45
+ # ("start", "stop"),
46
+ ]
47
+
48
+ # Monotonic variables (for S8)
49
+ # Format: {"name": "counter", "direction": "inc|dec", "increase_events": [...], "decrease_events": [...]}
50
+ MONOTONIC_VARS: list[dict] = []
51
+
52
+ # Events that must be idempotent (for A8)
53
+ IDEMPOTENT_EVENTS: set[str] = set()
54
+
55
+ # Leads-to requirements (for A9): list of (from_state, to_state)
56
+ LEADS_TO: list[tuple[str, str]] = []
57
+
58
+ # Sequence requirements (for A10): list of event lists that must occur in order
59
+ SEQUENCES: list[list[str]] = []
60
+
61
+ # Atomic groups (for A11): {"events": [...], "commit": "...", "rollback": "..."}
62
+ ATOMIC_GROUPS: list[dict] = []
63
+
64
+ # Before model for D1-D4 comparison (optional)
65
+ BEFORE_STATES: dict[str, dict[str, str]] = {}
66
+ BEFORE_INIT: str = "INIT"
67
+ BEFORE_TERMINALS: set[str] = set()
68
+ STATE_MAPPING: dict[str, str] = {}
69
+
70
+ # =============================================================================
71
+ # PHASE 2a: STRUCTURAL PRIMITIVES
72
+ # =============================================================================
73
+
74
+ def all_states():
75
+ return set(STATES.keys())
76
+
77
+ def all_events():
78
+ events = set()
79
+ for trans in STATES.values():
80
+ events.update(trans.keys())
81
+ return events
82
+
83
+ def S1_reachability():
84
+ """BFS from INIT — find unreachable states."""
85
+ visited = set()
86
+ queue = deque([INIT])
87
+ while queue:
88
+ s = queue.popleft()
89
+ if s in visited:
90
+ continue
91
+ visited.add(s)
92
+ for nxt in STATES.get(s, {}).values():
93
+ if nxt not in visited:
94
+ queue.append(nxt)
95
+ unreachable = all_states() - visited
96
+ return {
97
+ "pass": len(unreachable) == 0,
98
+ "reachable": sorted(visited),
99
+ "unreachable": sorted(unreachable),
100
+ "detail": f"{len(unreachable)} unreachable: {sorted(unreachable)}" if unreachable else "All states reachable",
101
+ }
102
+
103
+ def S2_deadlock():
104
+ """Any non-terminal state with zero outgoing transitions?"""
105
+ deadlocks = []
106
+ for s in sorted(all_states()):
107
+ if s not in TERMINALS and len(STATES.get(s, {})) == 0:
108
+ deadlocks.append(s)
109
+ return {
110
+ "pass": len(deadlocks) == 0,
111
+ "deadlocks": deadlocks,
112
+ "detail": f"Deadlocks: {deadlocks}" if deadlocks else "No deadlocks",
113
+ }
114
+
115
+ def S3_liveness():
116
+ """Detect absorbing cycles that exclude expected terminal/recovery states."""
117
+ # Build reverse graph to find cycles
118
+ cycles = []
119
+ for start in all_states():
120
+ # DFS from each state to find cycles
121
+ def find_cycle(s, path, visited_cycle):
122
+ if s in visited_cycle:
123
+ idx = path.index(s)
124
+ return path[idx:]
125
+ visited_cycle.add(s)
126
+ for nxt in STATES.get(s, {}).values():
127
+ result = find_cycle(nxt, path + [s], visited_cycle.copy())
128
+ if result:
129
+ return result
130
+ return None
131
+
132
+ cycle = find_cycle(start, [], set())
133
+ if cycle and cycle not in cycles:
134
+ cycles.append(cycle)
135
+
136
+ # An absorbing cycle is one where ALL transitions from cycle states stay in the cycle
137
+ absorbing = []
138
+ for cycle in cycles:
139
+ cycle_set = set(cycle)
140
+ is_absorbing = True
141
+ for s in cycle:
142
+ for nxt in STATES.get(s, {}).values():
143
+ if nxt not in cycle_set:
144
+ is_absorbing = False
145
+ break
146
+ if not is_absorbing:
147
+ break
148
+ if is_absorbing:
149
+ # Check if cycle excludes terminal/recovery states
150
+ if not (cycle_set & TERMINALS):
151
+ absorbing.append(cycle)
152
+
153
+ return {
154
+ "pass": len(absorbing) == 0,
155
+ "absorbing_cycles": absorbing,
156
+ "all_cycles": cycles,
157
+ "detail": f"Absorbing cycles (no exit, no terminal): {absorbing}" if absorbing else "No harmful absorbing cycles",
158
+ }
159
+
160
+ def S4_determinism():
161
+ """Same (state, event) → multiple different targets?"""
162
+ # The dict-of-dicts structure is inherently deterministic per event key.
163
+ # This check verifies: for each state, event names are unambiguous (no duplicates).
164
+ ambiguous = []
165
+ for s in sorted(all_states()):
166
+ seen = {}
167
+ for event, target in STATES.get(s, {}).items():
168
+ base = event.split("(")[0].strip() # Strip guard suffix for comparison
169
+ if base in seen and seen[base] != target:
170
+ ambiguous.append((s, base, seen[base], target))
171
+ seen[base] = target
172
+ return {
173
+ "pass": len(ambiguous) == 0,
174
+ "ambiguous": ambiguous,
175
+ "detail": f"Ambiguous transitions: {ambiguous}" if ambiguous else "Deterministic",
176
+ }
177
+
178
+ def S5_event_completeness():
179
+ """States missing handlers for events that other states handle."""
180
+ events = all_events()
181
+ warnings = []
182
+ for s in sorted(all_states()):
183
+ if s in TERMINALS:
184
+ continue
185
+ handled = set(STATES.get(s, {}).keys())
186
+ # Only flag if a state is missing events that are relevant (handled elsewhere)
187
+ relevant = set()
188
+ for e in events - handled:
189
+ # Check if this event type appears in guard variants
190
+ base = e.split("(")[0].strip()
191
+ if any(base in h for h in handled):
192
+ pass # Already handled via guard variant
193
+ else:
194
+ relevant.add(e)
195
+ if relevant:
196
+ warnings.append((s, sorted(relevant)))
197
+ return {
198
+ "pass": len(warnings) == 0,
199
+ "warnings": warnings,
200
+ "detail": f"Missing event handlers: {warnings}" if warnings else "All states handle all relevant events",
201
+ }
202
+
203
+ def S6_guard_completeness():
204
+ """For each transition with a guard condition, are ALL branch outcomes defined?"""
205
+ # Group transitions by (state, base_event)
206
+ guard_groups = {}
207
+ for s in sorted(all_states()):
208
+ for event in STATES.get(s, {}):
209
+ base = event.split("(")[0].strip()
210
+ key = (s, base)
211
+ if key not in guard_groups:
212
+ guard_groups[key] = []
213
+ guard_groups[key].append(event)
214
+
215
+ incomplete = []
216
+ for (s, base), variants in guard_groups.items():
217
+ if len(variants) > 1:
218
+ # Guard exists — check if there's a default/else path
219
+ has_default = any("else" in v.lower() or "default" in v.lower() for v in variants)
220
+ has_explicit = len(variants) >= 2 # At minimum, two guard branches
221
+ # Heuristic: if we have guard variants but no explicit "else", flag it
222
+ if not has_default:
223
+ incomplete.append({
224
+ "state": s,
225
+ "event": base,
226
+ "variants": variants,
227
+ "missing": "else/default branch",
228
+ })
229
+ return {
230
+ "pass": len(incomplete) == 0,
231
+ "incomplete_guards": incomplete,
232
+ "detail": f"Incomplete guards: {incomplete}" if incomplete else "All guard branches defined or single-path",
233
+ }
234
+
235
+ def S7_invariants():
236
+ """Verify each claimed invariant against all reachable states."""
237
+ # First compute reachable set
238
+ visited = set()
239
+ queue = deque([INIT])
240
+ while queue:
241
+ s = queue.popleft()
242
+ if s in visited:
243
+ continue
244
+ visited.add(s)
245
+ for nxt in STATES.get(s, {}).values():
246
+ if nxt not in visited:
247
+ queue.append(nxt)
248
+
249
+ violations = []
250
+ for inv in INVARIANTS:
251
+ try:
252
+ if not inv["check"](STATES, visited):
253
+ violations.append(inv["desc"])
254
+ except Exception as e:
255
+ violations.append(f"{inv['desc']} — ERROR: {e}")
256
+
257
+ return {
258
+ "pass": len(violations) == 0,
259
+ "violations": violations,
260
+ "detail": f"Invariant violations: {violations}" if violations else "All invariants hold",
261
+ }
262
+
263
+ # =============================================================================
264
+ # PHASE 2b: ADVERSARIAL PROBES
265
+ # =============================================================================
266
+
267
+ def step(current, events):
268
+ """Simulate a sequence of events from current state. Returns final state."""
269
+ s = current
270
+ for e in events:
271
+ if s in TERMINALS:
272
+ break
273
+ trans = STATES.get(s, {})
274
+ # Exact match first
275
+ if e in trans:
276
+ s = trans[e]
277
+ else:
278
+ # Try matching guard variants — pick the first matching base event
279
+ base_match = None
280
+ for evt, target in trans.items():
281
+ if evt.startswith(e) or e.startswith(evt.split("(")[0].strip()):
282
+ base_match = target
283
+ break
284
+ if base_match:
285
+ s = base_match
286
+ # else: event ignored (unhandled) — stay in current state
287
+ return s
288
+
289
+ def A1_unexpected_event():
290
+ """Inject every event into every state that doesn't handle it."""
291
+ events = all_events()
292
+ findings = []
293
+ for s in sorted(all_states()):
294
+ if s in TERMINALS:
295
+ continue
296
+ handled = set(STATES.get(s, {}).keys())
297
+ unhandled = events - handled
298
+ if unhandled:
299
+ findings.append({
300
+ "state": s,
301
+ "unhandled": sorted(unhandled),
302
+ "risk": "Event silently ignored — may represent undefined behavior",
303
+ })
304
+ return {
305
+ "pass": len(findings) == 0,
306
+ "findings": findings,
307
+ "detail": f"{len(findings)} states with unhandled events" if findings else "All event/state combinations defined",
308
+ }
309
+
310
+ def A2_race_interleaving():
311
+ """For each concurrent pair, test both arrival orders."""
312
+ if not CONCURRENT_PAIRS:
313
+ return {"pass": True, "findings": [], "detail": "No concurrent pairs defined — skipped"}
314
+
315
+ findings = []
316
+ for e1, e2 in CONCURRENT_PAIRS:
317
+ # Test from each state where both events are possible
318
+ for s in sorted(all_states()):
319
+ trans = STATES.get(s, {})
320
+ if e1 not in trans and e2 not in trans:
321
+ continue
322
+ final_e1e2 = step(s, [e1, e2])
323
+ final_e2e1 = step(s, [e2, e1])
324
+ if final_e1e2 != final_e2e1:
325
+ findings.append({
326
+ "state": s,
327
+ "pair": (e1, e2),
328
+ "final_e1_then_e2": final_e1e2,
329
+ "final_e2_then_e1": final_e2e1,
330
+ "risk": "Order-dependent outcome",
331
+ })
332
+ return {
333
+ "pass": len(findings) == 0,
334
+ "findings": findings,
335
+ "detail": f"{len(findings)} order-dependent race conditions" if findings else "No race conditions detected",
336
+ }
337
+
338
+ def A3_order_permutation():
339
+ """Test if different event orderings produce different terminal states."""
340
+ events = sorted(all_events())
341
+ if len(events) > 5:
342
+ # Too many permutations — sample subset
343
+ events = events[:5]
344
+
345
+ # Find event sequences that reach different terminals
346
+ terminal_sets = []
347
+ for perm in permutations(events):
348
+ final = step(INIT, list(perm))
349
+ terminal_sets.append((list(perm), final))
350
+
351
+ unique_terminals = set(t[1] for t in terminal_sets)
352
+
353
+ findings = []
354
+ if len(unique_terminals) > 1:
355
+ # Find the sequences producing each terminal
356
+ by_terminal = {}
357
+ for seq, term in terminal_sets:
358
+ by_terminal.setdefault(term, []).append(seq)
359
+ findings.append({
360
+ "terminal_states": sorted(unique_terminals),
361
+ "sequences": {t: seqs[0] for t, seqs in by_terminal.items()},
362
+ "risk": f"Same events produce {len(unique_terminals)} different outcomes",
363
+ })
364
+
365
+ return {
366
+ "pass": len(findings) == 0,
367
+ "findings": findings,
368
+ "detail": f"Order-dependent: {len(unique_terminals)} different outcomes" if findings else "Order-independent",
369
+ }
370
+
371
+ def A4_pair_symmetry():
372
+ """Check lock/unlock, alloc/free, start/stop symmetry."""
373
+ if not PAIRS:
374
+ return {"pass": True, "findings": [], "detail": "No paired operations defined — skipped"}
375
+
376
+ events = all_events()
377
+ findings = []
378
+
379
+ for acquire, release in PAIRS:
380
+ # Check if this pair type is even used in the model
381
+ acquire_events = [e for e in events if acquire in e.lower()]
382
+ release_events = [e for e in events if release in e.lower()]
383
+
384
+ if not acquire_events and not release_events:
385
+ continue
386
+
387
+ # Simple check: for each acquire event, is there a corresponding release?
388
+ # More sophisticated: every path that contains acquire must contain release
389
+ # before reaching a terminal state or another acquire.
390
+
391
+ # Quick heuristic: count occurrences in transition targets
392
+ acquire_targets = set()
393
+ release_sources = set()
394
+ for s, trans in STATES.items():
395
+ for e, t in trans.items():
396
+ if any(ae in e.lower() for ae in acquire_events):
397
+ acquire_targets.add(t)
398
+ if any(re in e.lower() for re in release_events):
399
+ release_sources.add(s)
400
+
401
+ if acquire_targets and not release_sources:
402
+ findings.append({
403
+ "pair": (acquire, release),
404
+ "risk": f"'{acquire}' used but no '{release}' found — resource leak likely",
405
+ })
406
+
407
+ return {
408
+ "pass": len(findings) == 0,
409
+ "findings": findings,
410
+ "detail": f"Asymmetric pairs: {findings}" if findings else "All pairs balanced",
411
+ }
412
+
413
+ def A5_boundary_blast():
414
+ """Probe counter/timer boundary values."""
415
+ if not BOUNDARY_VARS:
416
+ return {"pass": True, "findings": [], "detail": "No boundary variables defined — skipped"}
417
+
418
+ findings = []
419
+ for var in BOUNDARY_VARS:
420
+ name = var["name"]
421
+ max_val = var.get("max_valid", 255)
422
+ vtype = var.get("type", "counter")
423
+
424
+ test_values = [0, 1, max_val - 1, max_val, max_val + 1]
425
+ if vtype == "counter":
426
+ test_values += [2**8 - 1, 2**16 - 1, 2**32 - 1]
427
+
428
+ for tv in test_values:
429
+ if tv < 0 or tv > max_val:
430
+ findings.append({
431
+ "variable": name,
432
+ "tested_value": tv,
433
+ "max_valid": max_val,
434
+ "risk": f"Value {tv} exceeds max valid {max_val} — overflow possible",
435
+ })
436
+
437
+ if vtype == "timestamp":
438
+ findings.append({
439
+ "variable": name,
440
+ "risk": "Timestamp wraparound — verify elapsed_ms() / elapsed_ticks() handle wraparound correctly",
441
+ })
442
+
443
+ return {
444
+ "pass": len(findings) == 0,
445
+ "findings": findings,
446
+ "detail": f"Boundary issues: {len(findings)}" if findings else "Boundary checks passed",
447
+ }
448
+
449
+ def A6_resource_injection():
450
+ """Simulate resource failures at each state."""
451
+ # Heuristic: identify states that likely allocate resources
452
+ findings = []
453
+ for s in sorted(all_states()):
454
+ if s in TERMINALS:
455
+ continue
456
+ state_lower = s.lower()
457
+ events_lower = [e.lower() for e in STATES.get(s, {}).keys()]
458
+
459
+ # Does this state look like it allocates resources?
460
+ alloc_keywords = ["alloc", "create", "init", "start", "open", "connect", "begin"]
461
+ has_alloc = any(kw in state_lower or any(kw in e for e in events_lower) for kw in alloc_keywords)
462
+
463
+ if not has_alloc:
464
+ continue
465
+
466
+ # Does it have an error recovery path?
467
+ error_keywords = ["error", "fail", "retry", "timeout", "recover", "fatal"]
468
+ has_recovery = any(any(kw in e for e in events_lower) for kw in error_keywords)
469
+
470
+ if not has_recovery:
471
+ findings.append({
472
+ "state": s,
473
+ "risk": f"State '{s}' may allocate resources but has no visible error recovery path",
474
+ })
475
+
476
+ return {
477
+ "pass": len(findings) == 0,
478
+ "findings": findings,
479
+ "detail": f"Resource vulnerability: {len(findings)} states" if findings else "No resource vulnerabilities detected",
480
+ }
481
+
482
+ def A7_shortest_violation(invariant_results):
483
+ """Find shortest violating path for each failed invariant (requires re-running with path tracking)."""
484
+ if not INVARIANTS:
485
+ return {"pass": True, "findings": [], "detail": "No invariants defined — skipped"}
486
+
487
+ findings = []
488
+ for inv in INVARIANTS:
489
+ # BFS to find shortest path to violation
490
+ queue = deque([(INIT, [])])
491
+ visited = set()
492
+ found = None
493
+
494
+ while queue and not found:
495
+ s, path = queue.popleft()
496
+ if s in visited:
497
+ continue
498
+ visited.add(s)
499
+
500
+ try:
501
+ if not inv["check"](STATES, {s}):
502
+ found = path
503
+ break
504
+ except Exception:
505
+ found = path
506
+ break
507
+
508
+ for event, nxt in STATES.get(s, {}).items():
509
+ if nxt not in visited:
510
+ queue.append((nxt, path + [(s, event, nxt)]))
511
+
512
+ if found:
513
+ findings.append({
514
+ "invariant": inv["desc"],
515
+ "violating_path": found,
516
+ "path_length": len(found),
517
+ })
518
+
519
+ return {
520
+ "pass": len(findings) == 0,
521
+ "findings": findings,
522
+ "detail": f"Violated invariants: {len(findings)}" if findings else "All invariants hold for all reachable paths",
523
+ }
524
+
525
+ # =============================================================================
526
+ # S8 / A8-A11 / D1-D4
527
+ # =============================================================================
528
+
529
+ def S8_monotonic_variables():
530
+ findings = []
531
+ for var in MONOTONIC_VARS:
532
+ direction = var.get('direction')
533
+ name = var.get('name', '?')
534
+ if direction == 'inc':
535
+ for event in var.get('decrease_events', []):
536
+ findings.append({'code': 'S8_MONOTONIC_DECREASE', 'severity': 'error', 'message': f'Monotonic inc variable {name} decreased by {event}', 'evidence': {'variable': name, 'event': event}})
537
+ elif direction == 'dec':
538
+ for event in var.get('increase_events', []):
539
+ findings.append({'code': 'S8_MONOTONIC_INCREASE', 'severity': 'error', 'message': f'Monotonic dec variable {name} increased by {event}', 'evidence': {'variable': name, 'event': event}})
540
+ return {'pass': len(findings) == 0, 'findings': findings, 'detail': 'Monotonic variables respected' if not findings else f'{len(findings)} monotonic violations'}
541
+
542
+
543
+ def A8_idempotent_replay():
544
+ findings = []
545
+ for event in IDEMPOTENT_EVENTS:
546
+ for state in sorted(all_states()):
547
+ if event not in STATES.get(state, {}):
548
+ continue
549
+ once = STATES[state][event]
550
+ twice = STATES.get(once, {}).get(event)
551
+ if twice is None:
552
+ findings.append({'code': 'A8_NOT_REPLAYABLE', 'severity': 'warning', 'message': f'Idempotent event {event} not replayable from {state}', 'evidence': {'state': state, 'event': event}})
553
+ elif twice != once:
554
+ findings.append({'code': 'A8_NOT_IDEMPOTENT', 'severity': 'error', 'message': f'Idempotent event {event} changes state from {state}', 'evidence': {'state': state, 'event': event, 'once': once, 'twice': twice}})
555
+ return {'pass': len(findings) == 0, 'findings': findings, 'detail': 'Idempotent events replay-safe' if not findings else f'{len(findings)} idempotent findings'}
556
+
557
+
558
+ def A9_leads_to():
559
+ findings = []
560
+ for from_state, to_state in LEADS_TO:
561
+ visited = set()
562
+ queue = deque([from_state])
563
+ bad = False
564
+ while queue:
565
+ state = queue.popleft()
566
+ if state == to_state:
567
+ continue
568
+ if state in visited:
569
+ bad = True
570
+ break
571
+ visited.add(state)
572
+ nexts = list(STATES.get(state, {}).values())
573
+ if not nexts:
574
+ bad = True
575
+ break
576
+ for nxt in nexts:
577
+ queue.append(nxt)
578
+ if bad:
579
+ findings.append({'code': 'A9_LEADS_TO_VIOLATION', 'severity': 'error', 'message': f'State {from_state} does not always lead to {to_state}', 'evidence': {'from': from_state, 'to': to_state}})
580
+ return {'pass': len(findings) == 0, 'findings': findings, 'detail': 'Leads-to invariants hold' if not findings else f'{len(findings)} leads-to violations'}
581
+
582
+
583
+ def A10_sequence_order():
584
+ findings = []
585
+ for seq in SEQUENCES:
586
+ visited = set()
587
+ queue = deque([(INIT, 0, [])])
588
+ violation = None
589
+ while queue:
590
+ state, progress, path = queue.popleft()
591
+ key = (state, progress)
592
+ if key in visited:
593
+ continue
594
+ visited.add(key)
595
+ for event, nxt in STATES.get(state, {}).items():
596
+ p = progress
597
+ bad = False
598
+ if p < len(seq) and event == seq[p]:
599
+ p += 1
600
+ else:
601
+ idx = seq.index(event) if event in seq else -1
602
+ if idx > p:
603
+ bad = True
604
+ new_path = path + [event]
605
+ if bad:
606
+ violation = new_path
607
+ break
608
+ queue.append((nxt, p, new_path))
609
+ if violation:
610
+ break
611
+ if violation:
612
+ findings.append({'code': 'A10_SEQUENCE_VIOLATION', 'severity': 'error', 'message': f'Sequence {seq} violated by path {violation}', 'evidence': {'sequence': seq, 'path': violation}})
613
+ return {'pass': len(findings) == 0, 'findings': findings, 'detail': 'Sequence orders hold' if not findings else f'{len(findings)} sequence violations'}
614
+
615
+
616
+ def A11_atomicity():
617
+ findings = []
618
+ for group in ATOMIC_GROUPS:
619
+ atomic = set(group.get('events', []))
620
+ commit = group.get('commit')
621
+ rollback = group.get('rollback')
622
+ visited = set()
623
+ queue = deque([(INIT, False, False, [])])
624
+ violation = None
625
+ while queue:
626
+ state, started, closed, path = queue.popleft()
627
+ key = (state, started, closed)
628
+ if key in visited:
629
+ continue
630
+ visited.add(key)
631
+ for event, nxt in STATES.get(state, {}).items():
632
+ ns = started or event in atomic
633
+ nc = closed or event == commit or (rollback is not None and event == rollback)
634
+ new_path = path + [event]
635
+ if started and not closed and event not in atomic and event != commit and event != rollback:
636
+ violation = (new_path, 'left atomic scope')
637
+ break
638
+ if ns and not nc and nxt in TERMINALS:
639
+ violation = (new_path, 'terminal with incomplete atomic group')
640
+ break
641
+ queue.append((nxt, ns, nc, new_path))
642
+ if violation:
643
+ break
644
+ if violation:
645
+ findings.append({'code': 'A11_ATOMICITY_VIOLATION', 'severity': 'error', 'message': f'Atomicity violated: {violation[1]}', 'evidence': {'group': group, 'path': violation[0]}})
646
+ return {'pass': len(findings) == 0, 'findings': findings, 'detail': 'Atomicity invariants hold' if not findings else f'{len(findings)} atomicity violations'}
647
+
648
+
649
+ def D1_behavioral_preservation():
650
+ findings = []
651
+ if not BEFORE_STATES:
652
+ return {'pass': True, 'findings': [], 'detail': 'No before model — skipped'}
653
+ for state, trans in BEFORE_STATES.items():
654
+ mapped = STATE_MAPPING.get(state, state)
655
+ for event in trans:
656
+ if event not in STATES.get(mapped, {}):
657
+ findings.append({'code': 'D1_EVENT_DISABLED', 'severity': 'error', 'message': f'BEFORE event {event} disabled in AFTER state {mapped}', 'evidence': {'beforeState': state, 'mappedState': mapped, 'event': event}})
658
+ return {'pass': len(findings) == 0, 'findings': findings, 'detail': 'Behavior preserved' if not findings else f'{len(findings)} behavior regressions'}
659
+
660
+
661
+ def D2_invariant_continuity():
662
+ if not BEFORE_STATES:
663
+ return {'pass': True, 'findings': [], 'detail': 'No before model — skipped'}
664
+ return {'pass': True, 'findings': [], 'detail': 'Invariant continuity requires structured invariants; use DSH or data-model harness for full check'}
665
+
666
+
667
+ def D3_delta_summary():
668
+ findings = []
669
+ if not BEFORE_STATES:
670
+ return {'pass': True, 'findings': [], 'detail': 'No before model — skipped'}
671
+ before_events = {e for t in BEFORE_STATES.values() for e in t}
672
+ after_events = all_events()
673
+ before_states = set(BEFORE_STATES)
674
+ after_states = all_states()
675
+ removed_states = sorted(before_states - after_states)
676
+ added_states = sorted(after_states - before_states)
677
+ removed_events = sorted(before_events - after_events)
678
+ added_events = sorted(after_events - before_events)
679
+ for state in removed_states:
680
+ findings.append({'code': 'D3_REMOVED_STATE', 'severity': 'warning', 'message': f'BEFORE state {state} removed', 'evidence': {'state': state}})
681
+ for event in removed_events:
682
+ findings.append({'code': 'D3_REMOVED_EVENT', 'severity': 'warning', 'message': f'BEFORE event {event} removed', 'evidence': {'event': event}})
683
+ detail = f'Delta: +{len(added_states)} states, -{len(removed_states)} states, +{len(added_events)} events, -{len(removed_events)} events'
684
+ return {'pass': True, 'findings': findings, 'detail': detail}
685
+
686
+
687
+ def D4_deadlock_liveness_regression():
688
+ findings = []
689
+ if not BEFORE_STATES:
690
+ return {'pass': True, 'findings': [], 'detail': 'No before model — skipped'}
691
+ def deadlock_set(states, terminals):
692
+ return {s for s in states if s not in terminals and len(states.get(s, {})) == 0}
693
+ before_dead = deadlock_set(BEFORE_STATES, BEFORE_TERMINALS)
694
+ after_dead = deadlock_set(STATES, TERMINALS)
695
+ mapped_before_dead = {STATE_MAPPING.get(s, s) for s in before_dead}
696
+ for state in sorted(after_dead):
697
+ if state not in mapped_before_dead:
698
+ findings.append({'code': 'D4_DEADLOCK_REGRESSION', 'severity': 'error', 'message': f'AFTER introduces deadlock in {state}', 'evidence': {'state': state}})
699
+ return {'pass': len(findings) == 0, 'findings': findings, 'detail': 'No deadlock regressions' if not findings else f'{len(findings)} deadlock regressions'}
700
+
701
+
702
+ # =============================================================================
703
+ # MAIN
704
+ # =============================================================================
705
+
706
+ def run_all():
707
+ results = {}
708
+ errors = 0
709
+ warnings = 0
710
+
711
+ print("=" * 60)
712
+ print("PHASE 2a: STRUCTURAL PRIMITIVES")
713
+ print("=" * 60)
714
+
715
+ checks_2a = [
716
+ ("S1 Reachability", S1_reachability),
717
+ ("S2 Deadlock", S2_deadlock),
718
+ ("S3 Liveness", S3_liveness),
719
+ ("S4 Determinism", S4_determinism),
720
+ ("S5 Event Completeness", S5_event_completeness),
721
+ ("S6 Guard Completeness", S6_guard_completeness),
722
+ ("S7 Invariants", S7_invariants),
723
+ ("S8 Monotonic Variables", S8_monotonic_variables),
724
+ ]
725
+
726
+ for name, check_fn in checks_2a:
727
+ result = check_fn()
728
+ results[name] = result
729
+ status = "PASS" if result["pass"] else "FAIL"
730
+ prefix = " " if result["pass"] else " [!] "
731
+ print(f"{prefix}[{status}] {name}: {result['detail']}")
732
+ if not result["pass"]:
733
+ if "Warning" in str(type(check_fn)):
734
+ warnings += 1
735
+ else:
736
+ errors += 1
737
+
738
+ print()
739
+ print("=" * 60)
740
+ print("PHASE 2b: ADVERSARIAL PROBES")
741
+ print("=" * 60)
742
+
743
+ probes_2b = [
744
+ ("A1 Unexpected Event", A1_unexpected_event),
745
+ ("A2 Race Interleaving", A2_race_interleaving),
746
+ ("A3 Order Permutation", A3_order_permutation),
747
+ ("A4 Pair Symmetry", A4_pair_symmetry),
748
+ ("A5 Boundary Blast", A5_boundary_blast),
749
+ ("A6 Resource Injection", A6_resource_injection),
750
+ ("A7 Shortest Violation", lambda: A7_shortest_violation(results.get("S7 Invariants", {}))),
751
+ ("A8 Idempotent Replay", A8_idempotent_replay),
752
+ ("A9 Leads-To", A9_leads_to),
753
+ ("A10 Sequence Order", A10_sequence_order),
754
+ ("A11 Atomicity", A11_atomicity),
755
+ ]
756
+
757
+ for name, probe_fn in probes_2b:
758
+ result = probe_fn()
759
+ results[name] = result
760
+ status = "PASS" if result["pass"] else "FAIL"
761
+ prefix = " " if result["pass"] else " [!] "
762
+ print(f"{prefix}[{status}] {name}: {result['detail']}")
763
+ if not result["pass"]:
764
+ warnings += 1 # Probe failures are warnings by default (may be false positives)
765
+
766
+ if BEFORE_STATES:
767
+ print()
768
+ print("=" * 60)
769
+ print("PHASE 2c: BEFORE/AFTER REGRESSION")
770
+ print("=" * 60)
771
+ diff_checks = [
772
+ ("D1 Behavioral Preservation", D1_behavioral_preservation),
773
+ ("D2 Invariant Continuity", D2_invariant_continuity),
774
+ ("D3 Delta Summary", D3_delta_summary),
775
+ ("D4 Deadlock/Liveness Regression", D4_deadlock_liveness_regression),
776
+ ]
777
+ for name, check_fn in diff_checks:
778
+ result = check_fn()
779
+ results[name] = result
780
+ status = "PASS" if result["pass"] else "FAIL"
781
+ prefix = " " if result["pass"] else " [!] "
782
+ print(f"{prefix}[{status}] {name}: {result['detail']}")
783
+ if not result["pass"]:
784
+ errors += 1
785
+
786
+ print()
787
+ print("=" * 60)
788
+ print(f"SUMMARY: {errors} structural errors, {warnings} probe/other warnings")
789
+ print("=" * 60)
790
+
791
+ if errors > 0:
792
+ print()
793
+ print("ACTION: Fix structural errors before proceeding to Phase 3.")
794
+ print("Structural errors indicate the plan's logic is incomplete or inconsistent.")
795
+
796
+ if warnings > 0 and errors == 0:
797
+ print()
798
+ print("ACTION: Review probe warnings — may be false positives or acceptable risks.")
799
+ print("Escalate confirmed findings to Phase 3 gap analysis.")
800
+
801
+ return errors, warnings, results
802
+
803
+
804
+ if __name__ == "__main__":
805
+ errors, warnings, results = run_all()
806
+ sys.exit(1 if errors > 0 else 0)