dsh-logicprobe 0.5.6 → 0.6.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.
package/lib/engine.js CHANGED
@@ -45,6 +45,20 @@ export function validateModel(input) {
45
45
  seen.add(state.id);
46
46
  if (state.terminal !== undefined && typeof state.terminal !== 'boolean')
47
47
  bad('states[' + index + '].terminal', 'must be a boolean');
48
+ for (const kind of ['onEntry', 'onExit']) {
49
+ const actions = state[kind];
50
+ if (actions !== undefined) {
51
+ if (!Array.isArray(actions))
52
+ bad('states[' + index + '].' + kind, 'must be an array of action names');
53
+ else if (actions.length > 64)
54
+ bad('states[' + index + '].' + kind, 'must not exceed 64 actions');
55
+ else
56
+ actions.forEach((action, actionIndex) => {
57
+ if (typeof action !== 'string' || action.length === 0)
58
+ bad('states[' + index + '].' + kind + '[' + actionIndex + ']', 'must be a non-empty string');
59
+ });
60
+ }
61
+ }
48
62
  });
49
63
  if (typeof root.init === 'string' && root.init.length > 0 && !seen.has(root.init))
50
64
  bad('init', 'must name a declared state');
@@ -92,6 +106,21 @@ export function validateModel(input) {
92
106
  bad(updatePath + '.value', 'must be a number');
93
107
  });
94
108
  }
109
+ if (transition.cost !== undefined) {
110
+ if (typeof transition.cost !== 'number' || !Number.isFinite(transition.cost) || transition.cost < 0)
111
+ bad(path + '.cost', 'must be a non-negative finite number');
112
+ }
113
+ if (transition.weight !== undefined) {
114
+ if (typeof transition.weight !== 'number' || !Number.isFinite(transition.weight) || transition.weight < 0)
115
+ bad(path + '.weight', 'must be a non-negative finite number');
116
+ }
117
+ });
118
+ }
119
+ if (Array.isArray(root.states)) {
120
+ root.states.forEach((state, index) => {
121
+ if (state.maxTicks !== undefined && (!Number.isInteger(state.maxTicks) || state.maxTicks < 0)) {
122
+ bad('states[' + index + '].maxTicks', 'must be a non-negative integer');
123
+ }
95
124
  });
96
125
  }
97
126
  const variableNames = new Set();
@@ -204,6 +233,18 @@ export function validateModel(input) {
204
233
  if (invariant.rollback !== undefined && typeof invariant.rollback !== 'string')
205
234
  bad(path + '.rollback', 'must be a string');
206
235
  }
236
+ else if (invariant.kind === 'budget') {
237
+ if (typeof invariant.budget !== 'number' || !Number.isFinite(invariant.budget) || invariant.budget < 0)
238
+ bad(path + '.budget', 'must be a non-negative finite number');
239
+ }
240
+ else if (invariant.kind === 'probability') {
241
+ if (typeof invariant.target !== 'string' || invariant.target.length === 0)
242
+ bad(path + '.target', 'must be a non-empty string');
243
+ if (invariant.op !== '>=' && invariant.op !== '<=' && invariant.op !== '>' && invariant.op !== '<')
244
+ bad(path + '.op', "must be one of '>=', '<=', '>', '<'");
245
+ if (typeof invariant.p !== 'number' || !Number.isFinite(invariant.p) || invariant.p < 0 || invariant.p > 1)
246
+ bad(path + '.p', 'must be a number in [0, 1]');
247
+ }
207
248
  else {
208
249
  bad(path + '.kind', 'unknown invariant kind');
209
250
  }
@@ -245,6 +286,15 @@ export function validateModel(input) {
245
286
  bad('idempotentEvents[' + index + ']', 'must be a non-empty string');
246
287
  });
247
288
  }
289
+ if (root.tickEvents !== undefined) {
290
+ if (!Array.isArray(root.tickEvents))
291
+ bad('tickEvents', 'must be an array');
292
+ else
293
+ root.tickEvents.forEach((entry, index) => {
294
+ if (typeof entry !== 'string' || entry.length === 0)
295
+ bad('tickEvents[' + index + ']', 'must be a non-empty string');
296
+ });
297
+ }
248
298
  if (root.resourcePairs !== undefined) {
249
299
  if (!Array.isArray(root.resourcePairs))
250
300
  bad('resourcePairs', 'must be an array');
@@ -985,15 +1035,57 @@ function A3_orderPermutation(model, options) {
985
1035
  }
986
1036
  return checkResult('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + events.length + ' events)');
987
1037
  }
1038
+ function stateActionList(model, stateId, kind) {
1039
+ const state = model.states.find((entry) => entry.id === stateId);
1040
+ const list = state === undefined ? undefined : state[kind];
1041
+ return list ?? [];
1042
+ }
1043
+ function actionsDeclared(model) {
1044
+ return model.states.some((state) => (state.onEntry ?? []).length > 0 || (state.onExit ?? []).length > 0);
1045
+ }
1046
+ /** Apply an ordered action list to the held flag. Actions never change state or variables; they only matter to pairing checks. */
1047
+ function applyActionList(list, acquireEvent, releaseEvent, held) {
1048
+ let next = held;
1049
+ let reacquired = false;
1050
+ for (const action of list) {
1051
+ if (action === acquireEvent) {
1052
+ if (next)
1053
+ reacquired = true;
1054
+ next = true;
1055
+ }
1056
+ else if (action === releaseEvent) {
1057
+ next = false;
1058
+ }
1059
+ }
1060
+ return { held: next, reacquired };
1061
+ }
988
1062
  function A4_pairSymmetry(model) {
989
1063
  const findings = [];
1064
+ const hasActions = actionsDeclared(model);
990
1065
  const transitionEvents = new Set(model.transitions.map((transition) => transition.event));
1066
+ const actionEvents = new Set();
1067
+ if (hasActions) {
1068
+ for (const state of model.states) {
1069
+ for (const action of stateActionList(model, state.id, 'onEntry'))
1070
+ actionEvents.add(action);
1071
+ for (const action of stateActionList(model, state.id, 'onExit'))
1072
+ actionEvents.add(action);
1073
+ }
1074
+ }
1075
+ const pairHasEvent = (event) => transitionEvents.has(event) || actionEvents.has(event);
1076
+ const releaseReachableFrom = (stateId, releaseEvent) => {
1077
+ const reachable = stateGraphReachable(model, stateId);
1078
+ return [...reachable].some((state) => model.transitions.some((transition) => transition.from === state && transition.event === releaseEvent)
1079
+ || (hasActions && (stateActionList(model, state, 'onEntry').includes(releaseEvent) || stateActionList(model, state, 'onExit').includes(releaseEvent))));
1080
+ };
1081
+ const edges = stateGraphEdges(model);
991
1082
  for (const pair of model.resourcePairs ?? []) {
992
1083
  const acquireTransitions = model.transitions.filter((transition) => transition.event === pair.acquireEvent);
993
- const releaseExists = transitionEvents.has(pair.releaseEvent);
994
- if (acquireTransitions.length === 0 && !releaseExists)
1084
+ const actionAcquire = hasActions && model.states.some((state) => stateActionList(model, state.id, 'onEntry').includes(pair.acquireEvent) || stateActionList(model, state.id, 'onExit').includes(pair.acquireEvent));
1085
+ const releaseExists = pairHasEvent(pair.releaseEvent);
1086
+ if (acquireTransitions.length === 0 && !actionAcquire && !releaseExists)
995
1087
  continue;
996
- if (acquireTransitions.length > 0 && !releaseExists) {
1088
+ if ((acquireTransitions.length > 0 || actionAcquire) && !releaseExists) {
997
1089
  findings.push({
998
1090
  code: 'A4_NO_RELEASE_EVENT',
999
1091
  severity: 'error',
@@ -1001,23 +1093,57 @@ function A4_pairSymmetry(model) {
1001
1093
  });
1002
1094
  continue;
1003
1095
  }
1096
+ // Collect acquire sites: transitions whose event acquires, and state entry/exit actions that end holding the resource.
1097
+ const seeds = [];
1004
1098
  for (const acquire of acquireTransitions) {
1005
- // Is any release event reachable in the state graph from the acquired state?
1006
- const reachable = stateGraphReachable(model, acquire.to);
1007
- const releaseReachable = [...reachable].some((state) => model.transitions.some((transition) => transition.from === state && transition.event === pair.releaseEvent));
1008
- if (!releaseReachable) {
1099
+ if (!releaseReachableFrom(acquire.to, pair.releaseEvent)) {
1009
1100
  findings.push({
1010
1101
  code: 'A4_NO_RELEASE_REACHABLE',
1011
1102
  severity: 'error',
1012
- message: 'Resource "' + pair.resource + '": after ' + pair.acquireEvent + ' into ' + acquire.to + ', no ' + pair.releaseEvent + ' transition is reachable.',
1103
+ message: 'Resource "' + pair.resource + '": after ' + pair.acquireEvent + ' into ' + acquire.to + ', no ' + pair.releaseEvent + ' is reachable.',
1013
1104
  evidence: { acquireTransition: acquire },
1014
1105
  });
1015
1106
  continue;
1016
1107
  }
1017
- // Path-sensitive probe: track the held flag along state-graph paths.
1018
- const edges = stateGraphEdges(model);
1108
+ seeds.push({ state: acquire.to, held: true, path: [] });
1109
+ }
1110
+ if (hasActions) {
1111
+ for (const state of model.states) {
1112
+ // acquire fired by onEntry: resource is held for the whole stay in the state
1113
+ const entry = stateActionList(model, state.id, 'onEntry');
1114
+ if (entry.includes(pair.acquireEvent)) {
1115
+ const sim = applyActionList(entry, pair.acquireEvent, pair.releaseEvent, false);
1116
+ if (sim.reacquired) {
1117
+ findings.push({
1118
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1119
+ severity: 'warning',
1120
+ message: 'Resource "' + pair.resource + '" is acquired more than once inside onEntry of ' + state.id + ' before ' + pair.releaseEvent + '.',
1121
+ });
1122
+ }
1123
+ if (sim.held)
1124
+ seeds.push({ state: state.id, held: true, path: [] });
1125
+ }
1126
+ // acquire fired by onExit: happens on every outgoing edge, never on a terminal state
1127
+ const exit = stateActionList(model, state.id, 'onExit');
1128
+ if (exit.includes(pair.acquireEvent) && !state.terminal) {
1129
+ const sim = applyActionList(exit, pair.acquireEvent, pair.releaseEvent, false);
1130
+ if (sim.reacquired) {
1131
+ findings.push({
1132
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1133
+ severity: 'warning',
1134
+ message: 'Resource "' + pair.resource + '" is acquired more than once inside onExit of ' + state.id + ' before ' + pair.releaseEvent + '.',
1135
+ });
1136
+ }
1137
+ if (sim.held)
1138
+ seeds.push({ state: state.id, held: false, path: [] });
1139
+ }
1140
+ }
1141
+ }
1142
+ // Path-sensitive probe: track the held flag along state-graph paths, firing
1143
+ // onExit of the source state before each transition and onEntry of the target after it.
1144
+ for (const seed of seeds) {
1019
1145
  const visited = new Set();
1020
- const queue = [{ state: acquire.to, held: true, path: [] }];
1146
+ const queue = [seed];
1021
1147
  let steps = 0;
1022
1148
  while (queue.length > 0) {
1023
1149
  const entry = queue.shift();
@@ -1038,6 +1164,19 @@ function A4_pairSymmetry(model) {
1038
1164
  }
1039
1165
  for (const transition of edges.get(entry.state) ?? []) {
1040
1166
  let held = entry.held;
1167
+ if (hasActions) {
1168
+ const sim = applyActionList(stateActionList(model, entry.state, 'onExit'), pair.acquireEvent, pair.releaseEvent, held);
1169
+ if (sim.reacquired) {
1170
+ findings.push({
1171
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1172
+ severity: 'warning',
1173
+ message: 'Resource "' + pair.resource + '" is acquired again in onExit of ' + entry.state + ' before ' + pair.releaseEvent + '.',
1174
+ path: entry.path,
1175
+ });
1176
+ continue;
1177
+ }
1178
+ held = sim.held;
1179
+ }
1041
1180
  if (transition.event === pair.releaseEvent)
1042
1181
  held = false;
1043
1182
  else if (transition.event === pair.acquireEvent) {
@@ -1052,6 +1191,19 @@ function A4_pairSymmetry(model) {
1052
1191
  }
1053
1192
  held = true;
1054
1193
  }
1194
+ if (hasActions) {
1195
+ const sim = applyActionList(stateActionList(model, transition.to, 'onEntry'), pair.acquireEvent, pair.releaseEvent, held);
1196
+ if (sim.reacquired) {
1197
+ findings.push({
1198
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1199
+ severity: 'warning',
1200
+ message: 'Resource "' + pair.resource + '" is acquired again inside onEntry of ' + transition.to + ' before ' + pair.releaseEvent + '.',
1201
+ path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
1202
+ });
1203
+ continue;
1204
+ }
1205
+ held = sim.held;
1206
+ }
1055
1207
  queue.push({ state: transition.to, held, path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }] });
1056
1208
  }
1057
1209
  }
@@ -1599,6 +1751,442 @@ function A11_atomicity(model, options) {
1599
1751
  return checkResult('A11', 'Atomicity', findings, findings.length === 0 ? 'All atomicity invariants hold' : 'Atomicity findings: ' + findings.length);
1600
1752
  }
1601
1753
  // ---------------------------------------------------------------------------
1754
+ // A12 — Budget (worst-case path cost)
1755
+ // ---------------------------------------------------------------------------
1756
+ function transitionCost(transition) {
1757
+ return transition.cost ?? 1;
1758
+ }
1759
+ function runtimeGraph(model, options) {
1760
+ const groupMap = groupTransitions(model);
1761
+ const exploration = explore(model, options);
1762
+ const edges = new Map();
1763
+ for (const runtime of exploration.reachable) {
1764
+ const out = [];
1765
+ for (const event of allEvents(model)) {
1766
+ const group = groupMap.get(runtime.state + '|' + event);
1767
+ if (group === undefined || group.length === 0)
1768
+ continue;
1769
+ for (const transition of applicableTransitions(group, runtime)) {
1770
+ const next = applyUpdates(model, transition, runtime);
1771
+ out.push({
1772
+ to: runtimeKey(next),
1773
+ step: { from: runtime.state, event: transition.event, to: next.state },
1774
+ cost: transitionCost(transition),
1775
+ });
1776
+ }
1777
+ }
1778
+ edges.set(runtimeKey(runtime), out);
1779
+ }
1780
+ return {
1781
+ edges,
1782
+ keys: exploration.reachable.map((runtime) => runtimeKey(runtime)),
1783
+ initKey: runtimeKey(exploration.initialState),
1784
+ };
1785
+ }
1786
+ /** Shortest path from init to targetKey over the runtime graph, or undefined when unreachable. */
1787
+ function runtimePathToKey(graph, targetKey) {
1788
+ const parent = new Map();
1789
+ const visited = new Set([graph.initKey]);
1790
+ const queue = [graph.initKey];
1791
+ while (queue.length > 0) {
1792
+ const current = queue.shift();
1793
+ if (current === targetKey) {
1794
+ const steps = [];
1795
+ let key = targetKey;
1796
+ while (key !== graph.initKey) {
1797
+ const step = parent.get(key);
1798
+ if (step === undefined)
1799
+ break;
1800
+ steps.unshift(step);
1801
+ key = step.from === undefined ? graph.initKey : keyOfFromStep(graph, step, key);
1802
+ }
1803
+ return steps;
1804
+ }
1805
+ for (const edge of graph.edges.get(current) ?? []) {
1806
+ if (visited.has(edge.to))
1807
+ continue;
1808
+ visited.add(edge.to);
1809
+ parent.set(edge.to, edge.step);
1810
+ queue.push(edge.to);
1811
+ }
1812
+ }
1813
+ return undefined;
1814
+ }
1815
+ function keyOfFromStep(graph, step, targetKey) {
1816
+ // Reconstruct the source key by scanning predecessors of targetKey whose step matches.
1817
+ for (const [from, edges] of graph.edges) {
1818
+ for (const edge of edges) {
1819
+ if (edge.to === targetKey && edge.step.event === step.event && edge.step.from === step.from && edge.step.to === step.to) {
1820
+ return from;
1821
+ }
1822
+ }
1823
+ }
1824
+ return graph.initKey;
1825
+ }
1826
+ /** Find a reachable cycle whose total cost is positive; return a witness path init -> one full round. */
1827
+ function findUnboundedCycle(model, options) {
1828
+ const graph = runtimeGraph(model, options);
1829
+ const color = new Map();
1830
+ for (const start of graph.keys) {
1831
+ if (color.has(start))
1832
+ continue;
1833
+ const nodeStack = [start];
1834
+ const idxStack = [0];
1835
+ const depthOf = new Map([[start, 0]]);
1836
+ const costAt = new Map([[start, 0]]);
1837
+ const prevKey = new Map();
1838
+ const prevStep = new Map();
1839
+ color.set(start, 1);
1840
+ while (nodeStack.length > 0) {
1841
+ const node = nodeStack[nodeStack.length - 1];
1842
+ const outs = graph.edges.get(node) ?? [];
1843
+ if (idxStack[idxStack.length - 1] < outs.length) {
1844
+ const edge = outs[idxStack[idxStack.length - 1]++];
1845
+ const seen = color.get(edge.to);
1846
+ if (seen === undefined) {
1847
+ color.set(edge.to, 1);
1848
+ depthOf.set(edge.to, depthOf.get(node) + 1);
1849
+ costAt.set(edge.to, costAt.get(node) + edge.cost);
1850
+ prevKey.set(edge.to, node);
1851
+ prevStep.set(edge.to, edge.step);
1852
+ nodeStack.push(edge.to);
1853
+ idxStack.push(0);
1854
+ }
1855
+ else if (seen === 1) {
1856
+ // Back edge on the current path: this is a true cycle.
1857
+ const cycleCost = costAt.get(node) + edge.cost - costAt.get(edge.to);
1858
+ if (cycleCost > 0) {
1859
+ const entry = runtimePathToKey(graph, edge.to) ?? [];
1860
+ const round = [];
1861
+ let cursor = node;
1862
+ while (cursor !== edge.to && prevStep.has(cursor)) {
1863
+ round.unshift(prevStep.get(cursor));
1864
+ cursor = prevKey.get(cursor);
1865
+ }
1866
+ round.push(edge.step);
1867
+ return [...entry, ...round];
1868
+ }
1869
+ }
1870
+ }
1871
+ else {
1872
+ color.set(node, 2);
1873
+ nodeStack.pop();
1874
+ idxStack.pop();
1875
+ depthOf.delete(node);
1876
+ costAt.delete(node);
1877
+ prevKey.delete(node);
1878
+ prevStep.delete(node);
1879
+ }
1880
+ }
1881
+ }
1882
+ return undefined;
1883
+ }
1884
+ function findBudgetViolation(model, options, invariant) {
1885
+ const groupMap = groupTransitions(model);
1886
+ const init = initialState(model);
1887
+ const bestCost = new Map([[runtimeKey(init), 0]]);
1888
+ const queue = [{ runtime: init, cost: 0, path: [] }];
1889
+ let steps = 0;
1890
+ while (queue.length > 0) {
1891
+ const entry = queue.shift();
1892
+ if (++steps > options.maxStates)
1893
+ break;
1894
+ for (const event of allEvents(model)) {
1895
+ const group = groupMap.get(entry.runtime.state + '|' + event);
1896
+ if (group === undefined || group.length === 0)
1897
+ continue;
1898
+ for (const transition of applicableTransitions(group, entry.runtime)) {
1899
+ const next = applyUpdates(model, transition, entry.runtime);
1900
+ const cost = entry.cost + transitionCost(transition);
1901
+ const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }];
1902
+ // Costs are non-negative, so the first step that pushes the accumulated cost
1903
+ // over the budget is already a witness.
1904
+ if (cost > invariant.budget)
1905
+ return { path, cost, unbounded: false };
1906
+ const key = runtimeKey(next);
1907
+ const best = bestCost.get(key);
1908
+ if (best === undefined || cost < best) {
1909
+ bestCost.set(key, cost);
1910
+ queue.push({ runtime: next, cost, path });
1911
+ }
1912
+ }
1913
+ }
1914
+ }
1915
+ // No finite path exceeds the budget within reachable space; a reachable positive-cost
1916
+ // cycle still makes the worst-case unbounded. Plain DAG merges are not cycles.
1917
+ const cycle = findUnboundedCycle(model, options);
1918
+ if (cycle !== undefined)
1919
+ return { path: cycle, unbounded: true };
1920
+ return undefined;
1921
+ }
1922
+ function A12_budget(model, options) {
1923
+ const budgets = (model.invariants ?? []).filter((invariant) => invariant.kind === 'budget');
1924
+ const usesCost = model.transitions.some((transition) => transition.cost !== undefined);
1925
+ const findings = [];
1926
+ if (usesCost && budgets.length === 0) {
1927
+ findings.push({
1928
+ code: 'A12_COST_WITHOUT_BUDGET',
1929
+ severity: 'warning',
1930
+ message: 'Transitions declare cost, but no budget invariant is declared, so worst-case path cost is not verified. Add an invariant of kind budget to check it.',
1931
+ });
1932
+ }
1933
+ for (const invariant of budgets) {
1934
+ const violation = findBudgetViolation(model, options, invariant);
1935
+ if (violation !== undefined) {
1936
+ findings.push({
1937
+ code: 'A12_BUDGET_OVER',
1938
+ severity: 'error',
1939
+ message: violation.unbounded
1940
+ ? 'Budget invariant "' + invariant.id + '" (' + invariant.description + ') exceeded: a reachable positive-cost cycle lets path cost grow without bound, so no finite budget ' + invariant.budget + ' holds.'
1941
+ : 'Budget invariant "' + invariant.id + '" (' + invariant.description + ') exceeded: worst-case path cost ' + violation.cost + ' is over budget ' + invariant.budget + '.',
1942
+ path: violation.path,
1943
+ evidence: violation.unbounded ? { invariant, unbounded: true } : { invariant, totalCost: violation.cost, unbounded: false },
1944
+ });
1945
+ }
1946
+ }
1947
+ return checkResult('A12', 'Budget', findings, findings.length === 0 ? (budgets.length === 0 ? 'No budget invariants declared' : 'All budget invariants hold') : 'Budget findings: ' + findings.length);
1948
+ }
1949
+ // ---------------------------------------------------------------------------
1950
+ // A13 — Probability reachability (DTMC)
1951
+ // ---------------------------------------------------------------------------
1952
+ function probabilityOutcomes(model, runtime) {
1953
+ const groupMap = groupTransitions(model);
1954
+ const outcomes = new Map();
1955
+ for (const event of allEvents(model)) {
1956
+ const group = groupMap.get(runtime.state + '|' + event);
1957
+ if (group === undefined || group.length === 0)
1958
+ continue;
1959
+ for (const transition of applicableTransitions(group, runtime)) {
1960
+ const weight = transition.weight ?? 1;
1961
+ if (!(weight > 0))
1962
+ continue;
1963
+ const next = applyUpdates(model, transition, runtime);
1964
+ const key = runtimeKey(next);
1965
+ const current = outcomes.get(key);
1966
+ if (current === undefined)
1967
+ outcomes.set(key, { state: next.state, weight });
1968
+ else
1969
+ current.weight += weight;
1970
+ }
1971
+ }
1972
+ return outcomes;
1973
+ }
1974
+ /**
1975
+ * Probability of ever reaching targetState from the initial state under the DTMC
1976
+ * interpretation: at each runtime state every applicable transition with weight > 0 is an
1977
+ * outcome chosen proportionally to its weight. Solved by value iteration (least fixed
1978
+ * point of the absorbing chain).
1979
+ */
1980
+ function computeHitProbability(model, options, targetState) {
1981
+ const exploration = explore(model, options);
1982
+ const reachable = exploration.reachable;
1983
+ const n = reachable.length;
1984
+ const keyIndex = new Map();
1985
+ reachable.forEach((runtime, i) => keyIndex.set(runtimeKey(runtime), i));
1986
+ const fixedValue = new Array(n).fill(null);
1987
+ const chains = [];
1988
+ for (let i = 0; i < n; i++) {
1989
+ const runtime = reachable[i];
1990
+ if (runtime.state === targetState) {
1991
+ fixedValue[i] = 1;
1992
+ chains.push([]);
1993
+ continue;
1994
+ }
1995
+ const outs = probabilityOutcomes(model, runtime);
1996
+ let sum = 0;
1997
+ const list = [];
1998
+ for (const [key, entry] of outs) {
1999
+ const j = keyIndex.get(key);
2000
+ if (j === undefined)
2001
+ continue;
2002
+ list.push({ j, w: entry.weight });
2003
+ sum += entry.weight;
2004
+ }
2005
+ if (sum <= 0) {
2006
+ fixedValue[i] = 0;
2007
+ chains.push([]);
2008
+ continue;
2009
+ }
2010
+ chains.push(list.map((item) => ({ j: item.j, w: item.w / sum })));
2011
+ }
2012
+ const p = new Array(n).fill(0);
2013
+ let converged = false;
2014
+ for (let iter = 0; iter < 20000; iter++) {
2015
+ let maxDelta = 0;
2016
+ for (let i = 0; i < n; i++) {
2017
+ if (fixedValue[i] !== null)
2018
+ continue;
2019
+ const list = chains[i];
2020
+ let acc = 0;
2021
+ for (const item of list) {
2022
+ const fixed = fixedValue[item.j];
2023
+ acc += item.w * (fixed === null ? p[item.j] : fixed);
2024
+ }
2025
+ const delta = Math.abs(acc - p[i]);
2026
+ if (delta > maxDelta)
2027
+ maxDelta = delta;
2028
+ p[i] = acc;
2029
+ }
2030
+ if (maxDelta < 1e-9) {
2031
+ converged = true;
2032
+ break;
2033
+ }
2034
+ }
2035
+ const initIndex = keyIndex.get(runtimeKey(exploration.initialState)) ?? 0;
2036
+ const fixed = fixedValue[initIndex];
2037
+ return { probability: fixed === null ? p[initIndex] : fixed, converged };
2038
+ }
2039
+ function A13_probability(model, options) {
2040
+ const findings = [];
2041
+ const eps = 1e-9;
2042
+ for (const invariant of model.invariants ?? []) {
2043
+ if (invariant.kind !== 'probability')
2044
+ continue;
2045
+ const { probability, converged } = computeHitProbability(model, options, invariant.target);
2046
+ let violated = false;
2047
+ if (invariant.op === '>=')
2048
+ violated = probability < invariant.p - eps;
2049
+ else if (invariant.op === '>')
2050
+ violated = probability <= invariant.p + eps;
2051
+ else if (invariant.op === '<=')
2052
+ violated = probability > invariant.p + eps;
2053
+ else
2054
+ violated = probability >= invariant.p - eps;
2055
+ if (violated) {
2056
+ findings.push({
2057
+ code: 'A13_PROBABILITY_VIOLATION',
2058
+ severity: 'error',
2059
+ message: 'Probability invariant "' + invariant.id + '" (' + invariant.description + ') violated: P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + invariant.p + '.',
2060
+ evidence: { invariant, computed: probability, converged },
2061
+ });
2062
+ }
2063
+ else if (!converged) {
2064
+ findings.push({
2065
+ code: 'A13_NO_CONVERGENCE',
2066
+ severity: 'warning',
2067
+ message: 'Probability invariant "' + invariant.id + '" passes, but value iteration did not fully converge within the iteration cap.',
2068
+ evidence: { invariant, computed: probability, converged },
2069
+ });
2070
+ }
2071
+ }
2072
+ return checkResult('A13', 'Probability Reachability', findings, findings.length === 0 ? 'No probability invariants declared or all hold' : 'Probability findings: ' + findings.length);
2073
+ }
2074
+ // ---------------------------------------------------------------------------
2075
+ // A14 — Deadline (discrete tick clock)
2076
+ // ---------------------------------------------------------------------------
2077
+ function A14_deadline(model, options) {
2078
+ const findings = [];
2079
+ const tickEvents = new Set(model.tickEvents ?? []);
2080
+ const limits = new Map();
2081
+ let hasLimit = false;
2082
+ for (const state of model.states) {
2083
+ if (state.maxTicks !== undefined) {
2084
+ limits.set(state.id, state.maxTicks);
2085
+ hasLimit = true;
2086
+ }
2087
+ }
2088
+ if (!hasLimit) {
2089
+ return checkResult('A14', 'Deadline', [], 'No state declares maxTicks');
2090
+ }
2091
+ if (tickEvents.size === 0) {
2092
+ return checkResult('A14', 'Deadline', [{
2093
+ code: 'A14_NO_TICK_EVENTS',
2094
+ severity: 'warning',
2095
+ message: 'States declare maxTicks, but no tickEvents are declared, so deadline compliance cannot be verified.',
2096
+ }], 'Missing tick events');
2097
+ }
2098
+ const groupMap = groupTransitions(model);
2099
+ const init = initialState(model);
2100
+ const limitOf = (stateId) => limits.get(stateId);
2101
+ const queue = [{ runtime: init, ticks: 0, path: [] }];
2102
+ const seen = new Set();
2103
+ let steps = 0;
2104
+ while (queue.length > 0) {
2105
+ const entry = queue.shift();
2106
+ if (++steps > options.maxStates)
2107
+ break;
2108
+ const key = runtimeKey(entry.runtime) + '|' + entry.ticks;
2109
+ if (seen.has(key))
2110
+ continue;
2111
+ seen.add(key);
2112
+ for (const event of allEvents(model)) {
2113
+ const group = groupMap.get(entry.runtime.state + '|' + event);
2114
+ if (group === undefined || group.length === 0)
2115
+ continue;
2116
+ for (const transition of applicableTransitions(group, entry.runtime)) {
2117
+ const next = applyUpdates(model, transition, entry.runtime);
2118
+ const isTick = tickEvents.has(event);
2119
+ const stays = next.state === entry.runtime.state;
2120
+ const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }];
2121
+ let ticks;
2122
+ if (isTick && stays) {
2123
+ const lim = limitOf(next.state);
2124
+ if (lim !== undefined && entry.ticks + 1 > lim) {
2125
+ findings.push({
2126
+ code: 'A14_DEADLINE_MISS',
2127
+ severity: 'error',
2128
+ message: 'Deadline missed: state ' + next.state + ' can remain resident for more than ' + lim + ' tick(s).',
2129
+ path,
2130
+ evidence: { state: next.state, maxTicks: lim, ticks: entry.ticks + 1 },
2131
+ });
2132
+ continue;
2133
+ }
2134
+ ticks = entry.ticks + 1;
2135
+ }
2136
+ else if (isTick && !stays) {
2137
+ ticks = 0;
2138
+ }
2139
+ else if (!isTick && stays) {
2140
+ ticks = entry.ticks;
2141
+ }
2142
+ else {
2143
+ ticks = 0;
2144
+ }
2145
+ const nextKey = runtimeKey(next) + '|' + ticks;
2146
+ if (!seen.has(nextKey))
2147
+ queue.push({ runtime: next, ticks, path });
2148
+ }
2149
+ }
2150
+ }
2151
+ return checkResult('A14', 'Deadline', findings, findings.length === 0 ? 'All deadlines respected' : 'Deadline findings: ' + findings.length);
2152
+ }
2153
+ // ---------------------------------------------------------------------------
2154
+ // Coverage notes (informational gap notices)
2155
+ // ---------------------------------------------------------------------------
2156
+ // Underscore-separated identifiers like watchdog_timeout or isr_cmd are common in
2157
+ // state machines, so boundaries must treat '_' as a separator rather than a word char.
2158
+ const TIMING_VOCABULARY = /(?<![a-z0-9])(timeout|watchdog|timer|tick|deadline|period|delay|elapsed|latency)(?![a-z0-9])/i;
2159
+ const PREEMPTION_VOCABULARY = /(?<![a-z0-9])(isr|irq|interrupt|task|thread|preempt|rtos)(?![a-z0-9])/i;
2160
+ const HYBRID_VOCABULARY = /(?<![a-z0-9])(pid|plant|feedback|control.?loop|stability|stable|settling|damping|oscillat|chatter|kalman|foc|field.?weaken|motor|torque)(?![a-z0-9])/i;
2161
+ const PROBABILISTIC_VOCABULARY = /(?<![a-z0-9])(mtbf|mttf|failure.?rate|reliability|probability|probabilistic|markov|stochastic|fault.?tree|fmea)(?![a-z0-9])/i;
2162
+ function computeCoverageNotes(model) {
2163
+ const names = [];
2164
+ for (const state of model.states) {
2165
+ names.push(state.id);
2166
+ for (const action of state.onEntry ?? [])
2167
+ names.push(action);
2168
+ for (const action of state.onExit ?? [])
2169
+ names.push(action);
2170
+ }
2171
+ for (const transition of model.transitions)
2172
+ names.push(transition.event);
2173
+ const corpus = names.join(' ');
2174
+ const notes = [];
2175
+ if (TIMING_VOCABULARY.test(corpus)) {
2176
+ notes.push('The model references time-like vocabulary (timeout/watchdog/timer/deadline...). logicprobe verifies ordering, counts, and path budgets (A12) but not hard real-time semantics: deadlines, periods, and clock invariants need a timed model checker (e.g. UPPAAL).');
2177
+ }
2178
+ if (PREEMPTION_VOCABULARY.test(corpus)) {
2179
+ notes.push('The model references preemption/concurrency vocabulary (ISR/IRQ/task/interrupt...). logicprobe models event-order interleavings (A2/A3) but not preemptive concurrency; absolute claims such as thread-safe or interrupt-safe need dedicated verification (TSan, CBMC, or a model checker such as TLA+).');
2180
+ }
2181
+ if (HYBRID_VOCABULARY.test(corpus)) {
2182
+ notes.push('The model references control/hybrid vocabulary (pid/plant/feedback/stability/motor...). logicprobe verifies discrete transitions only; stability, settling time, and mode-switch dynamics over a continuous plant need hybrid verification (SpaceEx, Flow*, or Simulink/Stateflow analysis).');
2183
+ }
2184
+ if (PROBABILISTIC_VOCABULARY.test(corpus)) {
2185
+ notes.push('The model references probabilistic/reliability vocabulary (mtbf/failure rate/probability...). logicprobe is a qualitative model checker; reliability or probability claims need a stochastic model checker (PRISM, Storm) or fault-tree analysis.');
2186
+ }
2187
+ return notes;
2188
+ }
2189
+ // ---------------------------------------------------------------------------
1602
2190
  // main entry
1603
2191
  // ---------------------------------------------------------------------------
1604
2192
  export function runVerification(input, options = {}) {
@@ -1644,6 +2232,9 @@ export function runVerification(input, options = {}) {
1644
2232
  A9_leadsTo(model, exploration),
1645
2233
  A10_sequenceOrder(model, normalized),
1646
2234
  A11_atomicity(model, normalized),
2235
+ A12_budget(model, normalized),
2236
+ A13_probability(model, normalized),
2237
+ A14_deadline(model, normalized),
1647
2238
  ];
1648
2239
  let comparison;
1649
2240
  if (options.beforeModel !== undefined) {
@@ -1680,6 +2271,7 @@ export function runVerification(input, options = {}) {
1680
2271
  }
1681
2272
  const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
1682
2273
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
2274
+ const coverageNotes = computeCoverageNotes(model);
1683
2275
  return {
1684
2276
  ok: true,
1685
2277
  schemaVersion: 1,
@@ -1695,5 +2287,163 @@ export function runVerification(input, options = {}) {
1695
2287
  checks,
1696
2288
  ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
1697
2289
  ...(comparison === undefined ? {} : { comparison }),
2290
+ ...(coverageNotes.length === 0 ? {} : { coverageNotes }),
2291
+ };
2292
+ }
2293
+ /**
2294
+ * N-machine composition semantics (documented in dsh-model-schema.md):
2295
+ * - a non-rendezvous event advances exactly ONE non-terminal machine that fires it;
2296
+ * - a rendezvous event (handshake) fires only when AT LEAST TWO machines declare it in
2297
+ * their alphabet and EVERY such non-terminal machine has it enabled (guards held);
2298
+ * all participants then advance simultaneously. A machine that is terminal, or whose
2299
+ * alphabet does not include the event, does not participate;
2300
+ * - a terminal machine is stopped and takes no further part.
2301
+ * Checks: C1 composition deadlock (reachable node where no machine can advance while at
2302
+ * least one is not terminal) and C2 rendezvous that can never fire.
2303
+ */
2304
+ export function runCompositionVerification(machinesInput, options = {}) {
2305
+ const models = [];
2306
+ const hashes = [];
2307
+ const modelFindings = [];
2308
+ machinesInput.forEach((input, index) => {
2309
+ const validation = validateModel(input);
2310
+ if (!validation.ok) {
2311
+ modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'machine ' + index + ' invalid: ' + validation.errors.join('; ') });
2312
+ }
2313
+ else {
2314
+ models.push(validation.model);
2315
+ hashes.push(modelHash(validation.model));
2316
+ }
2317
+ });
2318
+ const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }));
2319
+ if (modelFindings.length > 0 || models.length < 2) {
2320
+ if (models.length < 2 && modelFindings.length === 0) {
2321
+ modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'composition requires at least two machines' });
2322
+ }
2323
+ return {
2324
+ ok: false,
2325
+ summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
2326
+ checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
2327
+ };
2328
+ }
2329
+ const rendezvous = new Set(options.rendezvous ?? []);
2330
+ const maxStates = options.maxStates ?? DEFAULT_MAX_STATES;
2331
+ const machineEventSets = models.map((model) => new Set(model.transitions.map((transition) => transition.event)));
2332
+ const compositionMoves = (node) => {
2333
+ const moves = [];
2334
+ // single-machine (non-rendezvous) steps
2335
+ for (let i = 0; i < models.length; i++) {
2336
+ if (isTerminal(models[i], node.runtimes[i].state))
2337
+ continue;
2338
+ for (const event of allEvents(models[i])) {
2339
+ if (rendezvous.has(event))
2340
+ continue;
2341
+ for (const next of stepRuntime(models[i], node.runtimes[i], event)) {
2342
+ const runtimes = node.runtimes.slice();
2343
+ runtimes[i] = next;
2344
+ moves.push({ next: { runtimes, path: [] }, event, machines: [i] });
2345
+ }
2346
+ }
2347
+ }
2348
+ // synchronized rendezvous steps (>= 2 participants, all enabled)
2349
+ for (const event of rendezvous) {
2350
+ const participants = [];
2351
+ for (let i = 0; i < models.length; i++) {
2352
+ if (isTerminal(models[i], node.runtimes[i].state))
2353
+ continue;
2354
+ if (!machineEventSets[i].has(event))
2355
+ continue;
2356
+ participants.push(i);
2357
+ }
2358
+ if (participants.length < 2)
2359
+ continue; // handshake needs a partner
2360
+ const outcomes = participants.map((i) => stepRuntime(models[i], node.runtimes[i], event));
2361
+ if (outcomes.some((list) => list.length === 0))
2362
+ continue; // not all participants ready
2363
+ let combos = [node.runtimes.slice()];
2364
+ participants.forEach((i, slot) => {
2365
+ const nextCombos = [];
2366
+ for (const combo of combos) {
2367
+ for (const outcome of outcomes[slot]) {
2368
+ const copy = combo.slice();
2369
+ copy[i] = outcome;
2370
+ nextCombos.push(copy);
2371
+ }
2372
+ }
2373
+ combos = nextCombos;
2374
+ });
2375
+ for (const runtimes of combos) {
2376
+ moves.push({ next: { runtimes, path: [] }, event, machines: participants });
2377
+ }
2378
+ }
2379
+ return moves;
2380
+ };
2381
+ const keyOf = (node) => node.runtimes.map((runtime) => runtimeKey(runtime)).join('|');
2382
+ const init = { runtimes: models.map((model) => initialState(model)), path: [] };
2383
+ const visited = new Set();
2384
+ const queue = [init];
2385
+ let compositeStates = 0;
2386
+ let truncated = false;
2387
+ const firedCount = new Map();
2388
+ for (const event of rendezvous)
2389
+ firedCount.set(event, 0);
2390
+ const c1Findings = [];
2391
+ while (queue.length > 0) {
2392
+ const node = queue.shift();
2393
+ const key = keyOf(node);
2394
+ if (visited.has(key))
2395
+ continue;
2396
+ visited.add(key);
2397
+ compositeStates += 1;
2398
+ if (compositeStates > maxStates) {
2399
+ truncated = true;
2400
+ break;
2401
+ }
2402
+ const moves = compositionMoves(node);
2403
+ const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state));
2404
+ if (moves.length === 0 && !allTerminal) {
2405
+ c1Findings.push({
2406
+ code: 'C1_COMPOSITION_DEADLOCK',
2407
+ severity: 'error',
2408
+ message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
2409
+ evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state) },
2410
+ });
2411
+ continue;
2412
+ }
2413
+ for (const move of moves) {
2414
+ if (move.machines.length > 1)
2415
+ firedCount.set(move.event, (firedCount.get(move.event) ?? 0) + 1);
2416
+ const nextKey = keyOf(move.next);
2417
+ if (visited.has(nextKey))
2418
+ continue;
2419
+ queue.push({ runtimes: move.next.runtimes, path: [...node.path, { event: move.event, machines: move.machines }] });
2420
+ }
2421
+ }
2422
+ const c2Findings = [];
2423
+ const allEventsSet = new Set();
2424
+ for (const set of machineEventSets)
2425
+ for (const event of set)
2426
+ allEventsSet.add(event);
2427
+ for (const event of rendezvous) {
2428
+ if (!allEventsSet.has(event))
2429
+ continue;
2430
+ if ((firedCount.get(event) ?? 0) === 0) {
2431
+ c2Findings.push({
2432
+ code: 'C2_RENDEZVOUS_NEVER_FIRES',
2433
+ severity: 'warning',
2434
+ message: 'Rendezvous event ' + event + ' can never fire: fewer than two machines ever jointly enable it.',
2435
+ });
2436
+ }
2437
+ }
2438
+ const errors = c1Findings.length;
2439
+ const warnings = c2Findings.length;
2440
+ const checks = [
2441
+ checkResult('C1', 'Composition Deadlock', c1Findings, c1Findings.length === 0 ? 'No composition deadlock reachable' : 'Composition deadlocks: ' + c1Findings.length),
2442
+ checkResult('C2', 'Rendezvous Sync', c2Findings, c2Findings.length === 0 ? 'All rendezvous events can fire' : 'Rendezvous warnings: ' + c2Findings.length),
2443
+ ];
2444
+ return {
2445
+ ok: errors === 0,
2446
+ summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
2447
+ checks,
1698
2448
  };
1699
2449
  }