dsh-logicprobe 0.6.8 → 0.7.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 +127 -2
- package/lib/index.js +304 -304
- package/lib/tool.js +1 -1
- package/lib/types/engine.d.ts +22 -0
- package/lib/types/index.d.ts +54 -54
- package/package.json +8 -4
- package/skills/logicprobe/references/__pycache__/logicprobe-engine.cpython-310.pyc +0 -0
- package/skills/logicprobe/references/dsh-model-schema.md +322 -266
- package/skills/logicprobe/references/logicprobe-engine.py +3169 -3028
- package/src/engine.ts +2714 -2568
- package/src/tool.ts +61 -61
package/lib/engine.js
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
export const ENGINE_SCHEMA_VERSION = 1;
|
|
3
|
+
export function isStateGuard(when) {
|
|
4
|
+
return 'state' in when;
|
|
5
|
+
}
|
|
6
|
+
/** Allowed keys per invariant kind — the schema is closed, so anything else is a typo or an unsupported feature. */
|
|
7
|
+
const INVARIANT_KEYS = {
|
|
8
|
+
'never-states': new Set(['id', 'description', 'kind', 'states']),
|
|
9
|
+
'var-in-range': new Set(['id', 'description', 'kind', 'variable', 'min', 'max', 'when']),
|
|
10
|
+
'event-before-state': new Set(['id', 'description', 'kind', 'event', 'state']),
|
|
11
|
+
'leads-to': new Set(['id', 'description', 'kind', 'from', 'to']),
|
|
12
|
+
'sequence': new Set(['id', 'description', 'kind', 'events']),
|
|
13
|
+
'atomicity': new Set(['id', 'description', 'kind', 'events', 'commit', 'rollback']),
|
|
14
|
+
'budget': new Set(['id', 'description', 'kind', 'budget']),
|
|
15
|
+
'probability': new Set(['id', 'description', 'kind', 'target', 'op', 'p']),
|
|
16
|
+
};
|
|
17
|
+
const KNOWN_INVARIANT_KINDS = new Set(Object.keys(INVARIANT_KEYS));
|
|
18
|
+
/**
|
|
19
|
+
* Declared keys per model part. The model schema is closed: a key that is not
|
|
20
|
+
* declared is a typo or an unsupported feature, and silently ignoring it can make
|
|
21
|
+
* the engine report a property that the model does not actually have (a mistyped
|
|
22
|
+
* guard variable reads as an always-false guard, which prunes real paths and can
|
|
23
|
+
* yield a false "no deadlock"). Rejecting is the only safe option for a verifier.
|
|
24
|
+
*/
|
|
25
|
+
const MODEL_KEYS = {
|
|
26
|
+
root: new Set(['schemaVersion', 'init', 'states', 'transitions', 'variables', 'invariants', 'concurrentPairs', 'boundaryChecks', 'resourcePairs', 'idempotentEvents', 'tickEvents', 'narrative']),
|
|
27
|
+
state: new Set(['id', 'terminal', 'onEntry', 'onExit', 'maxTicks']),
|
|
28
|
+
transition: new Set(['from', 'event', 'to', 'guard', 'updates', 'cost', 'weight']),
|
|
29
|
+
update: new Set(['variable', 'op', 'value']),
|
|
30
|
+
variable: new Set(['name', 'kind', 'init', 'min', 'max', 'monotonic']),
|
|
31
|
+
// LogicModelV1 boundary checks are (variable, values). The data-model engine has
|
|
32
|
+
// its own (entity, field) shape and its own validator — the two must not be mixed.
|
|
33
|
+
boundaryCheck: new Set(['variable', 'values']),
|
|
34
|
+
resourcePair: new Set(['resource', 'acquireEvent', 'releaseEvent', 'failEvent']),
|
|
35
|
+
narrative: new Set(['states', 'events', 'scenarios']),
|
|
36
|
+
scenario: new Set(['from', 'event', 'scenario']),
|
|
37
|
+
};
|
|
3
38
|
const DEFAULT_MAX_STATES = 10_000;
|
|
4
39
|
const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
|
|
5
40
|
function stableStringify(value) {
|
|
@@ -22,6 +57,13 @@ export function validateModel(input) {
|
|
|
22
57
|
return { ok: false, errors: ['model: must be an object'] };
|
|
23
58
|
}
|
|
24
59
|
const root = input;
|
|
60
|
+
const rejectUnknownKeys = (value, allowed, path) => {
|
|
61
|
+
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
|
|
62
|
+
if (unexpected.length > 0) {
|
|
63
|
+
bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + [...allowed].join(', ') + ')');
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
rejectUnknownKeys(root, MODEL_KEYS.root, 'model');
|
|
25
67
|
if (root.schemaVersion !== 1)
|
|
26
68
|
bad('schemaVersion', 'must be 1');
|
|
27
69
|
if (typeof root.init !== 'string' || root.init.length === 0)
|
|
@@ -37,6 +79,7 @@ export function validateModel(input) {
|
|
|
37
79
|
return;
|
|
38
80
|
}
|
|
39
81
|
const state = entry;
|
|
82
|
+
rejectUnknownKeys(state, MODEL_KEYS.state, 'states[' + index + ']');
|
|
40
83
|
if (typeof state.id !== 'string' || state.id.length === 0)
|
|
41
84
|
bad('states[' + index + '].id', 'must be a non-empty string');
|
|
42
85
|
else if (seen.has(state.id))
|
|
@@ -75,6 +118,7 @@ export function validateModel(input) {
|
|
|
75
118
|
return;
|
|
76
119
|
}
|
|
77
120
|
const transition = entry;
|
|
121
|
+
rejectUnknownKeys(transition, MODEL_KEYS.transition, path);
|
|
78
122
|
if (typeof transition.from !== 'string' || transition.from.length === 0)
|
|
79
123
|
bad(path + '.from', 'must be a non-empty string');
|
|
80
124
|
else if (!stateIds.has(transition.from))
|
|
@@ -98,6 +142,7 @@ export function validateModel(input) {
|
|
|
98
142
|
return;
|
|
99
143
|
}
|
|
100
144
|
const record = update;
|
|
145
|
+
rejectUnknownKeys(record, MODEL_KEYS.update, updatePath);
|
|
101
146
|
if (typeof record.variable !== 'string' || record.variable.length === 0)
|
|
102
147
|
bad(updatePath + '.variable', 'must be a non-empty string');
|
|
103
148
|
if (record.op !== 'set' && record.op !== 'inc' && record.op !== 'dec')
|
|
@@ -135,6 +180,7 @@ export function validateModel(input) {
|
|
|
135
180
|
return;
|
|
136
181
|
}
|
|
137
182
|
const variable = entry;
|
|
183
|
+
rejectUnknownKeys(variable, MODEL_KEYS.variable, path);
|
|
138
184
|
if (typeof variable.name !== 'string' || variable.name.length === 0)
|
|
139
185
|
bad(path + '.name', 'must be a non-empty string');
|
|
140
186
|
else if (variableNames.has(variable.name))
|
|
@@ -168,6 +214,10 @@ export function validateModel(input) {
|
|
|
168
214
|
else if (!variableNames.has(name))
|
|
169
215
|
bad(path, 'references unknown variable ' + name);
|
|
170
216
|
};
|
|
217
|
+
const invariantStateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
|
|
218
|
+
// Reference sets for invariant targets: denormalising a kind's target into the
|
|
219
|
+
// report makes a mistyped id look authoritative, so every reference must resolve.
|
|
220
|
+
const invariantEventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
|
|
171
221
|
if (root.invariants !== undefined) {
|
|
172
222
|
if (!Array.isArray(root.invariants))
|
|
173
223
|
bad('invariants', 'must be an array');
|
|
@@ -183,6 +233,17 @@ export function validateModel(input) {
|
|
|
183
233
|
bad(path + '.id', 'must be a non-empty string');
|
|
184
234
|
if (typeof invariant.description !== 'string')
|
|
185
235
|
bad(path + '.description', 'must be a string');
|
|
236
|
+
// Reject keys the selected kind does not declare instead of silently ignoring
|
|
237
|
+
// them: an ignored field (e.g. a `when` scope on a kind that cannot honor it)
|
|
238
|
+
// still shows up in the echoed report and reads as if it took effect. Kinds an
|
|
239
|
+
// older reader may not know are skipped so they still fail on `.kind` alone.
|
|
240
|
+
if (KNOWN_INVARIANT_KINDS.has(invariant.kind)) {
|
|
241
|
+
const allowed = INVARIANT_KEYS[invariant.kind];
|
|
242
|
+
const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key));
|
|
243
|
+
if (unexpected.length > 0) {
|
|
244
|
+
bad(path + '.' + unexpected[0], 'unknown field for kind ' + String(invariant.kind) + ' (allowed: ' + [...allowed].join(', ') + ')');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
186
247
|
if (invariant.kind === 'never-states') {
|
|
187
248
|
if (!Array.isArray(invariant.states) || invariant.states.length === 0)
|
|
188
249
|
bad(path + '.states', 'must be a non-empty array');
|
|
@@ -190,6 +251,8 @@ export function validateModel(input) {
|
|
|
190
251
|
invariant.states.forEach((state, stateIndex) => {
|
|
191
252
|
if (typeof state !== 'string' || state.length === 0)
|
|
192
253
|
bad(path + '.states[' + stateIndex + ']', 'must be a non-empty string');
|
|
254
|
+
else if (!invariantStateIds.has(state))
|
|
255
|
+
bad(path + '.states[' + stateIndex + ']', 'references unknown state ' + state);
|
|
193
256
|
});
|
|
194
257
|
}
|
|
195
258
|
else if (invariant.kind === 'var-in-range') {
|
|
@@ -198,18 +261,57 @@ export function validateModel(input) {
|
|
|
198
261
|
bad(path + '.min', 'must be a number');
|
|
199
262
|
if (invariant.max !== undefined && typeof invariant.max !== 'number')
|
|
200
263
|
bad(path + '.max', 'must be a number');
|
|
264
|
+
if (invariant.min === undefined && invariant.max === undefined)
|
|
265
|
+
bad(path, 'requires min or max (a range with neither bound is vacuous)');
|
|
266
|
+
if (invariant.when !== undefined) {
|
|
267
|
+
const when = invariant.when;
|
|
268
|
+
if (typeof when !== 'object' || when === null || Array.isArray(when)) {
|
|
269
|
+
bad(path + '.when', 'must be an object');
|
|
270
|
+
}
|
|
271
|
+
else if ('state' in when) {
|
|
272
|
+
const scope = when;
|
|
273
|
+
for (const key of Object.keys(scope)) {
|
|
274
|
+
if (key !== 'state')
|
|
275
|
+
bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)');
|
|
276
|
+
}
|
|
277
|
+
const state = scope.state;
|
|
278
|
+
if (typeof state !== 'string' || state.length === 0)
|
|
279
|
+
bad(path + '.when.state', 'must be a non-empty string');
|
|
280
|
+
else if (!invariantStateIds.has(state))
|
|
281
|
+
bad(path + '.when.state', 'references unknown state ' + state);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
validateGuard(when, path + '.when', errors, bad);
|
|
285
|
+
for (const variable of guardVariables(when)) {
|
|
286
|
+
if (variable === invariant.variable) {
|
|
287
|
+
bad(path + '.when', 'must not reference the constrained variable ' + invariant.variable + ' (it can mask its own violation)');
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
validateVariableRef(variable, path + '.when');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
201
295
|
}
|
|
202
296
|
else if (invariant.kind === 'event-before-state') {
|
|
203
297
|
if (typeof invariant.event !== 'string' || invariant.event.length === 0)
|
|
204
298
|
bad(path + '.event', 'must be a non-empty string');
|
|
299
|
+
else if (!invariantEventIds.has(invariant.event))
|
|
300
|
+
bad(path + '.event', 'references unknown event ' + invariant.event);
|
|
205
301
|
if (typeof invariant.state !== 'string' || invariant.state.length === 0)
|
|
206
302
|
bad(path + '.state', 'must be a non-empty string');
|
|
303
|
+
else if (!invariantStateIds.has(invariant.state))
|
|
304
|
+
bad(path + '.state', 'references unknown state ' + invariant.state);
|
|
207
305
|
}
|
|
208
306
|
else if (invariant.kind === 'leads-to') {
|
|
209
307
|
if (typeof invariant.from !== 'string' || invariant.from.length === 0)
|
|
210
308
|
bad(path + '.from', 'must be a non-empty string');
|
|
309
|
+
else if (!invariantStateIds.has(invariant.from))
|
|
310
|
+
bad(path + '.from', 'references unknown state ' + invariant.from);
|
|
211
311
|
if (typeof invariant.to !== 'string' || invariant.to.length === 0)
|
|
212
312
|
bad(path + '.to', 'must be a non-empty string');
|
|
313
|
+
else if (!invariantStateIds.has(invariant.to))
|
|
314
|
+
bad(path + '.to', 'references unknown state ' + invariant.to);
|
|
213
315
|
}
|
|
214
316
|
else if (invariant.kind === 'sequence') {
|
|
215
317
|
if (!Array.isArray(invariant.events) || invariant.events.length === 0)
|
|
@@ -240,6 +342,8 @@ export function validateModel(input) {
|
|
|
240
342
|
else if (invariant.kind === 'probability') {
|
|
241
343
|
if (typeof invariant.target !== 'string' || invariant.target.length === 0)
|
|
242
344
|
bad(path + '.target', 'must be a non-empty string');
|
|
345
|
+
else if (!invariantStateIds.has(invariant.target))
|
|
346
|
+
bad(path + '.target', 'references unknown state ' + invariant.target);
|
|
243
347
|
if (invariant.op !== '>=' && invariant.op !== '<=' && invariant.op !== '>' && invariant.op !== '<')
|
|
244
348
|
bad(path + '.op', "must be one of '>=', '<=', '>', '<'");
|
|
245
349
|
if (typeof invariant.p !== 'number' || !Number.isFinite(invariant.p) || invariant.p < 0 || invariant.p > 1)
|
|
@@ -272,6 +376,7 @@ export function validateModel(input) {
|
|
|
272
376
|
return;
|
|
273
377
|
}
|
|
274
378
|
const check = entry;
|
|
379
|
+
rejectUnknownKeys(check, MODEL_KEYS.boundaryCheck, path);
|
|
275
380
|
validateVariableRef(check.variable, path + '.variable');
|
|
276
381
|
if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number'))
|
|
277
382
|
bad(path + '.values', 'must be an array of numbers');
|
|
@@ -306,6 +411,7 @@ export function validateModel(input) {
|
|
|
306
411
|
return;
|
|
307
412
|
}
|
|
308
413
|
const pair = entry;
|
|
414
|
+
rejectUnknownKeys(pair, MODEL_KEYS.resourcePair, path);
|
|
309
415
|
if (typeof pair.resource !== 'string' || pair.resource.length === 0)
|
|
310
416
|
bad(path + '.resource', 'must be a non-empty string');
|
|
311
417
|
if (typeof pair.acquireEvent !== 'string' || pair.acquireEvent.length === 0)
|
|
@@ -323,6 +429,7 @@ export function validateModel(input) {
|
|
|
323
429
|
}
|
|
324
430
|
else {
|
|
325
431
|
const narrative = root.narrative;
|
|
432
|
+
rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath);
|
|
326
433
|
const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
|
|
327
434
|
const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
|
|
328
435
|
const fromEventGroups = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.from + '|' + transition.event) : []);
|
|
@@ -368,6 +475,7 @@ export function validateModel(input) {
|
|
|
368
475
|
return;
|
|
369
476
|
}
|
|
370
477
|
const scenario = entry;
|
|
478
|
+
rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath);
|
|
371
479
|
if (typeof scenario.from !== 'string' || scenario.from.length === 0)
|
|
372
480
|
bad(scenarioPath + '.from', 'must be a non-empty string');
|
|
373
481
|
else if (!stateIds.has(scenario.from))
|
|
@@ -845,10 +953,18 @@ function S5_eventCompleteness(model) {
|
|
|
845
953
|
}
|
|
846
954
|
return checkResult('S5', 'Event Completeness', findings, findings.length === 0 ? 'All states handle all relevant events' : findings.length + ' unhandled (state, event) pairs');
|
|
847
955
|
}
|
|
848
|
-
|
|
956
|
+
/** Whether a `var-in-range` `when` scope selects this runtime state. */
|
|
957
|
+
function whenScopeHolds(when, runtime) {
|
|
958
|
+
return isStateGuard(when) ? when.state === runtime.state : evalGuard(when, runtime.vars);
|
|
959
|
+
}
|
|
960
|
+
function invariantHolds(invariant, runtime, isInitial = false) {
|
|
849
961
|
if (invariant.kind === 'never-states')
|
|
850
962
|
return !invariant.states.includes(runtime.state);
|
|
851
963
|
if (invariant.kind === 'var-in-range') {
|
|
964
|
+
// The initial runtime state is checked unconditionally: a machine must never be
|
|
965
|
+
// able to escape the range simply by starting outside the scope.
|
|
966
|
+
if (!isInitial && invariant.when !== undefined && !whenScopeHolds(invariant.when, runtime))
|
|
967
|
+
return true;
|
|
852
968
|
const value = runtime.vars[invariant.variable];
|
|
853
969
|
if (typeof value !== 'number')
|
|
854
970
|
return false;
|
|
@@ -865,7 +981,7 @@ function shortestViolationForInvariant(model, options, invariant) {
|
|
|
865
981
|
return shortestEventBeforeStateViolation(model, options, invariant);
|
|
866
982
|
}
|
|
867
983
|
const init = initialState(model);
|
|
868
|
-
if (!invariantHolds(invariant, init)) {
|
|
984
|
+
if (!invariantHolds(invariant, init, true)) {
|
|
869
985
|
return { invariant, path: [], reason: 'Initial state violates the invariant.' };
|
|
870
986
|
}
|
|
871
987
|
const visited = new Set([runtimeKey(init)]);
|
|
@@ -1375,6 +1491,15 @@ function mapInvariantForComparison(invariant, mapping) {
|
|
|
1375
1491
|
state: mapStateId(mapping, invariant.state),
|
|
1376
1492
|
};
|
|
1377
1493
|
}
|
|
1494
|
+
if (invariant.kind === 'var-in-range') {
|
|
1495
|
+
const mapped = { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
|
|
1496
|
+
// A state scope must follow the state rename, otherwise D2 reports a spurious
|
|
1497
|
+
// regression when the scope later refers to a state id that no longer exists.
|
|
1498
|
+
if (mapped.when !== undefined && isStateGuard(mapped.when)) {
|
|
1499
|
+
mapped.when = { state: mapStateId(mapping, mapped.when.state) };
|
|
1500
|
+
}
|
|
1501
|
+
return mapped;
|
|
1502
|
+
}
|
|
1378
1503
|
return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
|
|
1379
1504
|
}
|
|
1380
1505
|
function D2_invariantContinuity(before, after, options, mapping) {
|