dsh-logicprobe 0.8.1 → 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.
@@ -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,21 +2025,36 @@ 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
- # Find a run from start that never reaches target, or None when every run does.
1874
- # The property is universal (see the A9 row in SKILL.md): one branch that loops
1875
- # forever, or that stops before the target, refutes it.
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.
1876
2046
  #
1877
- # Depth-first walk of the run graph with three colours; target runs are success
1878
- # leaves that are never expanded. A node reached while it is on the current walk
1879
- # (GRAY) closes a cycle that avoids the target. A node with no outgoing step at
1880
- # all stops the machine where it stands, which is the same violation for a
1881
- # different reason. A node whose walk completed without a violation is BLACK, and
1882
- # reaching it again from another branch is a shared sub-graph, not a cycle -- that
1883
- # is why the colour map cannot be a plain visited set: a diamond (two branches
1884
- # rejoining) is acyclic and must pass, while a genuine cycle must not. A run is
1885
- # identified by its state plus its variable values.
1886
- if start['state'] == target:
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:
1887
2058
  return None
1888
2059
  GRAY = 1
1889
2060
  BLACK = 2
@@ -1899,7 +2070,7 @@ def _find_leads_to_bad_path(model, start, target):
1899
2070
  nexts.append({'next': nxt, 'event': event})
1900
2071
  frame['nexts'] = nexts
1901
2072
  if not nexts:
1902
- return {'path': frame['path'], 'reason': 'Dead end before target ' + target}
2073
+ return {'path': frame['path'], 'reason': 'Dead end before target ' + label}
1903
2074
  if frame['index'] >= len(frame['nexts']):
1904
2075
  color[_runtime_key(frame['runtime'])] = BLACK
1905
2076
  stack.pop()
@@ -1907,12 +2078,12 @@ def _find_leads_to_bad_path(model, start, target):
1907
2078
  item = frame['nexts'][frame['index']]
1908
2079
  frame['index'] += 1
1909
2080
  step = {'from': frame['runtime']['state'], 'event': item['event'], 'to': item['next']['state']}
1910
- if item['next']['state'] == target:
2081
+ if item['next']['state'] in targets:
1911
2082
  continue
1912
2083
  key = _runtime_key(item['next'])
1913
2084
  seen = color.get(key)
1914
2085
  if seen == GRAY:
1915
- return {'path': frame['path'] + [step], 'reason': 'Cycle avoids target ' + target}
2086
+ return {'path': frame['path'] + [step], 'reason': 'Cycle avoids target ' + label}
1916
2087
  if seen == BLACK:
1917
2088
  continue
1918
2089
  color[key] = GRAY
@@ -1926,10 +2097,12 @@ def A9_leads_to(model, exploration):
1926
2097
  for invariant in model.get('invariants') or []:
1927
2098
  if invariant['kind'] != 'leads-to':
1928
2099
  continue
2100
+ targets = set(_leads_to_targets(invariant))
2101
+ label = _leads_to_target_label(invariant)
1929
2102
  for runtime in exploration['reachable']:
1930
2103
  if runtime['state'] != invariant['from']:
1931
2104
  continue
1932
- bad = _find_leads_to_bad_path(model, runtime, invariant['to'])
2105
+ bad = _find_leads_to_bad_path(model, runtime, targets, label)
1933
2106
  if bad is not None:
1934
2107
  findings.append({
1935
2108
  'code': 'A9_LEADS_TO_VIOLATION',
@@ -2309,7 +2482,6 @@ def _compute_hit_probability(model, max_states, target_state):
2309
2482
 
2310
2483
  def A13_probability(model, max_states):
2311
2484
  findings = []
2312
- eps = 1e-9
2313
2485
  for invariant in model.get('invariants') or []:
2314
2486
  if invariant['kind'] != 'probability':
2315
2487
  continue
@@ -2318,16 +2490,7 @@ def A13_probability(model, max_states):
2318
2490
  converged = result['converged']
2319
2491
  op = invariant['op']
2320
2492
  p_bound = invariant['p']
2321
- violated = False
2322
- if op == '>=':
2323
- violated = probability < p_bound - eps
2324
- elif op == '>':
2325
- violated = probability <= p_bound + eps
2326
- elif op == '<=':
2327
- violated = probability > p_bound + eps
2328
- else:
2329
- violated = probability >= p_bound - eps
2330
- if violated:
2493
+ if _probability_violated(invariant, probability):
2331
2494
  findings.append({
2332
2495
  'code': 'A13_PROBABILITY_VIOLATION',
2333
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',