dsh-logicprobe 0.8.0 → 0.9.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.
@@ -68,7 +68,8 @@ The same reasoning applies to every id a check names. A reference to a state, ev
68
68
  | `transitions[].guard` variables | a declared variable |
69
69
  | `resourcePairs[].acquireEvent` / `.releaseEvent` / `.failEvent` | a declared event |
70
70
  | `invariants[].event` (`event-before-state`) | a declared event |
71
- | `invariants[].state` / `.states[]` / `.from` / `.to` / `.target` / `.when.state` | a declared state |
71
+ | `invariants[].state` / `.states[]` / `.from` / `.target` / `.when.state` | a declared state |
72
+ | `invariants[].to` and every member of `invariants[].to[]` (`leads-to`) | a declared state |
72
73
 
73
74
  A vacuous check is worse than a rejected one: a `leads-to` invariant whose `to` state is misspelled can never be violated, and a `never-states` list holding a nonexistent id can never be reached, so both report "pass" for a model that was never actually checked.
74
75
 
@@ -162,13 +163,13 @@ A guard is exactly one of:
162
163
  | `never-states` | `{ states: ["ERROR"] }` | No reachable runtime state may be in the forbidden set |
163
164
  | `var-in-range` | `{ variable, min?, max?, when? }` | Every reachable runtime state selected by `when` keeps the variable in range (at least one of `min`/`max` is required) |
164
165
  | `event-before-state` | `{ event: "power_ready", state: "ACTIVE" }` | Every path entering `state` must have passed through `event` first |
165
- | `leads-to` | `{ from: "MIGRATING", to: "DONE" }` | Every path from `from` must eventually reach `to` |
166
+ | `leads-to` | `{ from: "MIGRATING", to: "DONE" }`, or `to: ["DONE", "FAILED"]` | Every path from `from` must eventually reach `to`. With a target set, every path must reach at least one member. The set must be non-empty and free of duplicates |
166
167
  | `sequence` | `{ events: ["backup", "modify", "commit"] }` | Events must occur in the given order |
167
168
  | `atomicity` | `{ events: ["write"], commit: "commit", rollback?: "rollback" }` | Atomic group must end with commit/rollback before leaving scope |
168
169
  | `budget` | `{ budget: n }` | No reachable path may accumulate transition cost greater than n (A12). Costs are non-negative; a transition without `cost` counts 1, so legacy machines keep step-count semantics |
169
170
  | `probability` | `{ target, op: one of >= <= > <, p }` | P(ever hitting target) must satisfy the bound (A13, DTMC from transition `weight`, default 1; value iteration) |
170
171
 
171
- A7 reports the shortest violating path for each failed invariant. An empty path means the initial state already violates it.
172
+ A7 reports a violating path for each failed invariant, and an empty path means the initial state already violates it. The state-predicate kinds, `sequence`, `atomicity` and `budget` are breadth-first, so their witness is the shortest one; `leads-to` and `probability` report a witness without claiming minimality.
172
173
 
173
174
  ### Scoping a range to a state (`when`)
174
175
 
@@ -281,7 +282,7 @@ When `beforeModel` is passed to `logicprobe_verify`, the engine treats `model` a
281
282
  | Check | Purpose |
282
283
  |---|---|
283
284
  | D1 Behavioral Preservation | Every BEFORE (state, event) that could fire must still be fireable from the mapped AFTER state |
284
- | D2 Invariant Continuity | Every BEFORE invariant (mapped through `stateMapping`) must still hold in AFTER |
285
+ | D2 Invariant Continuity | Every BEFORE invariant (mapped through `stateMapping`) must still hold in AFTER. State predicates go through the same reachability search as S7; `leads-to`, `sequence`, `atomicity`, `budget` and `probability` are re-decided by the searches their own probes (A9-A13) use |
285
286
  | D3 Regression Delta | Lists added/removed states, events, and transitions |
286
287
  | D4 Deadlock/Liveness Regression | New deadlock states or closed SCCs not present in BEFORE |
287
288
 
@@ -306,7 +307,7 @@ List events that must be idempotent in `idempotentEvents`. For every reachable s
306
307
  ## Advanced constraints (S8, A9-A14)
307
308
 
308
309
  - **S8 Monotonic Variables**: declare `monotonic: "inc"|"dec"` on a variable; updates must not move in the opposite direction.
309
- - **A9 Leads-To**: `{ kind: "leads-to", from, to }` — every path from `from` must eventually reach `to`.
310
+ - **A9 Leads-To**: `{ kind: "leads-to", from, to }` — every path from `from` must eventually reach `to`. `to` is one state id or a non-empty array of state ids for "reach any one of these"; a duplicated member is a validation error rather than a shorthand. Either way the property stays universal: one branch that loops forever, or that stops before every target, refutes it.
310
311
  - **A10 Sequence**: `{ kind: "sequence", events }` — events must appear in order.
311
312
  - **A11 Atomicity**: `{ kind: "atomicity", events, commit, rollback? }` — once an atomic event starts, the machine must reach commit/rollback before leaving the atomic scope or terminating.
312
313
  - **A12 Budget**: `{ kind: "budget", budget }` — no reachable path may accumulate transition cost above the budget; reports the shortest over-budget path and flags reachable positive-cost cycles as unbounded.
@@ -271,6 +271,8 @@ def shortest_violating_path(states, init, invariant_check):
271
271
  return None # Invariant holds for all reachable states
272
272
  ```
273
273
 
274
+ That template decides one runtime state at a time, so it only fits the state-predicate kinds. A `leads-to`, `sequence`, `atomicity`, `budget` or `probability` invariant asserts a property of a whole run: reuse the search in its own section (A9-A13) as the witness finder, or A7 will report "all invariants hold" for a model where one of them fails.
275
+
274
276
  ### S8: Monotonic Variables
275
277
 
276
278
  For counters or progress variables that must only move in one direction, list the events that increase/decrease them. Any event that moves against the declared direction is a finding.
@@ -291,11 +293,13 @@ IDEMPOTENT_EVENTS = {"retry", "sync", "webhook_delivery"}
291
293
 
292
294
  ### A9: Leads-To
293
295
 
294
- For progress claims ("from MIGRATING, eventually DONE"), check every path from the source state. If a path dead-ends or loops before reaching the target, flag it.
296
+ For progress claims ("from MIGRATING, eventually DONE"), check every path from the source state. If a path dead-ends or loops before reaching the target, flag it. When the code has several acceptable outcomes, give the invariant a target set and check every path reaches at least one member; that is still a universal claim, so a branch that reaches none of them fails.
295
297
 
296
298
  ```python
297
299
  LEADS_TO = [
298
300
  ("MIGRATING", "DONE"),
301
+ # or a set of acceptable outcomes
302
+ ("DRAINING", ["IDLE", "FAULT"]),
299
303
  ]
300
304
  ```
301
305
 
@@ -438,7 +442,7 @@ The BEFORE model comes from **code, not the plan**. The plan may describe the cu
438
442
 
439
443
  **Behavioral preservation**: For each event sequence accepted by BEFORE, trace the same sequence in AFTER. If AFTER ends in a different state (or rejects the sequence), flag as BEHAVIORAL DELTA. The plan must explicitly document this delta — if it doesn't, it's a regression.
440
444
 
441
- **Invariant continuity**: Re-run S7 invariant checks on both models. Any invariant that passes on BEFORE but fails on AFTER is a regression — the refactoring broke an existing guarantee.
445
+ **Invariant continuity**: Re-decide every invariant on AFTER, mapped through `stateMapping`. Any invariant that holds on BEFORE but fails on AFTER is a regression — the refactoring broke an existing guarantee. State predicates go through the same reachability search as S7; `leads-to`, `sequence`, `atomicity`, `budget` and `probability` are re-decided by the searches their own probes (A9-A13) use, so continuity agrees with the standalone verdict. A state reference that the mapping drops is reported as a mapped-state warning instead of silently changing the property.
442
446
 
443
447
  **Complexity delta**: Count objectively:
444
448
 
@@ -507,7 +511,7 @@ Manual verification is reliable for state machines with **≤ 10 states and ≤
507
511
 
508
512
  **A6 Resource Injection**: For each state that appears to allocate (names containing "alloc", "init", "start", "open", "connect", "begin"), check if there is a recovery/error path if the allocation fails. If no recovery exists, flag as RESOURCE VULNERABLE.
509
513
 
510
- **A7 Minimal Counter-Example**: For each invariant that failed in S7, find the SHORTEST event sequence that violates it. This is the most useful output — it gives the plan author an exact repro.
514
+ **A7 Minimal Counter-Example**: For each invariant that failed, find the event sequence that violates it. This is the most useful output — it gives the plan author an exact repro. The searches for state predicates, `sequence`, `atomicity` and `budget` are breadth-first, so their witness is the shortest one; the `leads-to` walk and the `probability` bound report what they found without claiming minimality.
511
515
 
512
516
  ### Manual Mode Output Format
513
517
 
@@ -392,11 +392,32 @@ def validate_model(input_value):
392
392
  bad(p + '.from', 'must be a non-empty string')
393
393
  elif lfrom not in invariant_state_ids:
394
394
  bad(p + '.from', 'references unknown state ' + lfrom)
395
+ # `to` is one state id or a target set. A set with a duplicate
396
+ # member is a model-authoring slip that would otherwise give one
397
+ # meaning two hashes, so it is rejected rather than deduplicated.
395
398
  lto = entry.get('to')
396
- if not isinstance(lto, str) or len(lto) == 0:
397
- bad(p + '.to', 'must be a non-empty string')
398
- elif lto not in invariant_state_ids:
399
- bad(p + '.to', 'references unknown state ' + lto)
399
+ if isinstance(lto, str):
400
+ if len(lto) == 0:
401
+ bad(p + '.to', 'must be a non-empty string')
402
+ elif lto not in invariant_state_ids:
403
+ bad(p + '.to', 'references unknown state ' + lto)
404
+ elif isinstance(lto, list):
405
+ if len(lto) == 0:
406
+ bad(p + '.to', 'must be a non-empty array of state ids')
407
+ else:
408
+ seen_targets = set()
409
+ for target_index, target in enumerate(lto):
410
+ at = p + '.to[' + str(target_index) + ']'
411
+ if not isinstance(target, str) or len(target) == 0:
412
+ bad(at, 'must be a non-empty string')
413
+ elif target not in invariant_state_ids:
414
+ bad(at, 'references unknown state ' + target)
415
+ elif target in seen_targets:
416
+ bad(at, 'duplicates state ' + target + ' in the target set')
417
+ else:
418
+ seen_targets.add(target)
419
+ else:
420
+ bad(p + '.to', 'must be a state id or a non-empty array of state ids')
400
421
  elif kind == 'sequence':
401
422
  evs = entry.get('events')
402
423
  if not isinstance(evs, list) or len(evs) == 0:
@@ -1190,10 +1211,98 @@ def _shortest_violation_for_invariant(model, max_states, invariant):
1190
1211
  return None
1191
1212
 
1192
1213
 
1193
- def _run_invariants(model, max_states):
1214
+ def _is_path_property_invariant(invariant):
1215
+ # Whether an invariant asserts a property of whole runs rather than of a single
1216
+ # runtime state. These kinds cannot be decided by a state predicate, so they are
1217
+ # not part of _shortest_violation_for_invariant (and therefore not of S7), which
1218
+ # owns the states; A9-A13 own the verdicts, and _path_property_violation below
1219
+ # makes the same verdicts available to A7 and D2.
1220
+ return invariant['kind'] in ('leads-to', 'sequence', 'atomicity', 'budget', 'probability')
1221
+
1222
+
1223
+ def _probability_violated(invariant, probability):
1224
+ # Whether a probability invariant's bound fails for the computed hit probability.
1225
+ eps = 1e-9
1226
+ op = invariant['op']
1227
+ if op == '>=':
1228
+ return probability < invariant['p'] - eps
1229
+ if op == '>':
1230
+ return probability <= invariant['p'] + eps
1231
+ if op == '<=':
1232
+ return probability > invariant['p'] + eps
1233
+ return probability >= invariant['p'] - eps
1234
+
1235
+
1236
+ def _reachable_runtime_states(model, max_states):
1237
+ # Every runtime state reachable from init, capped like the other searches.
1238
+ init = _initial_state(model)
1239
+ visited = set([_runtime_key(init)])
1240
+ reached = [init]
1241
+ queue = deque([init])
1242
+ steps = 0
1243
+ while queue:
1244
+ steps += 1
1245
+ if steps > max_states:
1246
+ break
1247
+ current = queue.popleft()
1248
+ for event in _all_events(model):
1249
+ for nxt in _step_runtime(model, current, event):
1250
+ k = _runtime_key(nxt)
1251
+ if k in visited:
1252
+ continue
1253
+ visited.add(k)
1254
+ reached.append(nxt)
1255
+ queue.append(nxt)
1256
+ return reached
1257
+
1258
+
1259
+ def _path_property_violation(model, max_states, invariant):
1260
+ # Violation of a path-property invariant, or None when it holds. Each kind
1261
+ # delegates to the search its dedicated probe uses (A9 leads-to, A10 sequence, A11
1262
+ # atomicity, A12 budget, A13 probability), so a verdict reported here can never
1263
+ # disagree with the probe that owns the kind.
1264
+ kind = invariant['kind']
1265
+ if kind == 'leads-to':
1266
+ targets = set(_leads_to_targets(invariant))
1267
+ label = _leads_to_target_label(invariant)
1268
+ for runtime in _reachable_runtime_states(model, max_states):
1269
+ if runtime['state'] != invariant['from']:
1270
+ continue
1271
+ bad = _find_leads_to_bad_path(model, runtime, targets, label)
1272
+ if bad is not None:
1273
+ return {'invariant': invariant, 'path': bad['path'], 'reason': bad['reason']}
1274
+ return None
1275
+ if kind == 'sequence':
1276
+ return _find_sequence_violation(model, max_states, invariant)
1277
+ if kind == 'atomicity':
1278
+ return _find_atomicity_violation(model, max_states, invariant)
1279
+ if kind == 'budget':
1280
+ violation = _find_budget_violation(model, max_states, invariant)
1281
+ if violation is None:
1282
+ return None
1283
+ reason = ('a reachable cycle keeps accumulating cost, so no finite budget holds'
1284
+ if violation['unbounded']
1285
+ else 'a run accumulates cost ' + str(violation['cost']) + ', above the declared budget ' + str(invariant['budget']))
1286
+ return {'invariant': invariant, 'path': violation['path'], 'reason': reason}
1287
+ if kind == 'probability':
1288
+ computed = _compute_hit_probability(model, max_states, invariant['target'])
1289
+ probability = computed['probability']
1290
+ if not _probability_violated(invariant, probability):
1291
+ return None
1292
+ reason = ('P(hit ' + invariant['target'] + ') = ' + '{:.6f}'.format(probability)
1293
+ + ' does not satisfy ' + invariant['op'] + ' ' + str(invariant['p'])
1294
+ + ('' if computed['converged'] else ' (value iteration did not converge)'))
1295
+ return {'invariant': invariant, 'path': [], 'reason': reason}
1296
+ return None
1297
+
1298
+
1299
+ def _run_invariants(model, max_states, include_path_properties=False):
1194
1300
  violations = []
1195
1301
  for invariant in model.get('invariants') or []:
1196
- violation = _shortest_violation_for_invariant(model, max_states, invariant)
1302
+ if include_path_properties and _is_path_property_invariant(invariant):
1303
+ violation = _path_property_violation(model, max_states, invariant)
1304
+ else:
1305
+ violation = _shortest_violation_for_invariant(model, max_states, invariant)
1197
1306
  if violation is not None:
1198
1307
  violations.append(violation)
1199
1308
  return violations
@@ -1575,14 +1684,27 @@ def A6_resource_injection(model):
1575
1684
 
1576
1685
 
1577
1686
  def A7_shortest_violations(model, max_states):
1578
- violations = _run_invariants(model, max_states)
1687
+ # Path-property invariants are included: "for any invariant that fails" is this
1688
+ # check's contract, and a check that silently skips five of the eight kinds cannot
1689
+ # honour it. The searches for the state kinds, for sequence, atomicity and budget
1690
+ # are breadth-first, so their first witness is the shortest one; the leads-to walk
1691
+ # and the probability bound have no such guarantee, and their message says what it
1692
+ # found instead of claiming minimality.
1693
+ violations = _run_invariants(model, max_states, True)
1579
1694
  findings = []
1580
1695
  for violation in violations:
1581
1696
  invariant = violation['invariant']
1697
+ path_length = len(violation['path'])
1698
+ if _is_path_property_invariant(invariant):
1699
+ message = ('Invariant "' + invariant['id'] + '" violated: ' + violation['reason']
1700
+ + ('' if path_length == 0 else ' (witness path length: ' + str(path_length) + ')'))
1701
+ else:
1702
+ message = ('Invariant "' + invariant['id'] + '" shortest violating path length: ' + str(path_length)
1703
+ + (' (initial state)' if path_length == 0 else ''))
1582
1704
  findings.append({
1583
1705
  'code': 'A7_SHORTEST_COUNTEREXAMPLE',
1584
1706
  'severity': 'warning',
1585
- 'message': 'Invariant "' + invariant['id'] + '" shortest violating path length: ' + str(len(violation['path'])) + (' (initial state)' if len(violation['path']) == 0 else ''),
1707
+ 'message': message,
1586
1708
  'path': violation['path'],
1587
1709
  'evidence': {'invariant': invariant['id']},
1588
1710
  })
@@ -1648,6 +1770,14 @@ def _map_invariant_for_comparison(invariant, mapping):
1648
1770
  when = invariant.get('when')
1649
1771
  if when is not None and 'state' in when:
1650
1772
  out['when'] = {'state': _map_state_id(mapping, when['state'])}
1773
+ elif invariant['kind'] == 'leads-to':
1774
+ # A target set maps member by member; a rename that drops a state is reported by
1775
+ # D2's own mapped-state check rather than silently changing the property.
1776
+ out['from'] = _map_state_id(mapping, invariant['from'])
1777
+ to = invariant['to']
1778
+ out['to'] = _map_state_id(mapping, to) if isinstance(to, str) else [_map_state_id(mapping, s) for s in to]
1779
+ elif invariant['kind'] == 'probability':
1780
+ out['target'] = _map_state_id(mapping, invariant['target'])
1651
1781
  return out
1652
1782
 
1653
1783
 
@@ -1683,7 +1813,33 @@ def D2_invariant_continuity(before, after, max_states, mapping):
1683
1813
  'evidence': {'invariant': invariant['id'], 'variable': mapped['variable']},
1684
1814
  })
1685
1815
  continue
1686
- violation = _shortest_violation_for_invariant(after, max_states, mapped)
1816
+ elif mapped['kind'] == 'leads-to':
1817
+ # The state references of a path-property invariant must follow stateMapping
1818
+ # too, otherwise a pure rename searches for a state AFTER does not declare.
1819
+ for state in [mapped['from']] + _leads_to_targets(mapped):
1820
+ if state not in after_states:
1821
+ findings.append({
1822
+ 'code': 'D2_MAPPED_STATE_MISSING',
1823
+ 'severity': 'warning',
1824
+ 'message': 'BEFORE invariant "' + invariant['id'] + '" maps to state ' + state + ', which is not declared in AFTER.',
1825
+ 'evidence': {'invariant': invariant['id'], 'state': state},
1826
+ })
1827
+ elif mapped['kind'] == 'probability':
1828
+ if mapped['target'] not in after_states:
1829
+ findings.append({
1830
+ 'code': 'D2_MAPPED_STATE_MISSING',
1831
+ 'severity': 'warning',
1832
+ 'message': 'BEFORE invariant "' + invariant['id'] + '" maps to state ' + mapped['target'] + ', which is not declared in AFTER.',
1833
+ 'evidence': {'invariant': invariant['id'], 'state': mapped['target']},
1834
+ })
1835
+ # Every invariant the engine can decide is re-decided against AFTER.
1836
+ # Path-property kinds go through the same searches their dedicated probes use,
1837
+ # so D2 can no longer report "all BEFORE invariants continue to hold" while
1838
+ # A9-A13 fail on the very same model.
1839
+ if _is_path_property_invariant(mapped):
1840
+ violation = _path_property_violation(after, max_states, mapped)
1841
+ else:
1842
+ violation = _shortest_violation_for_invariant(after, max_states, mapped)
1687
1843
  if violation is not None:
1688
1844
  findings.append({
1689
1845
  'code': 'D2_INVARIANT_REGRESSION',
@@ -1869,29 +2025,71 @@ def S8_monotonic_variables(model):
1869
2025
  'Monotonic variables are respected' if not findings else 'Monotonic findings: ' + str(len(findings)))
1870
2026
 
1871
2027
 
1872
- def _find_leads_to_bad_path(model, start, target):
1873
- if start['state'] == target:
2028
+ def _leads_to_targets(invariant):
2029
+ # Target states of a leads-to invariant. The schema accepts one state id or a
2030
+ # non-empty array of them, so "reach any of these" needs no separate kind.
2031
+ to = invariant['to']
2032
+ return [to] if isinstance(to, str) else list(to)
2033
+
2034
+
2035
+ def _leads_to_target_label(invariant):
2036
+ # Target list as findings print it. A single target renders as its bare id, so
2037
+ # existing messages stay byte-identical.
2038
+ return ', '.join(_leads_to_targets(invariant))
2039
+
2040
+
2041
+ def _find_leads_to_bad_path(model, start, targets, label):
2042
+ # Find a run from start that reaches none of targets, or None when every run
2043
+ # reaches at least one of them. The property is universal (see the A9 row in
2044
+ # SKILL.md): one branch that loops forever, or that stops before any target,
2045
+ # refutes it.
2046
+ #
2047
+ # Depth-first walk of the run graph with three colours; runs at a target are
2048
+ # success leaves that are never expanded. A node reached while it is on the
2049
+ # current walk (GRAY) closes a cycle that avoids every target. A node with no
2050
+ # outgoing step at all stops the machine where it stands, which is the same
2051
+ # violation for a different reason. A node whose walk completed without a
2052
+ # violation is BLACK, and reaching it again from another branch is a shared
2053
+ # sub-graph, not a cycle -- that is why the colour map cannot be a plain visited
2054
+ # set: a diamond (two branches rejoining) is acyclic and must pass, while a
2055
+ # genuine cycle must not. A run is identified by its state plus its variable
2056
+ # values.
2057
+ if start['state'] in targets:
1874
2058
  return None
1875
- visited = set()
1876
- queue = deque([{'runtime': start, 'path': []}])
1877
- while queue:
1878
- entry = queue.popleft()
1879
- k = _runtime_key(entry['runtime'])
1880
- if entry['runtime']['state'] == target:
2059
+ GRAY = 1
2060
+ BLACK = 2
2061
+ color = {}
2062
+ stack = [{'runtime': start, 'path': [], 'nexts': None, 'index': 0}]
2063
+ color[_runtime_key(start)] = GRAY
2064
+ while stack:
2065
+ frame = stack[-1]
2066
+ if frame['nexts'] is None:
2067
+ nexts = []
2068
+ for event in _all_events(model):
2069
+ for nxt in _step_runtime(model, frame['runtime'], event):
2070
+ nexts.append({'next': nxt, 'event': event})
2071
+ frame['nexts'] = nexts
2072
+ if not nexts:
2073
+ return {'path': frame['path'], 'reason': 'Dead end before target ' + label}
2074
+ if frame['index'] >= len(frame['nexts']):
2075
+ color[_runtime_key(frame['runtime'])] = BLACK
2076
+ stack.pop()
1881
2077
  continue
1882
- if k in visited:
1883
- return {'path': entry['path'], 'reason': 'Cycle avoids target ' + target}
1884
- visited.add(k)
1885
- nexts = []
1886
- for event in _all_events(model):
1887
- for nxt in _step_runtime(model, entry['runtime'], event):
1888
- nexts.append({'next': nxt, 'event': event})
1889
- if not nexts:
1890
- return {'path': entry['path'], 'reason': 'Dead end before target ' + target}
1891
- for item in nexts:
1892
- queue.append({'runtime': item['next'],
1893
- 'path': list(entry['path']) + [{'from': entry['runtime']['state'], 'event': item['event'], 'to': item['next']['state']}]})
1894
- return {'path': [], 'reason': 'No path reaches target ' + target}
2078
+ item = frame['nexts'][frame['index']]
2079
+ frame['index'] += 1
2080
+ step = {'from': frame['runtime']['state'], 'event': item['event'], 'to': item['next']['state']}
2081
+ if item['next']['state'] in targets:
2082
+ continue
2083
+ key = _runtime_key(item['next'])
2084
+ seen = color.get(key)
2085
+ if seen == GRAY:
2086
+ return {'path': frame['path'] + [step], 'reason': 'Cycle avoids target ' + label}
2087
+ if seen == BLACK:
2088
+ continue
2089
+ color[key] = GRAY
2090
+ stack.append({'runtime': item['next'], 'path': frame['path'] + [step], 'nexts': None, 'index': 0})
2091
+ # Every branch either reached the target or rejoined a branch that did.
2092
+ return None
1895
2093
 
1896
2094
 
1897
2095
  def A9_leads_to(model, exploration):
@@ -1899,10 +2097,12 @@ def A9_leads_to(model, exploration):
1899
2097
  for invariant in model.get('invariants') or []:
1900
2098
  if invariant['kind'] != 'leads-to':
1901
2099
  continue
2100
+ targets = set(_leads_to_targets(invariant))
2101
+ label = _leads_to_target_label(invariant)
1902
2102
  for runtime in exploration['reachable']:
1903
2103
  if runtime['state'] != invariant['from']:
1904
2104
  continue
1905
- bad = _find_leads_to_bad_path(model, runtime, invariant['to'])
2105
+ bad = _find_leads_to_bad_path(model, runtime, targets, label)
1906
2106
  if bad is not None:
1907
2107
  findings.append({
1908
2108
  'code': 'A9_LEADS_TO_VIOLATION',
@@ -2282,7 +2482,6 @@ def _compute_hit_probability(model, max_states, target_state):
2282
2482
 
2283
2483
  def A13_probability(model, max_states):
2284
2484
  findings = []
2285
- eps = 1e-9
2286
2485
  for invariant in model.get('invariants') or []:
2287
2486
  if invariant['kind'] != 'probability':
2288
2487
  continue
@@ -2291,16 +2490,7 @@ def A13_probability(model, max_states):
2291
2490
  converged = result['converged']
2292
2491
  op = invariant['op']
2293
2492
  p_bound = invariant['p']
2294
- violated = False
2295
- if op == '>=':
2296
- violated = probability < p_bound - eps
2297
- elif op == '>':
2298
- violated = probability <= p_bound + eps
2299
- elif op == '<=':
2300
- violated = probability > p_bound + eps
2301
- else:
2302
- violated = probability >= p_bound - eps
2303
- if violated:
2493
+ if _probability_violated(invariant, probability):
2304
2494
  findings.append({
2305
2495
  'code': 'A13_PROBABILITY_VIOLATION',
2306
2496
  'severity': 'error',
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: logicprobe-datamodel
3
- description: "Use when reviewing design documents, architecture specs, technical proposals, schema changes, data contracts, or refactoring plans that make claims about entities, fields, constraints, relationships, data invariants, migration coverage, or before/after data-model equivalence. When the document contains schema changes, data migration logic, or data-behavioral assertions ('all records must have X', 'target count equals source count', 'no orphan rows', 'migration is non-breaking'), escalate into data-model verification — generate and run executable checks for structural consistency, data invariants, migration coverage, copy consistency, and breaking changes before trusting any claim. Also proactively SUGGEST this skill for code-level data/schema behavioral questions."
3
+ description: "Use when reviewing design documents, architecture specs, technical proposals, schema changes, data contracts, or refactoring plans that make claims about entities, fields, constraints, relationships, data invariants, migration coverage, copy consistency, rollback symmetry, or before/after data-model equivalence. When the document contains schema changes, data migration logic, or data-behavioural assertions ('all records must have X', 'target count equals source count', 'no orphan rows', 'ids never move backwards', 'exactly one row per event', 'rollback restores the old shape', 'migration is non-breaking'), escalate into data-model verification — generate and run executable checks for structural consistency (DS1-DS4), data invariants and adversarial probes (DA1-DA12: null/boundary injection, uniqueness, referential integrity, migration coverage, copy consistency, rollback symmetry, idempotency, monotonicity, ordering, leads-to, atomicity), and before/after data regression (DD1-DD4) before trusting any claim. Behavioural, state-machine, protocol and concurrency claims belong to the sibling logicprobe skill. Also proactively SUGGEST this skill for code-level data/schema behavioural questions."
4
4
  ---
5
5
 
6
6
  # Logic Probe Data
@@ -1,4 +1,4 @@
1
- import { defineTool } from '@deepseek-ai/dsh-tools'
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from './json-value.js'
3
3
  import { runConcurrencyScan } from './concurrency.js'
4
4
 
@@ -13,7 +13,7 @@ export const LOGICPROBE_CONCURRENCY_SCAN_TOOL_NAME = 'logicprobe_concurrency_sca
13
13
  export const logicProbeConcurrencyScanTool = defineTool({
14
14
  name: LOGICPROBE_CONCURRENCY_SCAN_TOOL_NAME,
15
15
  description:
16
- 'Scan a document or plan text for concurrency risk points. Use ONLY after confirming the target actually has concurrency requirements or behavior (threads, async tasks, interrupts, shared state, parallel execution). If the target is purely sequential, do not call this tool. Returns findings for keywords like thread-safe, lock-free, data race, race condition, atomic, synchronized, mutex, semaphore, shared variable, reentrant, interrupt-safe. Absolute claims (thread-safe, lock-free, no data race) are flagged as errors requiring dedicated verification.',
16
+ 'Scan a document or plan text for concurrency risk points. Use ONLY after confirming the target actually has concurrency requirements or behavior (threads, async tasks, interrupts, shared state, parallel execution). If the target is purely sequential, do not call this tool. Returns findings for keywords like thread-safe, lock-free, wait-free, data race, race condition, atomic, synchronized, mutex, semaphore, spinlock, shared variable, shared memory, reentrant, interrupt-safe, ISR, IRQ, NMI, critical section, disable_irq/enable_irq. Absolute claims (thread-safe, lock-free, no data race, interrupt-safe, ISR-safe) are flagged as errors requiring dedicated verification, and each carries suggestions naming the analysis to run instead (TSan/Helgrind, CBMC, TLA+, or RTOS-aware interrupt analysis). logicprobe does not prove concurrency safety.',
17
17
  parameters: {
18
18
  text: {
19
19
  type: 'string',