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 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
- function invariantHolds(invariant, runtime) {
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) {