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/LICENSE +21 -21
- package/README.en-US.md +6 -3
- package/README.md +5 -2
- package/lib/compose-tool.js +43 -0
- package/lib/concurrency.js +11 -1
- package/lib/engine.js +761 -11
- package/lib/export-tool.js +45 -0
- package/lib/exporters.js +389 -0
- package/lib/index.js +32 -20
- package/lib/tool.js +6 -5
- package/lib/types/compose-tool.d.ts +6 -0
- package/lib/types/concurrency.d.ts +2 -0
- package/lib/types/engine.d.ts +66 -0
- package/lib/types/export-tool.d.ts +7 -0
- package/lib/types/exporters.d.ts +17 -0
- package/lib/types/tool.d.ts +5 -4
- package/package.json +106 -103
- package/skills/logicprobe/SKILL.md +14 -8
- package/skills/logicprobe/references/concurrency-risk-guide.md +2 -0
- package/skills/logicprobe/references/dsh-model-schema.md +266 -204
- package/skills/logicprobe/references/gap-routing-guide.md +36 -0
- package/skills/logicprobe/references/logic-verification-guide.md +56 -4
- package/skills/logicprobe/references/logicprobe-engine.py +3028 -0
- package/skills/logicprobe/references/verification-harness.py +153 -0
- package/src/compose-tool.ts +46 -0
- package/src/concurrency.ts +13 -1
- package/src/engine.ts +2568 -1778
- package/src/export-tool.ts +47 -0
- package/src/exporters.ts +347 -0
- package/src/index.ts +349 -337
- package/src/tool.ts +61 -60
- package/skills/logicprobe/references/__pycache__/verification-harness.cpython-312.pyc +0 -0
- package/skills/logicprobe-datamodel/references/__pycache__/data-model-harness.cpython-312.pyc +0 -0
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
|
|
994
|
-
|
|
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
|
-
|
|
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 + '
|
|
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
|
-
|
|
1018
|
-
|
|
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 = [
|
|
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
|
}
|