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