dsh-logicprobe 0.3.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.
@@ -0,0 +1,582 @@
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)