dsh-model-everything 0.0.0-stage → 0.1.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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +319 -0
  3. package/README.md +319 -3
  4. package/cordis.patch.yml +20 -0
  5. package/examples/README.md +48 -0
  6. package/examples/control-dispatcher.json +113 -0
  7. package/examples/control-dispatcher.overbudget.json +113 -0
  8. package/examples/ecommerce-data-model.json +185 -0
  9. package/examples/order-state-machine.after.json +116 -0
  10. package/examples/order-state-machine.before.json +101 -0
  11. package/examples/user-migration.after.json +29 -0
  12. package/examples/user-migration.before.json +29 -0
  13. package/gate/locale/en.json +6 -0
  14. package/gate/locale/zh.json +6 -0
  15. package/gate/package.json +5 -0
  16. package/lib/baseline.js +249 -0
  17. package/lib/compose-tool.js +76 -0
  18. package/lib/concurrency-tool.js +40 -0
  19. package/lib/concurrency.js +206 -0
  20. package/lib/data-engine.js +1141 -0
  21. package/lib/data-tool.js +79 -0
  22. package/lib/design-engine.js +1149 -0
  23. package/lib/design-tool.js +55 -0
  24. package/lib/engine.js +4343 -0
  25. package/lib/export-tool.js +78 -0
  26. package/lib/exporters.js +434 -0
  27. package/lib/gate.js +274 -0
  28. package/lib/granularity.js +237 -0
  29. package/lib/index.js +237 -0
  30. package/lib/json-value.js +18 -0
  31. package/lib/option-shape.js +62 -0
  32. package/lib/report-diff-tool.js +63 -0
  33. package/lib/report-view.js +408 -0
  34. package/lib/structure-tool.js +91 -0
  35. package/lib/structure.js +711 -0
  36. package/lib/tool.js +85 -0
  37. package/lib/types/baseline.d.ts +65 -0
  38. package/lib/types/compose-tool.d.ts +7 -0
  39. package/lib/types/concurrency-tool.d.ts +8 -0
  40. package/lib/types/concurrency.d.ts +37 -0
  41. package/lib/types/data-engine.d.ts +211 -0
  42. package/lib/types/data-tool.d.ts +10 -0
  43. package/lib/types/design-engine.d.ts +215 -0
  44. package/lib/types/design-tool.d.ts +11 -0
  45. package/lib/types/engine.d.ts +494 -0
  46. package/lib/types/export-tool.d.ts +7 -0
  47. package/lib/types/exporters.d.ts +37 -0
  48. package/lib/types/gate.d.ts +91 -0
  49. package/lib/types/granularity.d.ts +106 -0
  50. package/lib/types/index.d.ts +45 -0
  51. package/lib/types/json-value.d.ts +21 -0
  52. package/lib/types/option-shape.d.ts +22 -0
  53. package/lib/types/report-diff-tool.d.ts +8 -0
  54. package/lib/types/report-view.d.ts +43 -0
  55. package/lib/types/structure-tool.d.ts +10 -0
  56. package/lib/types/structure.d.ts +152 -0
  57. package/lib/types/tool.d.ts +11 -0
  58. package/lib/types/uml-communication.d.ts +109 -0
  59. package/lib/types/uml-composite.d.ts +107 -0
  60. package/lib/types/uml-interaction-overview.d.ts +91 -0
  61. package/lib/types/uml-profile.d.ts +112 -0
  62. package/lib/types/uml-structure.d.ts +53 -0
  63. package/lib/types/uml-syntax.d.ts +296 -0
  64. package/lib/types/uml-timing.d.ts +152 -0
  65. package/lib/types/uml-tool.d.ts +12 -0
  66. package/lib/types/uml-usecase.d.ts +58 -0
  67. package/lib/types/uml.d.ts +303 -0
  68. package/lib/uml-communication.js +353 -0
  69. package/lib/uml-composite.js +442 -0
  70. package/lib/uml-interaction-overview.js +277 -0
  71. package/lib/uml-profile.js +373 -0
  72. package/lib/uml-structure.js +306 -0
  73. package/lib/uml-syntax.js +545 -0
  74. package/lib/uml-timing.js +974 -0
  75. package/lib/uml-tool.js +213 -0
  76. package/lib/uml-usecase.js +532 -0
  77. package/lib/uml.js +2110 -0
  78. package/locale/en.json +6 -0
  79. package/locale/zh.json +6 -0
  80. package/package.json +124 -6
  81. package/skills/model-everything/SKILL.md +482 -0
  82. package/skills/model-everything/references/dsh-model-schema.md +437 -0
  83. package/skills/model-everything/references/gap-routing-guide.md +42 -0
  84. package/skills/model-everything/references/hash-spec.md +152 -0
  85. package/skills/model-everything/references/logic-verification-guide.md +422 -0
  86. package/skills/model-everything-datamodel/SKILL.md +130 -0
  87. package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
  88. package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
  89. package/skills/model-everything-design/SKILL.md +115 -0
  90. package/skills/model-everything-interleaving/SKILL.md +63 -0
  91. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
  92. package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
  93. package/skills/model-everything-report-reading/SKILL.md +137 -0
  94. package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
  95. package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
  96. package/skills/model-everything-structure/SKILL.md +96 -0
  97. package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
  98. package/skills/model-everything-uml/SKILL.md +60 -0
  99. package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
  100. package/src/baseline.ts +288 -0
  101. package/src/compose-tool.ts +78 -0
  102. package/src/concurrency-tool.ts +44 -0
  103. package/src/concurrency.ts +254 -0
  104. package/src/data-engine.ts +1208 -0
  105. package/src/data-tool.ts +83 -0
  106. package/src/design-engine.ts +1263 -0
  107. package/src/design-tool.ts +60 -0
  108. package/src/engine.ts +4564 -0
  109. package/src/export-tool.ts +81 -0
  110. package/src/exporters.ts +399 -0
  111. package/src/gate.ts +330 -0
  112. package/src/granularity.ts +312 -0
  113. package/src/index.ts +289 -0
  114. package/src/json-value.ts +20 -0
  115. package/src/option-shape.ts +64 -0
  116. package/src/report-diff-tool.ts +67 -0
  117. package/src/report-view.ts +451 -0
  118. package/src/structure-tool.ts +93 -0
  119. package/src/structure.ts +786 -0
  120. package/src/tool.ts +94 -0
  121. package/src/uml-communication.ts +428 -0
  122. package/src/uml-composite.ts +520 -0
  123. package/src/uml-interaction-overview.ts +321 -0
  124. package/src/uml-profile.ts +439 -0
  125. package/src/uml-structure.ts +317 -0
  126. package/src/uml-syntax.ts +764 -0
  127. package/src/uml-timing.ts +1106 -0
  128. package/src/uml-tool.ts +230 -0
  129. package/src/uml-usecase.ts +566 -0
  130. package/src/uml.ts +2139 -0
package/lib/engine.js ADDED
@@ -0,0 +1,4343 @@
1
+ import { createHash } from 'node:crypto';
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
+ };
38
+ /**
39
+ * The next-steps list every verification report carries. Derived only from the checks
40
+ * and the narrative coverage, so two runs over the same model produce the same list.
41
+ */
42
+ function verificationNextSteps(checks, coverage) {
43
+ const steps = [];
44
+ // The name leads and the code follows in parentheses, so a reader who has never seen a report can act
45
+ // on this sentence, and a reader who wants the code still has it. The code is a label for the check,
46
+ // not the check's name.
47
+ const failed = checks.filter((check) => check.status === 'fail').map((check) => check.name + ' (' + check.id + ')');
48
+ const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort();
49
+ if (failed.length > 0)
50
+ steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.');
51
+ // A check that had nothing to examine is named here, because a reader who is not told will assume every
52
+ // named check looked at the model. A list of failures does not say that, and neither does `pass`.
53
+ const skipped = checks.filter((check) => check.status === 'skip').map((check) => check.name + ' (' + check.id + ')');
54
+ if (skipped.length > 0) {
55
+ steps.push('These examined nothing, so their silence is not a result: ' + skipped.join(', ') + '. Declare what each one would need, or accept that the model is unverified on those points.');
56
+ }
57
+ // Named checks above, bare codes here. That is deliberate: each warning is a different kind of thing,
58
+ // so there is no single name to give the set, and the sentence points at where each one IS explained
59
+ // rather than leaving a reader to guess what the codes stand for.
60
+ if (warningCodes.length > 0)
61
+ steps.push('Review the warning findings (' + warningCodes.join(', ') + '): they are not failures, but they are not silence either. Each one is explained in its own `message` under `findings`.');
62
+ if (coverage !== undefined && !narrativeComplete(coverage)) {
63
+ steps.push('Complete the narrative (states ' + coverage.states + ', events ' + coverage.events + ', scenarios ' + coverage.scenarios + ') — a partial narrative is valid, but a reader still has to re-derive the missing symbols.');
64
+ }
65
+ // The range is dropped: the code that would be reported back is already named in `findings`, and a
66
+ // reader who has not seen a report yet learns nothing from four digits.
67
+ steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress: behaviour preserved, invariants continuous, the state mapping consistent. Then compare the two reports with `report_diff` rather than reading them side by side.');
68
+ return steps;
69
+ }
70
+ /**
71
+ * Repair guidance for a rejected model. A refusal reports the offending path and the rule
72
+ * it broke, but not what to write instead; for fields whose absence is a documented trap,
73
+ * that is the difference between "fix it" and "guess again". Derived only from the
74
+ * validation messages, so the list stays deterministic.
75
+ */
76
+ function refusalGuidance(errors) {
77
+ const steps = [];
78
+ if (errors.some((message) => message.includes('.description'))) {
79
+ steps.push('Add the required "description" string to every invariant (for example "description": "what this invariant guarantees"); a `_`-prefixed key is not a substitute, and the value takes part in `modelHash`.');
80
+ }
81
+ return steps;
82
+ }
83
+ const DEFAULT_MAX_STATES = 10_000;
84
+ const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
85
+ /**
86
+ * Ceilings the caller cannot raise.
87
+ *
88
+ * These are not budget opinions, they are the difference between a bounded answer and an aborted
89
+ * process. `maxStates` bounds every walk in the engine with `steps > options.maxStates`, so a `NaN`
90
+ * makes that comparison false forever and the walk allocates until the heap dies - the robustness
91
+ * audit measured 4 GB and a fatal V8 error at about 70 s, from an option the tool schema accepts.
92
+ * `Infinity` never stops at all.
93
+ *
94
+ * The state ceiling is ten times the default, so a caller who genuinely needs more can ask.
95
+ * Permutations are factorial, which makes that ceiling hard: 9! is 362 880 orderings, three orders
96
+ * of magnitude past the default's 120, and A2's sampling path exists for the cases beyond it.
97
+ */
98
+ const MAX_STATES_CEILING = 100_000;
99
+ const MAX_PERMUTATION_EVENTS_CEILING = 9;
100
+ /**
101
+ * A caller-supplied bound, or the default when the value could not bound anything.
102
+ *
103
+ * The engine never throws for a bad option, so this neither refuses nor throws: it replaces or
104
+ * clamps the value and records that it did, and the report carries the note. A non-finite or
105
+ * non-integer bound is not a claim to disagree with - it is a bound that does not bound, and
106
+ * `steps > NaN` is false forever.
107
+ */
108
+ function boundedOption(value, fallback, ceiling, name, notes) {
109
+ if (value === undefined)
110
+ return fallback;
111
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
112
+ notes.push(name + ' was ' + String(value) + ', which cannot bound a search; the default of ' + String(fallback) + ' was used instead.');
113
+ return fallback;
114
+ }
115
+ if (value > ceiling) {
116
+ notes.push(name + ' was ' + String(value) + '; it was clamped to ' + String(ceiling) + '. A larger bound does not buy a larger answer here: the search would run until the process ran out of memory.');
117
+ return ceiling;
118
+ }
119
+ return value;
120
+ }
121
+ /**
122
+ * Budget for the leads-to walk, as a multiple of `maxStates`. That walk is depth-first
123
+ * over run states, so `maxStates` does not bound it the way it bounds the breadth-first
124
+ * explorations: an `inc`/`dec` counter whose updates are not pinned by a guard has an
125
+ * unbounded reachable set, every run state is distinct, and the walk never revisits one.
126
+ * The budget is shared across every start state of a single check, so a wide search
127
+ * space cannot multiply it by the number of starts.
128
+ */
129
+ const LEADS_TO_WALK_FACTOR = 20;
130
+ function leadsToWalkBudget(options) {
131
+ return Math.max(1_000, options.maxStates * LEADS_TO_WALK_FACTOR);
132
+ }
133
+ function stableStringify(value) {
134
+ if (value === null || typeof value !== 'object')
135
+ return JSON.stringify(value);
136
+ if (Array.isArray(value))
137
+ return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
138
+ const record = value;
139
+ return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
140
+ }
141
+ /**
142
+ * The hash of any model this plugin carries: the same normalization as `modelHash`,
143
+ * over whatever object the family defines. A use case diagram and a state machine are
144
+ * hashed by one rule, so a report can name either.
145
+ */
146
+ export function valueHash(value, spec = DEFAULT_HASH_SPEC) {
147
+ return createHash('sha256').update(stableStringify(hashPayload(value, spec))).digest('hex');
148
+ }
149
+ export function modelHash(model, spec = DEFAULT_HASH_SPEC) {
150
+ return valueHash(model, spec);
151
+ }
152
+ /**
153
+ * Deterministic JSON (sorted keys, no insignificant whitespace) — the serialization the
154
+ * hash specification is defined over. Exported so every report hashes inputs the same way.
155
+ */
156
+ export function canonicalJson(value) {
157
+ return stableStringify(value);
158
+ }
159
+ export const PUBLISHED_HASH_SPECS = ['v0', 'v1'];
160
+ export const DEFAULT_HASH_SPEC = 'v1';
161
+ /**
162
+ * Versioned report contracts. Every tool result carries `schema`, so a consumer can
163
+ * branch on the contract instead of sniffing fields, and a change to a report's shape
164
+ * is a version bump here rather than a silent break. `findings[]` keeps the same
165
+ * stable core in every family: `{code, severity, message, detail?, evidence?, path?}`.
166
+ */
167
+ export const REPORT_SCHEMAS = {
168
+ verify: 'model-everything/verify/v1',
169
+ compose: 'model-everything/compose/v1',
170
+ datamodel: 'model-everything/datamodel/v1',
171
+ concurrency: 'model-everything/concurrency/v1',
172
+ export: 'model-everything/export/v1',
173
+ umlRender: 'model-everything/uml/render/v1',
174
+ umlParse: 'model-everything/uml/parse/v1',
175
+ umlReview: 'model-everything/uml/review/v1',
176
+ umlExplainLabels: 'model-everything/uml/explain-labels/v1',
177
+ // A UML refusal happens before the action resolves, so there is no action schema to name. It still carries
178
+ // one: every other family's refusal does, and a reader who keys on `schema` was left with nothing.
179
+ umlRefusal: 'model-everything/uml/refusal/v1',
180
+ structure: 'model-everything/structure/v1',
181
+ granularity: 'model-everything/granularity/v1',
182
+ design: 'model-everything/design/v1',
183
+ baseline: 'model-everything/baseline/v1',
184
+ };
185
+ export function narrativeCoverageOf(model) {
186
+ const narrative = model.narrative;
187
+ if (narrative === undefined)
188
+ return undefined;
189
+ const describedStates = narrative.states === undefined ? 0 : Object.keys(narrative.states).length;
190
+ const describedEvents = narrative.events === undefined ? 0 : Object.keys(narrative.events).length;
191
+ const groups = new Set(model.transitions.map((transition) => transition.from + '|' + transition.event));
192
+ const describedGroups = narrative.scenarios === undefined
193
+ ? 0
194
+ : new Set(narrative.scenarios.map((scenario) => scenario.from + '|' + scenario.event)).size;
195
+ const events = new Set(model.transitions.map((transition) => transition.event));
196
+ return {
197
+ states: String(describedStates) + '/' + String(model.states.length),
198
+ events: String(describedEvents) + '/' + String(events.size),
199
+ scenarios: String(describedGroups) + '/' + String(groups.size),
200
+ };
201
+ }
202
+ /** True when every dimension of the coverage is fully described. */
203
+ export function narrativeComplete(coverage) {
204
+ if (coverage === undefined)
205
+ return false;
206
+ return coverage.states.split('/')[0] === coverage.states.split('/')[1]
207
+ && coverage.events.split('/')[0] === coverage.events.split('/')[1]
208
+ && coverage.scenarios.split('/')[0] === coverage.scenarios.split('/')[1];
209
+ }
210
+ /** A tool that refused the request before running anything: it neither passed nor failed a review. */
211
+ export function refusalVerdict(reason) {
212
+ return { verdict: 'fail', verdictReason: 'the tool did not run: ' + reason };
213
+ }
214
+ /**
215
+ * Decide the verdict from finding counts. Any error-severity finding fails the review
216
+ * even though the tool ran successfully — that gap is exactly what `verdict` closes.
217
+ */
218
+ export function verdictOf(errors, warnings, firstCode) {
219
+ if (errors > 0) {
220
+ return {
221
+ verdict: 'fail',
222
+ // `first error:` rather than `first:`: what follows is a code, and a reader who does not know the
223
+ // codes should still be able to tell that this field is pointing at one. The code is the label; the
224
+ // finding's own `message` carries the sentence.
225
+ verdictReason: String(errors) + ' error finding(s)' + (firstCode === undefined ? '' : ' (first error: ' + firstCode + ')'),
226
+ };
227
+ }
228
+ if (warnings > 0) {
229
+ return { verdict: 'pass_with_findings', verdictReason: 'no error findings; ' + String(warnings) + ' warning finding(s)' };
230
+ }
231
+ return { verdict: 'pass', verdictReason: 'no error or warning findings' };
232
+ }
233
+ export function verdictOfFindings(findings) {
234
+ const errors = findings.filter((finding) => finding.severity === 'error');
235
+ const warnings = findings.filter((finding) => finding.severity === 'warning');
236
+ return verdictOf(errors.length, warnings.length, errors[0]?.code);
237
+ }
238
+ /**
239
+ * Whether a finding means "the search stopped before deciding", as opposed to "the model is wrong".
240
+ * Such a finding must reach `summary.truncated`, or a caller reading only that flag would take an
241
+ * unfinished search for a clean one.
242
+ *
243
+ * The shipped schema reference states the rule as a name: the check that stopped carries a finding
244
+ * whose code ends in `_UNDECIDED`, and the report sets `summary.truncated`. Read from the name, that
245
+ * is one rule in one place. It used to be a hand-written set of codes, and A12 and A14 minted their
246
+ * own codes without joining it: the report carried an undecided finding and `truncated` stayed
247
+ * false, so the two halves of that sentence disagreed.
248
+ */
249
+ function hasUndecidedFinding(checks) {
250
+ return checks.some((check) => check.findings.some((finding) => finding.code.endsWith('_UNDECIDED')));
251
+ }
252
+ /**
253
+ * Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
254
+ * `states[0]._note`; a metadata key's own children are not listed, since the subtree is
255
+ * metadata as a whole). The schema ignores these keys as annotation metadata; echoing
256
+ * them in the report makes the ignored metadata auditable instead of invisible.
257
+ *
258
+ * Iterative, with an explicit stack: this runs before validation on whatever the caller passed, so
259
+ * a recursive walk overflowed the stack on a deeply nested model and the `RangeError` arrived
260
+ * instead of any report at all. A guard nested ten thousand deep is nonsense, and it has to be
261
+ * refused rather than crash the walk that was going to refuse it.
262
+ */
263
+ export function metadataKeysOf(input) {
264
+ const keys = [];
265
+ const stack = [{ value: input, path: '' }];
266
+ while (stack.length > 0) {
267
+ const frame = stack.pop();
268
+ const value = frame.value;
269
+ if (value === null || typeof value !== 'object')
270
+ continue;
271
+ if (Array.isArray(value)) {
272
+ for (let index = value.length - 1; index >= 0; index -= 1) {
273
+ stack.push({ value: value[index], path: frame.path + '[' + String(index) + ']' });
274
+ }
275
+ continue;
276
+ }
277
+ for (const [key, entry] of Object.entries(value)) {
278
+ const child = frame.path === '' ? key : frame.path + '.' + key;
279
+ if (key.startsWith('_'))
280
+ keys.push(child);
281
+ stack.push({ value: entry, path: child });
282
+ }
283
+ }
284
+ return keys.sort();
285
+ }
286
+ function withoutMetadata(value) {
287
+ if (value === null || typeof value !== 'object')
288
+ return value;
289
+ if (Array.isArray(value))
290
+ return value.map((entry) => withoutMetadata(entry));
291
+ const out = {};
292
+ for (const [key, entry] of Object.entries(value)) {
293
+ if (key.startsWith('_'))
294
+ continue;
295
+ out[key] = withoutMetadata(entry);
296
+ }
297
+ return out;
298
+ }
299
+ /**
300
+ * The exact payload a hash spec hashes. Exported so the specification has one
301
+ * implementation and the documentation can be checked against it.
302
+ */
303
+ export function hashPayload(model, spec = DEFAULT_HASH_SPEC) {
304
+ return spec === 'v1' ? withoutMetadata(model) : model;
305
+ }
306
+ export function validateModel(input) {
307
+ const errors = [];
308
+ const bad = (path, message) => {
309
+ errors.push(path + ': ' + message);
310
+ };
311
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
312
+ return { ok: false, errors: ['model: must be an object'] };
313
+ }
314
+ const root = input;
315
+ // `_`-prefixed keys are annotation metadata (provenance, verification snapshots,
316
+ // extraction caveats). They are ignored here and excluded from the hash, so a model
317
+ // can carry its own archive record without a sidecar that drifts away from it.
318
+ // Everything else stays closed: a mistyped `sttes` must still be an error.
319
+ const rejectUnknownKeys = (value, allowed, path) => {
320
+ const unexpected = Object.keys(value).filter((key) => !allowed.has(key) && !key.startsWith('_'));
321
+ if (unexpected.length > 0) {
322
+ bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + [...allowed].join(', ') + ')');
323
+ }
324
+ };
325
+ rejectUnknownKeys(root, MODEL_KEYS.root, 'model');
326
+ if (root.schemaVersion !== 1)
327
+ bad('schemaVersion', 'must be 1');
328
+ if (typeof root.init !== 'string' || root.init.length === 0)
329
+ bad('init', 'must be a non-empty string');
330
+ if (!Array.isArray(root.states) || root.states.length === 0) {
331
+ bad('states', 'must be a non-empty array');
332
+ }
333
+ else {
334
+ const seen = new Set();
335
+ root.states.forEach((entry, index) => {
336
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
337
+ bad('states[' + index + ']', 'must be an object');
338
+ return;
339
+ }
340
+ const state = entry;
341
+ rejectUnknownKeys(state, MODEL_KEYS.state, 'states[' + index + ']');
342
+ if (typeof state.id !== 'string' || state.id.length === 0)
343
+ bad('states[' + index + '].id', 'must be a non-empty string');
344
+ else if (seen.has(state.id))
345
+ bad('states[' + index + '].id', 'duplicate state id ' + state.id);
346
+ else
347
+ seen.add(state.id);
348
+ if (state.terminal !== undefined && typeof state.terminal !== 'boolean')
349
+ bad('states[' + index + '].terminal', 'must be a boolean');
350
+ for (const kind of ['onEntry', 'onExit']) {
351
+ const actions = state[kind];
352
+ if (actions !== undefined) {
353
+ if (!Array.isArray(actions))
354
+ bad('states[' + index + '].' + kind, 'must be an array of action names');
355
+ else if (actions.length > 64)
356
+ bad('states[' + index + '].' + kind, 'must not exceed 64 actions');
357
+ else
358
+ actions.forEach((action, actionIndex) => {
359
+ if (typeof action !== 'string' || action.length === 0)
360
+ bad('states[' + index + '].' + kind + '[' + actionIndex + ']', 'must be a non-empty string');
361
+ });
362
+ }
363
+ }
364
+ });
365
+ if (typeof root.init === 'string' && root.init.length > 0 && !seen.has(root.init))
366
+ bad('init', 'must name a declared state');
367
+ }
368
+ if (!Array.isArray(root.transitions)) {
369
+ bad('transitions', 'must be an array');
370
+ }
371
+ else {
372
+ const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
373
+ root.transitions.forEach((entry, index) => {
374
+ const path = 'transitions[' + index + ']';
375
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
376
+ bad(path, 'must be an object');
377
+ return;
378
+ }
379
+ const transition = entry;
380
+ rejectUnknownKeys(transition, MODEL_KEYS.transition, path);
381
+ if (typeof transition.from !== 'string' || transition.from.length === 0)
382
+ bad(path + '.from', 'must be a non-empty string');
383
+ else if (!stateIds.has(transition.from))
384
+ bad(path + '.from', 'unknown state ' + transition.from);
385
+ if (typeof transition.event !== 'string' || transition.event.length === 0)
386
+ bad(path + '.event', 'must be a non-empty string');
387
+ if (typeof transition.to !== 'string' || transition.to.length === 0)
388
+ bad(path + '.to', 'must be a non-empty string');
389
+ else if (!stateIds.has(transition.to))
390
+ bad(path + '.to', 'unknown state ' + transition.to);
391
+ if (transition.guard !== undefined)
392
+ validateGuard(transition.guard, path + '.guard', errors, bad);
393
+ if (transition.updates !== undefined) {
394
+ if (!Array.isArray(transition.updates))
395
+ bad(path + '.updates', 'must be an array');
396
+ else
397
+ transition.updates.forEach((update, updateIndex) => {
398
+ const updatePath = path + '.updates[' + updateIndex + ']';
399
+ if (typeof update !== 'object' || update === null || Array.isArray(update)) {
400
+ bad(updatePath, 'must be an object');
401
+ return;
402
+ }
403
+ const record = update;
404
+ rejectUnknownKeys(record, MODEL_KEYS.update, updatePath);
405
+ if (typeof record.variable !== 'string' || record.variable.length === 0)
406
+ bad(updatePath + '.variable', 'must be a non-empty string');
407
+ if (record.op !== 'set' && record.op !== 'inc' && record.op !== 'dec')
408
+ bad(updatePath + '.op', "must be 'set', 'inc', or 'dec'");
409
+ if (record.value !== undefined && (typeof record.value !== 'number' || !Number.isFinite(record.value))) {
410
+ // `typeof NaN === 'number'`, so the type check alone let a NaN through, and every
411
+ // comparison against a NaN is false: the update silently did nothing.
412
+ bad(updatePath + '.value', 'must be a finite number');
413
+ }
414
+ });
415
+ }
416
+ if (transition.cost !== undefined) {
417
+ if (typeof transition.cost !== 'number' || !Number.isFinite(transition.cost) || transition.cost < 0)
418
+ bad(path + '.cost', 'must be a non-negative finite number');
419
+ }
420
+ if (transition.weight !== undefined) {
421
+ if (typeof transition.weight !== 'number' || !Number.isFinite(transition.weight) || transition.weight < 0)
422
+ bad(path + '.weight', 'must be a non-negative finite number');
423
+ }
424
+ });
425
+ }
426
+ if (Array.isArray(root.states)) {
427
+ root.states.forEach((state, index) => {
428
+ if (state.maxTicks !== undefined && (!Number.isInteger(state.maxTicks) || state.maxTicks < 0)) {
429
+ bad('states[' + index + '].maxTicks', 'must be a non-negative integer');
430
+ }
431
+ });
432
+ }
433
+ const variableNames = new Set();
434
+ if (root.variables !== undefined) {
435
+ if (!Array.isArray(root.variables))
436
+ bad('variables', 'must be an array');
437
+ else
438
+ root.variables.forEach((entry, index) => {
439
+ const path = 'variables[' + index + ']';
440
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
441
+ bad(path, 'must be an object');
442
+ return;
443
+ }
444
+ const variable = entry;
445
+ rejectUnknownKeys(variable, MODEL_KEYS.variable, path);
446
+ if (typeof variable.name !== 'string' || variable.name.length === 0)
447
+ bad(path + '.name', 'must be a non-empty string');
448
+ else if (variableNames.has(variable.name))
449
+ bad(path + '.name', 'duplicate variable ' + variable.name);
450
+ else
451
+ variableNames.add(variable.name);
452
+ if (variable.kind !== 'integer' && variable.kind !== 'boolean')
453
+ bad(path + '.kind', "must be 'integer' or 'boolean'");
454
+ const expected = variable.kind === 'boolean' ? 'boolean' : 'number';
455
+ if (typeof variable.init !== expected)
456
+ bad(path + '.init', 'must be a ' + expected);
457
+ // `typeof NaN === 'number'`, so the type check alone accepts NaN, and a variable initialised
458
+ // to NaN makes every guard over it false: the model would read as if that branch were dead.
459
+ else if (expected === 'number' && !Number.isFinite(variable.init))
460
+ bad(path + '.init', 'must be a finite number');
461
+ if (variable.min !== undefined && (typeof variable.min !== 'number' || !Number.isFinite(variable.min)))
462
+ bad(path + '.min', 'must be a finite number');
463
+ if (variable.max !== undefined && (typeof variable.max !== 'number' || !Number.isFinite(variable.max)))
464
+ bad(path + '.max', 'must be a finite number');
465
+ if (typeof variable.min === 'number' && typeof variable.max === 'number' && variable.min > variable.max)
466
+ bad(path + '.max', 'must be >= min');
467
+ if (variable.monotonic !== undefined && variable.monotonic !== 'inc' && variable.monotonic !== 'dec')
468
+ bad(path + '.monotonic', "must be 'inc' or 'dec'");
469
+ if (variable.kind === 'integer' && typeof variable.init === 'number') {
470
+ if (typeof variable.min === 'number' && variable.init < variable.min)
471
+ bad(path + '.init', 'must be >= min');
472
+ if (typeof variable.max === 'number' && variable.init > variable.max)
473
+ bad(path + '.init', 'must be <= max');
474
+ }
475
+ });
476
+ }
477
+ const validateVariableRef = (name, path) => {
478
+ if (typeof name !== 'string' || name.length === 0)
479
+ bad(path, 'must be a non-empty string');
480
+ else if (!variableNames.has(name))
481
+ bad(path, 'references unknown variable ' + name);
482
+ };
483
+ const invariantStateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
484
+ // Reference sets for invariant targets: denormalising a kind's target into the
485
+ // report makes a mistyped id look authoritative, so every reference must resolve.
486
+ const invariantEventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
487
+ if (root.invariants !== undefined) {
488
+ if (!Array.isArray(root.invariants))
489
+ bad('invariants', 'must be an array');
490
+ else
491
+ root.invariants.forEach((entry, index) => {
492
+ const path = 'invariants[' + index + ']';
493
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
494
+ bad(path, 'must be an object');
495
+ return;
496
+ }
497
+ const invariant = entry;
498
+ if (typeof invariant.id !== 'string' || invariant.id.length === 0)
499
+ bad(path + '.id', 'must be a non-empty string');
500
+ if (typeof invariant.description !== 'string')
501
+ bad(path + '.description', 'must be a string');
502
+ // Reject keys the selected kind does not declare instead of silently ignoring
503
+ // them: an ignored field (e.g. a `when` scope on a kind that cannot honor it)
504
+ // still shows up in the echoed report and reads as if it took effect. Kinds an
505
+ // older reader may not know are skipped so they still fail on `.kind` alone.
506
+ if (KNOWN_INVARIANT_KINDS.has(invariant.kind)) {
507
+ const allowed = INVARIANT_KEYS[invariant.kind];
508
+ const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key) && !key.startsWith('_'));
509
+ if (unexpected.length > 0) {
510
+ bad(path + '.' + unexpected[0], 'unknown field for kind ' + String(invariant.kind) + ' (allowed: ' + [...allowed].join(', ') + ')');
511
+ }
512
+ }
513
+ if (invariant.kind === 'never-states') {
514
+ if (!Array.isArray(invariant.states) || invariant.states.length === 0)
515
+ bad(path + '.states', 'must be a non-empty array');
516
+ else
517
+ invariant.states.forEach((state, stateIndex) => {
518
+ if (typeof state !== 'string' || state.length === 0)
519
+ bad(path + '.states[' + stateIndex + ']', 'must be a non-empty string');
520
+ else if (!invariantStateIds.has(state))
521
+ bad(path + '.states[' + stateIndex + ']', 'references unknown state ' + state);
522
+ });
523
+ }
524
+ else if (invariant.kind === 'var-in-range') {
525
+ validateVariableRef(invariant.variable, path + '.variable');
526
+ if (invariant.min !== undefined && typeof invariant.min !== 'number')
527
+ bad(path + '.min', 'must be a number');
528
+ if (invariant.max !== undefined && typeof invariant.max !== 'number')
529
+ bad(path + '.max', 'must be a number');
530
+ if (invariant.min === undefined && invariant.max === undefined)
531
+ bad(path, 'requires min or max (a range with neither bound is vacuous)');
532
+ if (invariant.when !== undefined) {
533
+ const when = invariant.when;
534
+ if (typeof when !== 'object' || when === null || Array.isArray(when)) {
535
+ bad(path + '.when', 'must be an object');
536
+ }
537
+ else if ('state' in when) {
538
+ const scope = when;
539
+ for (const key of Object.keys(scope)) {
540
+ if (key !== 'state' && !key.startsWith('_'))
541
+ bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)');
542
+ }
543
+ const state = scope.state;
544
+ if (typeof state !== 'string' || state.length === 0)
545
+ bad(path + '.when.state', 'must be a non-empty string');
546
+ else if (!invariantStateIds.has(state))
547
+ bad(path + '.when.state', 'references unknown state ' + state);
548
+ }
549
+ else {
550
+ validateGuard(when, path + '.when', errors, bad);
551
+ for (const variable of guardVariables(when)) {
552
+ if (variable === invariant.variable) {
553
+ bad(path + '.when', 'must not reference the constrained variable ' + invariant.variable + ' (it can mask its own violation)');
554
+ }
555
+ else {
556
+ validateVariableRef(variable, path + '.when');
557
+ }
558
+ }
559
+ }
560
+ }
561
+ }
562
+ else if (invariant.kind === 'event-before-state') {
563
+ if (typeof invariant.event !== 'string' || invariant.event.length === 0)
564
+ bad(path + '.event', 'must be a non-empty string');
565
+ else if (!invariantEventIds.has(invariant.event))
566
+ bad(path + '.event', 'references unknown event ' + invariant.event);
567
+ if (typeof invariant.state !== 'string' || invariant.state.length === 0)
568
+ bad(path + '.state', 'must be a non-empty string');
569
+ else if (!invariantStateIds.has(invariant.state))
570
+ bad(path + '.state', 'references unknown state ' + invariant.state);
571
+ }
572
+ else if (invariant.kind === 'leads-to') {
573
+ if (typeof invariant.from !== 'string' || invariant.from.length === 0)
574
+ bad(path + '.from', 'must be a non-empty string');
575
+ else if (!invariantStateIds.has(invariant.from))
576
+ bad(path + '.from', 'references unknown state ' + invariant.from);
577
+ // `to` is one state id or a target set. A set with a duplicate member is a
578
+ // model-authoring slip that would otherwise give one meaning two hashes, so it
579
+ // is rejected rather than silently deduplicated.
580
+ if (typeof invariant.to === 'string') {
581
+ if (invariant.to.length === 0)
582
+ bad(path + '.to', 'must be a non-empty string');
583
+ else if (!invariantStateIds.has(invariant.to))
584
+ bad(path + '.to', 'references unknown state ' + invariant.to);
585
+ }
586
+ else if (Array.isArray(invariant.to)) {
587
+ if (invariant.to.length === 0)
588
+ bad(path + '.to', 'must be a non-empty array of state ids');
589
+ else {
590
+ const targets = new Set();
591
+ invariant.to.forEach((target, targetIndex) => {
592
+ const at = path + '.to[' + targetIndex + ']';
593
+ if (typeof target !== 'string' || target.length === 0)
594
+ bad(at, 'must be a non-empty string');
595
+ else if (!invariantStateIds.has(target))
596
+ bad(at, 'references unknown state ' + target);
597
+ else if (targets.has(target))
598
+ bad(at, 'duplicates state ' + target + ' in the target set');
599
+ else
600
+ targets.add(target);
601
+ });
602
+ }
603
+ }
604
+ else {
605
+ bad(path + '.to', 'must be a state id or a non-empty array of state ids');
606
+ }
607
+ }
608
+ else if (invariant.kind === 'sequence') {
609
+ if (!Array.isArray(invariant.events) || invariant.events.length === 0)
610
+ bad(path + '.events', 'must be a non-empty array');
611
+ else
612
+ invariant.events.forEach((event, eventIndex) => {
613
+ if (typeof event !== 'string' || event.length === 0)
614
+ bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string');
615
+ });
616
+ }
617
+ else if (invariant.kind === 'atomicity') {
618
+ if (!Array.isArray(invariant.events) || invariant.events.length === 0)
619
+ bad(path + '.events', 'must be a non-empty array');
620
+ else
621
+ invariant.events.forEach((event, eventIndex) => {
622
+ if (typeof event !== 'string' || event.length === 0)
623
+ bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string');
624
+ });
625
+ if (typeof invariant.commit !== 'string' || invariant.commit.length === 0)
626
+ bad(path + '.commit', 'must be a non-empty string');
627
+ if (invariant.rollback !== undefined && typeof invariant.rollback !== 'string')
628
+ bad(path + '.rollback', 'must be a string');
629
+ }
630
+ else if (invariant.kind === 'budget') {
631
+ if (typeof invariant.budget !== 'number' || !Number.isFinite(invariant.budget) || invariant.budget < 0)
632
+ bad(path + '.budget', 'must be a non-negative finite number');
633
+ }
634
+ else if (invariant.kind === 'probability') {
635
+ if (typeof invariant.target !== 'string' || invariant.target.length === 0)
636
+ bad(path + '.target', 'must be a non-empty string');
637
+ else if (!invariantStateIds.has(invariant.target))
638
+ bad(path + '.target', 'references unknown state ' + invariant.target);
639
+ if (invariant.op !== '>=' && invariant.op !== '<=' && invariant.op !== '>' && invariant.op !== '<')
640
+ bad(path + '.op', "must be one of '>=', '<=', '>', '<'");
641
+ if (typeof invariant.p !== 'number' || !Number.isFinite(invariant.p) || invariant.p < 0 || invariant.p > 1)
642
+ bad(path + '.p', 'must be a number in [0, 1]');
643
+ }
644
+ else {
645
+ bad(path + '.kind', 'unknown invariant kind');
646
+ }
647
+ });
648
+ }
649
+ if (root.concurrentPairs !== undefined) {
650
+ if (!Array.isArray(root.concurrentPairs))
651
+ bad('concurrentPairs', 'must be an array');
652
+ else
653
+ root.concurrentPairs.forEach((entry, index) => {
654
+ const path = 'concurrentPairs[' + index + ']';
655
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') {
656
+ bad(path, 'must be a [event, event] string pair');
657
+ }
658
+ });
659
+ }
660
+ if (root.boundaryChecks !== undefined) {
661
+ if (!Array.isArray(root.boundaryChecks))
662
+ bad('boundaryChecks', 'must be an array');
663
+ else
664
+ root.boundaryChecks.forEach((entry, index) => {
665
+ const path = 'boundaryChecks[' + index + ']';
666
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
667
+ bad(path, 'must be an object');
668
+ return;
669
+ }
670
+ const check = entry;
671
+ rejectUnknownKeys(check, MODEL_KEYS.boundaryCheck, path);
672
+ validateVariableRef(check.variable, path + '.variable');
673
+ if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number'))
674
+ bad(path + '.values', 'must be an array of numbers');
675
+ });
676
+ }
677
+ if (root.idempotentEvents !== undefined) {
678
+ if (!Array.isArray(root.idempotentEvents))
679
+ bad('idempotentEvents', 'must be an array');
680
+ else
681
+ root.idempotentEvents.forEach((entry, index) => {
682
+ if (typeof entry !== 'string' || entry.length === 0)
683
+ bad('idempotentEvents[' + index + ']', 'must be a non-empty string');
684
+ });
685
+ }
686
+ if (root.tickEvents !== undefined) {
687
+ if (!Array.isArray(root.tickEvents))
688
+ bad('tickEvents', 'must be an array');
689
+ else
690
+ root.tickEvents.forEach((entry, index) => {
691
+ if (typeof entry !== 'string' || entry.length === 0)
692
+ bad('tickEvents[' + index + ']', 'must be a non-empty string');
693
+ });
694
+ }
695
+ if (root.resourcePairs !== undefined) {
696
+ if (!Array.isArray(root.resourcePairs))
697
+ bad('resourcePairs', 'must be an array');
698
+ else
699
+ root.resourcePairs.forEach((entry, index) => {
700
+ const path = 'resourcePairs[' + index + ']';
701
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
702
+ bad(path, 'must be an object');
703
+ return;
704
+ }
705
+ const pair = entry;
706
+ rejectUnknownKeys(pair, MODEL_KEYS.resourcePair, path);
707
+ if (typeof pair.resource !== 'string' || pair.resource.length === 0)
708
+ bad(path + '.resource', 'must be a non-empty string');
709
+ if (typeof pair.acquireEvent !== 'string' || pair.acquireEvent.length === 0)
710
+ bad(path + '.acquireEvent', 'must be a non-empty string');
711
+ if (typeof pair.releaseEvent !== 'string' || pair.releaseEvent.length === 0)
712
+ bad(path + '.releaseEvent', 'must be a non-empty string');
713
+ if (pair.failEvent !== undefined && typeof pair.failEvent !== 'string')
714
+ bad(path + '.failEvent', 'must be a string');
715
+ });
716
+ }
717
+ if (root.narrative !== undefined) {
718
+ const narrativePath = 'narrative';
719
+ if (typeof root.narrative !== 'object' || root.narrative === null || Array.isArray(root.narrative)) {
720
+ bad(narrativePath, 'must be an object');
721
+ }
722
+ else {
723
+ const narrative = root.narrative;
724
+ rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath);
725
+ const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
726
+ const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
727
+ // A narrative is allowed to cover part of the model: states first, events and
728
+ // scenarios later is the natural authoring order, and `narrativeCoverage` reports
729
+ // what is still missing instead of rejecting the file. What stays an error is a
730
+ // narrative that is *wrong* (unknown id, empty description, duplicate scenario) or
731
+ // empty — an empty block claims documentation that does not exist.
732
+ const declaredDimensions = ['states', 'events', 'scenarios'].filter((key) => narrative[key] !== undefined);
733
+ if (declaredDimensions.length === 0) {
734
+ bad(narrativePath, 'declares no dimension: give at least one of states, events or scenarios (a partial narrative is fine)');
735
+ }
736
+ if (narrative.states !== undefined) {
737
+ if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
738
+ bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description');
739
+ }
740
+ else {
741
+ for (const [id, description] of Object.entries(narrative.states)) {
742
+ if (!stateIds.has(id))
743
+ bad(narrativePath + '.states', 'references unknown state ' + id);
744
+ if (typeof description !== 'string' || description.length === 0)
745
+ bad(narrativePath + '.states.' + id, 'must be a non-empty string');
746
+ }
747
+ }
748
+ }
749
+ if (narrative.events !== undefined) {
750
+ if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
751
+ bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description');
752
+ }
753
+ else {
754
+ for (const [id, description] of Object.entries(narrative.events)) {
755
+ if (!eventIds.has(id))
756
+ bad(narrativePath + '.events', 'references unknown event ' + id);
757
+ if (typeof description !== 'string' || description.length === 0)
758
+ bad(narrativePath + '.events.' + id, 'must be a non-empty string');
759
+ }
760
+ }
761
+ }
762
+ if (narrative.scenarios !== undefined) {
763
+ if (!Array.isArray(narrative.scenarios)) {
764
+ bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }');
765
+ }
766
+ else {
767
+ const seen = new Set();
768
+ narrative.scenarios.forEach((entry, index) => {
769
+ const scenarioPath = narrativePath + '.scenarios[' + index + ']';
770
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
771
+ bad(scenarioPath, 'must be an object');
772
+ return;
773
+ }
774
+ const scenario = entry;
775
+ rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath);
776
+ if (typeof scenario.from !== 'string' || scenario.from.length === 0)
777
+ bad(scenarioPath + '.from', 'must be a non-empty string');
778
+ else if (!stateIds.has(scenario.from))
779
+ bad(scenarioPath + '.from', 'unknown state ' + scenario.from);
780
+ if (typeof scenario.event !== 'string' || scenario.event.length === 0)
781
+ bad(scenarioPath + '.event', 'must be a non-empty string');
782
+ else if (!eventIds.has(scenario.event))
783
+ bad(scenarioPath + '.event', 'unknown event ' + scenario.event);
784
+ if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0)
785
+ bad(scenarioPath + '.scenario', 'must be a non-empty string');
786
+ const key = String(scenario.from) + '|' + String(scenario.event);
787
+ if (seen.has(key))
788
+ bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')');
789
+ seen.add(key);
790
+ });
791
+ }
792
+ }
793
+ }
794
+ }
795
+ // Guard/update variable references were validated structurally; re-walk for references.
796
+ if (Array.isArray(root.transitions)) {
797
+ for (const entry of root.transitions) {
798
+ walkGuardReferences(entry.guard, variableNames, errors, bad);
799
+ for (const update of entry.updates ?? []) {
800
+ if (!variableNames.has(update.variable))
801
+ bad('transitions.updates', 'references unknown variable ' + update.variable);
802
+ if (update.op !== 'set' && update.value !== undefined && update.value === 0) {
803
+ // zero delta is harmless; no finding
804
+ }
805
+ }
806
+ }
807
+ }
808
+ if (errors.length > 0)
809
+ return { ok: false, errors };
810
+ return { ok: true, model: root };
811
+ }
812
+ /**
813
+ * Event names in the adversarial declarations that no transition and no entry/exit action
814
+ * declares.
815
+ *
816
+ * The robustness audit called this the dominant defect class: a misspelled name made a check
817
+ * skip and then report its all-clear - "All pairs balanced", "No race conditions detected", "All
818
+ * deadlines respected" - for a model it never ran on. A reader cannot tell an empty check from a
819
+ * passing one, which is what a verifier must never do.
820
+ *
821
+ * A name that resolves to nothing is reported by the check that would have used it, not refused
822
+ * by validation, because for some fields the absence IS the defect: a
823
+ * `resourcePairs[].releaseEvent` naming a step nobody built is what `A4_NO_RELEASE_EVENT` says,
824
+ * and refusing it would replace a finding that names the missing step with a generic one.
825
+ */
826
+ export function unresolvedEventNames(root) {
827
+ const declared = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
828
+ // `onEntry`/`onExit` actions count: A4 treats them as implicit acquire/release events, so a
829
+ // lock modelled as an entry action is declared even though no transition names it.
830
+ if (Array.isArray(root.states)) {
831
+ for (const state of root.states) {
832
+ for (const action of state.onEntry ?? [])
833
+ declared.add(action);
834
+ for (const action of state.onExit ?? [])
835
+ declared.add(action);
836
+ }
837
+ }
838
+ const found = [];
839
+ const note = (value, path, check) => {
840
+ if (typeof value !== 'string' || declared.has(value))
841
+ return;
842
+ found.push({ path, value, check });
843
+ };
844
+ if (Array.isArray(root.concurrentPairs)) {
845
+ root.concurrentPairs.forEach((entry, index) => {
846
+ if (!Array.isArray(entry) || entry.length !== 2)
847
+ return;
848
+ note(entry[0], 'concurrentPairs[' + String(index) + '][0]', 'A2');
849
+ note(entry[1], 'concurrentPairs[' + String(index) + '][1]', 'A2');
850
+ });
851
+ }
852
+ if (Array.isArray(root.idempotentEvents)) {
853
+ root.idempotentEvents.forEach((entry, index) => note(entry, 'idempotentEvents[' + String(index) + ']', 'A8'));
854
+ }
855
+ if (Array.isArray(root.tickEvents)) {
856
+ root.tickEvents.forEach((entry, index) => note(entry, 'tickEvents[' + String(index) + ']', 'A14'));
857
+ }
858
+ if (Array.isArray(root.resourcePairs)) {
859
+ root.resourcePairs.forEach((entry, index) => {
860
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
861
+ return;
862
+ const pair = entry;
863
+ const path = 'resourcePairs[' + String(index) + ']';
864
+ note(pair.acquireEvent, path + '.acquireEvent', 'A4');
865
+ note(pair.releaseEvent, path + '.releaseEvent', 'A4');
866
+ note(pair.failEvent, path + '.failEvent', 'A6');
867
+ });
868
+ }
869
+ return found;
870
+ }
871
+ /**
872
+ * The check's own findings, plus a note for each declared event name it did not already account
873
+ * for.
874
+ *
875
+ * The severity is a warning, and that is a measured decision rather than a hedge. Three shapes
876
+ * exist, and the repository's own textbook cases pin all three:
877
+ *
878
+ * - the check can still run and reports the defect itself (`A4_NO_RELEASE_EVENT`,
879
+ * `A6_NO_FAILURE_HANDLER`): the check's code owns it and the sweep stays silent. The textbook
880
+ * semaphore case declares `failEvent: 'timeout'`, a name that exists nowhere, and expects A6
881
+ * to fire with no error - because "there is no timeout handler" is the defect;
882
+ * - the check cannot run at all because the name resolved to nothing (A2 with a misnamed pair,
883
+ * A8, A14, and A4's acquire event): its `detail` would otherwise read "No race conditions
884
+ * detected" beside a `fail` status, and the finding supplies what the `detail` cannot;
885
+ * - a check with nothing to run on for other reasons is a `skip`, and stays one.
886
+ *
887
+ * Raising it to an error would make a working model unshippable for a merely sloppy declaration,
888
+ * which is a different defect from a check that lied about what it examined.
889
+ */
890
+ function withUnresolved(model, check, findings) {
891
+ const accounted = new Set();
892
+ for (const finding of findings) {
893
+ const event = finding.evidence?.event;
894
+ if (typeof event === 'string')
895
+ accounted.add(event);
896
+ }
897
+ const extra = unresolvedEventNames(model)
898
+ .filter((entry) => entry.check === check && !accounted.has(entry.value))
899
+ .map((entry) => ({
900
+ code: 'MODEL_UNRESOLVED_EVENT',
901
+ severity: 'warning',
902
+ message: entry.path + ': no transition and no entry/exit action declares ' + JSON.stringify(entry.value) + ', so ' + entry.check + ' had nothing to run on it. Declare the event, or remove the declaration if it is a typo',
903
+ detail: 'While a declared name resolves to nothing, this check reports a result it did not earn. The rest of the report is unaffected: the name is a declaration defect, not a model defect.',
904
+ evidence: { path: entry.path, event: entry.value, check: entry.check },
905
+ }));
906
+ return extra.length === 0 ? findings : [...findings, ...extra];
907
+ }
908
+ /**
909
+ * How deeply a guard may nest.
910
+ *
911
+ * A guard is a boolean expression a person writes and reads. The limit is a structural prior
912
+ * rather than a runtime catch: the depth is decided while walking, so the walk must not itself be
913
+ * recursive - a recursive validator overflowed the stack BEFORE it could reach a depth of thirty
914
+ * two, which is the defect this limit was meant to prevent.
915
+ */
916
+ const MAX_GUARD_DEPTH = 32;
917
+ /**
918
+ * Validate a guard tree, iteratively.
919
+ *
920
+ * The walk uses an explicit stack for the reason above: recursion here would make the depth limit
921
+ * unreachable, and a guard nested ten thousand deep has to produce the refusal below rather than a
922
+ * `RangeError` the caller cannot route on.
923
+ */
924
+ function validateGuard(input, path, errors, bad) {
925
+ const stack = [{ node: input, path, depth: 0 }];
926
+ while (stack.length > 0) {
927
+ const frame = stack.pop();
928
+ const node = frame.node;
929
+ if (frame.depth > MAX_GUARD_DEPTH) {
930
+ bad(frame.path, 'nests more than ' + String(MAX_GUARD_DEPTH) + ' levels deep. A guard is a boolean expression over model variables; one this deep is not read by a person.');
931
+ continue;
932
+ }
933
+ if (typeof node !== 'object' || node === null || Array.isArray(node)) {
934
+ bad(frame.path, 'must be an object');
935
+ continue;
936
+ }
937
+ const guard = node;
938
+ if (guard.variable !== undefined) {
939
+ if (typeof guard.variable !== 'string' || guard.variable.length === 0)
940
+ bad(frame.path + '.variable', 'must be a non-empty string');
941
+ if (guard.op !== '==' && guard.op !== '!=' && guard.op !== '<' && guard.op !== '<=' && guard.op !== '>' && guard.op !== '>=') {
942
+ bad(frame.path + '.op', 'must be a comparison operator');
943
+ }
944
+ if (typeof guard.value !== 'number' && typeof guard.value !== 'boolean')
945
+ bad(frame.path + '.value', 'must be a number or boolean');
946
+ // A NaN operand makes every comparison false, so the branch would read as dead and the model
947
+ // would say it has a condition it can never satisfy.
948
+ else if (typeof guard.value === 'number' && !Number.isFinite(guard.value))
949
+ bad(frame.path + '.value', 'must be a finite number');
950
+ continue;
951
+ }
952
+ let pushed = false;
953
+ for (const key of ['all', 'any']) {
954
+ const children = guard[key];
955
+ if (children === undefined)
956
+ continue;
957
+ pushed = true;
958
+ if (!Array.isArray(children)) {
959
+ bad(frame.path + '.' + key, 'must be an array');
960
+ break;
961
+ }
962
+ if (children.length === 0) {
963
+ // Vacuously true for `all`, vacuously false for `any`: the first makes a guarded transition
964
+ // unconditional, the second makes a live branch dead.
965
+ bad(frame.path + '.' + key, key === 'all'
966
+ ? 'must not be empty: an empty conjunction is always true, which would make this transition unconditional while it reads as guarded'
967
+ : 'must not be empty: an empty disjunction is always false, so this transition can never fire');
968
+ break;
969
+ }
970
+ for (let index = children.length - 1; index >= 0; index -= 1) {
971
+ stack.push({ node: children[index], path: frame.path + '.' + key + '[' + String(index) + ']', depth: frame.depth + 1 });
972
+ }
973
+ break;
974
+ }
975
+ if (pushed)
976
+ continue;
977
+ if (guard.not !== undefined) {
978
+ stack.push({ node: guard.not, path: frame.path + '.not', depth: frame.depth + 1 });
979
+ continue;
980
+ }
981
+ bad(frame.path, 'must be a leaf ({ variable, op, value }), { all }, { any }, or { not }');
982
+ }
983
+ }
984
+ /**
985
+ * Every variable a guard names.
986
+ *
987
+ * Total over arbitrary JSON, and iterative for the same reason `validateGuard` is: this runs while
988
+ * validating, so a recursive walk overflows the stack on a deeply nested guard and the caller gets a
989
+ * `RangeError` instead of the refusal that says the guard is too deep. A node it does not recognise
990
+ * contributes no references, which is the safe direction - the guard is already an error, so nothing
991
+ * is decided from it either way.
992
+ */
993
+ function isGuardObject(value) {
994
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
995
+ }
996
+ function walkGuardReferences(guard, variableNames, errors, bad) {
997
+ const stack = [guard];
998
+ while (stack.length > 0) {
999
+ const node = stack.pop();
1000
+ if (node === undefined)
1001
+ continue;
1002
+ if (!isGuardObject(node))
1003
+ continue;
1004
+ if (node.variable !== undefined) {
1005
+ if (typeof node.variable !== 'string')
1006
+ continue;
1007
+ if (!variableNames.has(node.variable))
1008
+ bad('guard', 'references unknown variable ' + node.variable);
1009
+ continue;
1010
+ }
1011
+ let descended = false;
1012
+ for (const key of ['all', 'any']) {
1013
+ const children = node[key];
1014
+ if (children === undefined)
1015
+ continue;
1016
+ descended = true;
1017
+ // An empty array is the degenerate answer - true for `all`, false for `any` - and
1018
+ // `validateGuard` refuses it. Here it simply carries no references.
1019
+ if (!Array.isArray(children))
1020
+ break;
1021
+ for (const child of children)
1022
+ stack.push(child);
1023
+ break;
1024
+ }
1025
+ if (descended)
1026
+ continue;
1027
+ if (node.not !== undefined)
1028
+ stack.push(node.not);
1029
+ }
1030
+ }
1031
+ /**
1032
+ * Every variable a guard names, total and iterative for the same reasons as
1033
+ * `walkGuardReferences`.
1034
+ */
1035
+ export function guardVariables(guard) {
1036
+ const found = [];
1037
+ const stack = [guard];
1038
+ while (stack.length > 0) {
1039
+ const node = stack.pop();
1040
+ if (node === undefined)
1041
+ continue;
1042
+ if (!isGuardObject(node))
1043
+ continue;
1044
+ if (typeof node.variable === 'string') {
1045
+ found.push(node.variable);
1046
+ continue;
1047
+ }
1048
+ let descended = false;
1049
+ for (const key of ['all', 'any']) {
1050
+ const children = node[key];
1051
+ if (children === undefined)
1052
+ continue;
1053
+ descended = true;
1054
+ if (!Array.isArray(children))
1055
+ break;
1056
+ for (const child of children)
1057
+ stack.push(child);
1058
+ break;
1059
+ }
1060
+ if (descended)
1061
+ continue;
1062
+ if (node.not !== undefined)
1063
+ stack.push(node.not);
1064
+ }
1065
+ return found;
1066
+ }
1067
+ /**
1068
+ * Whether a guard holds, TOTAL over arbitrary JSON.
1069
+ *
1070
+ * A node that is not one of the four shapes is a guard that is never true, so a malformed guard
1071
+ * cannot fire a transition. Safely false is the right answer rather than a throw: the validator has
1072
+ * already refused the model, and a review that crashes tells the caller less than a review that
1073
+ * names the field that is wrong.
1074
+ */
1075
+ function evalGuard(guard, vars) {
1076
+ if (!isGuardObject(guard))
1077
+ return false;
1078
+ if (typeof guard.variable === 'string') {
1079
+ const actual = vars[guard.variable];
1080
+ switch (guard.op) {
1081
+ case '==': return actual === guard.value;
1082
+ case '!=': return actual !== guard.value;
1083
+ case '<': return actual < guard.value;
1084
+ case '<=': return actual <= guard.value;
1085
+ case '>': return actual > guard.value;
1086
+ case '>=': return actual >= guard.value;
1087
+ }
1088
+ return false;
1089
+ }
1090
+ for (const key of ['all', 'any']) {
1091
+ const children = guard[key];
1092
+ if (children === undefined)
1093
+ continue;
1094
+ // An empty conjunction is true and an empty disjunction is false, which is also what
1095
+ // every/some do. The validator refuses both; this keeps the evaluator total for a model that
1096
+ // arrived anyway.
1097
+ if (!Array.isArray(children))
1098
+ return false;
1099
+ return key === 'all'
1100
+ ? children.every((child) => evalGuard(child, vars))
1101
+ : children.some((child) => evalGuard(child, vars));
1102
+ }
1103
+ if (guard.not !== undefined)
1104
+ return !evalGuard(guard.not, vars);
1105
+ return false;
1106
+ }
1107
+ function initialState(model) {
1108
+ const vars = {};
1109
+ for (const variable of model.variables ?? [])
1110
+ vars[variable.name] = variable.init;
1111
+ return { state: model.init, vars };
1112
+ }
1113
+ function runtimeKey(runtime) {
1114
+ return runtime.state + '|' + stableStringify(runtime.vars);
1115
+ }
1116
+ function applyUpdates(model, transition, runtime) {
1117
+ const vars = { ...runtime.vars };
1118
+ for (const update of transition.updates ?? []) {
1119
+ const current = vars[update.variable];
1120
+ if (update.op === 'set')
1121
+ vars[update.variable] = update.value ?? 0;
1122
+ else if (update.op === 'inc')
1123
+ vars[update.variable] = ((typeof current === 'number' ? current : 0) + (update.value ?? 1));
1124
+ else
1125
+ vars[update.variable] = ((typeof current === 'number' ? current : 0) - (update.value ?? 1));
1126
+ }
1127
+ return { state: transition.to, vars };
1128
+ }
1129
+ function groupTransitions(model) {
1130
+ const groups = new Map();
1131
+ for (const transition of model.transitions) {
1132
+ const key = transition.from + '|' + transition.event;
1133
+ const list = groups.get(key) ?? [];
1134
+ list.push(transition);
1135
+ groups.set(key, list);
1136
+ }
1137
+ return groups;
1138
+ }
1139
+ function applicableTransitions(group, runtime) {
1140
+ const guarded = [];
1141
+ const unguarded = [];
1142
+ for (const transition of group) {
1143
+ if (transition.guard === undefined)
1144
+ unguarded.push(transition);
1145
+ else
1146
+ guarded.push(transition);
1147
+ }
1148
+ const matched = guarded.filter((transition) => evalGuard(transition.guard, runtime.vars));
1149
+ if (matched.length > 0)
1150
+ return matched;
1151
+ return unguarded;
1152
+ }
1153
+ function stepRuntime(model, runtime, event) {
1154
+ const group = groupTransitions(model).get(runtime.state + '|' + event);
1155
+ if (group === undefined || group.length === 0)
1156
+ return [];
1157
+ const seen = new Set();
1158
+ const outcomes = [];
1159
+ for (const transition of applicableTransitions(group, runtime)) {
1160
+ const next = applyUpdates(model, transition, runtime);
1161
+ const key = runtimeKey(next);
1162
+ if (!seen.has(key)) {
1163
+ seen.add(key);
1164
+ outcomes.push(next);
1165
+ }
1166
+ }
1167
+ return outcomes;
1168
+ }
1169
+ function explore(model, options) {
1170
+ const init = initialState(model);
1171
+ const reachable = [];
1172
+ const reachableKeys = new Set();
1173
+ const queue = [init];
1174
+ let truncated = false;
1175
+ while (queue.length > 0) {
1176
+ const runtime = queue.shift();
1177
+ const key = runtimeKey(runtime);
1178
+ if (reachableKeys.has(key))
1179
+ continue;
1180
+ reachableKeys.add(key);
1181
+ reachable.push(runtime);
1182
+ if (reachable.length > options.maxStates) {
1183
+ truncated = true;
1184
+ break;
1185
+ }
1186
+ for (const event of allEvents(model)) {
1187
+ for (const next of stepRuntime(model, runtime, event)) {
1188
+ if (!reachableKeys.has(runtimeKey(next)))
1189
+ queue.push(next);
1190
+ }
1191
+ }
1192
+ }
1193
+ return { reachable, reachableKeys, truncated, initialState: init };
1194
+ }
1195
+ function allEvents(model) {
1196
+ return [...new Set(model.transitions.map((transition) => transition.event))].sort();
1197
+ }
1198
+ function stateById(model, id) {
1199
+ return model.states.find((state) => state.id === id);
1200
+ }
1201
+ function isTerminal(model, state) {
1202
+ return stateById(model, state)?.terminal === true;
1203
+ }
1204
+ function uniqueTargets(transitions) {
1205
+ return [...new Set(transitions.map((transition) => transition.to))];
1206
+ }
1207
+ /**
1208
+ * What a check would have needed in order to examine anything, or `undefined` if it had it.
1209
+ *
1210
+ * The verification family had no way to say "I did not look": a check whose vocabulary was empty
1211
+ * returned `pass` with a detail like "No budget invariants declared", and `summary.checksRun` counted
1212
+ * it as run. The design family has always reported `skip` for the same situation, and this file's own
1213
+ * doctrine says a check with nothing to run on "is a `skip`, and stays one" - so `pass` here was the
1214
+ * false green the repository forbids, not a stylistic difference.
1215
+ *
1216
+ * The table is one place on purpose. Eleven return sites across eleven functions would each have to
1217
+ * remember, and the twelfth would be the one that forgot. A check that produced a finding is never
1218
+ * turned into a skip: the finding is evidence that something was examined, and a name that resolved to
1219
+ * nothing is exactly the case where the check cannot run but must still fail loudly.
1220
+ */
1221
+ function nothingToExamine(id, model) {
1222
+ const invariantsOfKind = (kind) => (model.invariants ?? []).filter((invariant) => invariant.kind === kind).length;
1223
+ switch (id) {
1224
+ case 'S7':
1225
+ // S7 decides state predicates. A roster of path-property invariants alone leaves it nothing to decide,
1226
+ // and "All invariants hold" over that roster is a pass over nothing: the verdict belongs to A9-A13.
1227
+ return (model.invariants ?? []).filter((invariant) => !isPathPropertyInvariant(invariant)).length === 0
1228
+ ? 'no never-states or var-in-range invariant was declared'
1229
+ : undefined;
1230
+ case 'A7':
1231
+ return (model.invariants ?? []).length === 0 ? 'no invariants were declared' : undefined;
1232
+ case 'A2':
1233
+ case 'A4':
1234
+ return (model.concurrentPairs ?? []).length === 0 ? 'no concurrent pairs were declared' : undefined;
1235
+ case 'A3': {
1236
+ const events = new Set(model.transitions.map((transition) => transition.event));
1237
+ return events.size < 2 ? 'fewer than two distinct events to permute' : undefined;
1238
+ }
1239
+ case 'A5':
1240
+ return (model.boundaryChecks ?? []).length === 0 ? 'no boundary checks were declared' : undefined;
1241
+ case 'A6':
1242
+ return (model.resourcePairs ?? []).length === 0 ? 'no resource pairs were declared' : undefined;
1243
+ case 'A8':
1244
+ return (model.idempotentEvents ?? []).length === 0 ? 'no idempotent events were declared' : undefined;
1245
+ case 'S8':
1246
+ return (model.variables ?? []).length === 0 ? 'no variables were declared' : undefined;
1247
+ case 'S6':
1248
+ return model.transitions.some((transition) => transition.guard !== undefined) ? undefined : 'no transition declares a guard';
1249
+ case 'A9':
1250
+ return invariantsOfKind('leads-to') === 0 ? 'no leads-to invariant was declared' : undefined;
1251
+ case 'A10':
1252
+ return invariantsOfKind('sequence') === 0 ? 'no sequence invariant was declared' : undefined;
1253
+ case 'A11':
1254
+ return invariantsOfKind('atomicity') === 0 ? 'no atomicity invariant was declared' : undefined;
1255
+ case 'A12':
1256
+ return invariantsOfKind('budget') === 0 ? 'no budget invariant was declared' : undefined;
1257
+ case 'A13':
1258
+ return invariantsOfKind('probability') === 0 ? 'no probability invariant was declared' : undefined;
1259
+ case 'A14':
1260
+ return model.states.some((state) => state.maxTicks !== undefined) ? undefined : 'no state declares maxTicks';
1261
+ default:
1262
+ return undefined;
1263
+ }
1264
+ }
1265
+ function checkResult(id, name, findings, detail) {
1266
+ const errors = findings.filter((finding) => finding.severity === 'error').length;
1267
+ const warnings = findings.filter((finding) => finding.severity === 'warning').length;
1268
+ return {
1269
+ id,
1270
+ name,
1271
+ // An info finding is a note, not a defect: it must not flip a check to `fail`, or the
1272
+ // check would be dragged into the "resolve the failing checks" list for a remark.
1273
+ status: errors + warnings === 0 ? 'pass' : 'fail',
1274
+ detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
1275
+ findings,
1276
+ };
1277
+ }
1278
+ // ---------------------------------------------------------------------------
1279
+ // S1-S8
1280
+ // ---------------------------------------------------------------------------
1281
+ function S1_reachability(model, exploration) {
1282
+ const reached = new Set(exploration.reachable.map((runtime) => runtime.state));
1283
+ const unreachable = model.states.filter((state) => !reached.has(state.id)).map((state) => state.id);
1284
+ const findings = unreachable.map((state) => ({
1285
+ code: 'S1_UNREACHABLE_STATE',
1286
+ severity: 'warning',
1287
+ message: 'State ' + state + ' is not reachable from init.',
1288
+ }));
1289
+ return checkResult('S1', 'Reachability', findings, unreachable.length === 0 ? 'All states reachable' : 'Unreachable states: ' + unreachable.join(', '));
1290
+ }
1291
+ function S2_deadlock(model) {
1292
+ const outgoing = new Set(model.transitions.map((transition) => transition.from));
1293
+ const findings = [];
1294
+ for (const state of model.states) {
1295
+ if (state.terminal === true)
1296
+ continue;
1297
+ if (!outgoing.has(state.id)) {
1298
+ findings.push({
1299
+ code: 'S2_NO_TRANSITIONS',
1300
+ severity: 'error',
1301
+ message: 'Non-terminal state ' + state.id + ' has no outgoing transitions.',
1302
+ });
1303
+ }
1304
+ }
1305
+ return checkResult('S2', 'Deadlock', findings, findings.length === 0 ? 'No deadlocks' : 'Deadlocks: ' + findings.map((finding) => finding.message).join('; '));
1306
+ }
1307
+ function sccs(model) {
1308
+ const states = model.states.map((state) => state.id);
1309
+ const edges = new Map();
1310
+ for (const state of states)
1311
+ edges.set(state, []);
1312
+ for (const transition of model.transitions) {
1313
+ const list = edges.get(transition.from);
1314
+ if (list !== undefined)
1315
+ list.push(transition.to);
1316
+ }
1317
+ let index = 0;
1318
+ const indices = new Map();
1319
+ const low = new Map();
1320
+ const onStack = new Set();
1321
+ const stack = [];
1322
+ const components = [];
1323
+ const visit = (node) => {
1324
+ indices.set(node, index);
1325
+ low.set(node, index);
1326
+ index += 1;
1327
+ stack.push(node);
1328
+ onStack.add(node);
1329
+ for (const next of edges.get(node) ?? []) {
1330
+ if (!indices.has(next)) {
1331
+ visit(next);
1332
+ low.set(node, Math.min(low.get(node), low.get(next)));
1333
+ }
1334
+ else if (onStack.has(next)) {
1335
+ low.set(node, Math.min(low.get(node), indices.get(next)));
1336
+ }
1337
+ }
1338
+ if (low.get(node) === indices.get(node)) {
1339
+ const component = [];
1340
+ while (stack.length > 0) {
1341
+ const member = stack.pop();
1342
+ onStack.delete(member);
1343
+ component.push(member);
1344
+ if (member === node)
1345
+ break;
1346
+ }
1347
+ components.push(component);
1348
+ }
1349
+ };
1350
+ for (const state of states) {
1351
+ if (!indices.has(state))
1352
+ visit(state);
1353
+ }
1354
+ return components;
1355
+ }
1356
+ function S3_liveness(model) {
1357
+ const components = sccs(model);
1358
+ const findings = [];
1359
+ for (const component of components) {
1360
+ const componentSet = new Set(component);
1361
+ const internalEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && componentSet.has(transition.to));
1362
+ const escapingEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to));
1363
+ if (internalEdges.length === 0 || escapingEdges.length > 0)
1364
+ continue;
1365
+ if (component.some((state) => isTerminal(model, state)))
1366
+ continue;
1367
+ findings.push({
1368
+ code: 'S3_CLOSED_SCC',
1369
+ severity: 'error',
1370
+ message: 'Closed SCC has no exit and contains no terminal state: ' + component.sort().join(', '),
1371
+ evidence: { states: component.sort(), transitions: internalEdges.length },
1372
+ });
1373
+ }
1374
+ return checkResult('S3', 'Liveness', findings, findings.length === 0 ? 'No harmful closed SCCs' : 'Closed SCCs: ' + findings.length);
1375
+ }
1376
+ function assignmentsFor(model, variables) {
1377
+ const specs = (model.variables ?? []).filter((variable) => variables.includes(variable.name));
1378
+ let combinations = [{}];
1379
+ for (const spec of specs) {
1380
+ let values;
1381
+ if (spec.kind === 'boolean')
1382
+ values = [false, true];
1383
+ else {
1384
+ const constants = collectGuardConstants(model).filter((value) => guardLeaves(model).some((leaf) => leaf.variable === spec.name && typeof leaf.value === 'number' && leaf.value === value));
1385
+ if (spec.min !== undefined && spec.max !== undefined) {
1386
+ values = [];
1387
+ for (let value = spec.min; value <= spec.max && value <= spec.min + 200; value++)
1388
+ values.push(value);
1389
+ }
1390
+ else {
1391
+ const set = new Set([-1, 0, 1, ...constants]);
1392
+ values = [...set].sort((a, b) => a - b);
1393
+ }
1394
+ }
1395
+ const next = [];
1396
+ for (const assignment of combinations) {
1397
+ for (const value of values) {
1398
+ next.push({ ...assignment, [spec.name]: value });
1399
+ if (next.length > 10_000)
1400
+ return null;
1401
+ }
1402
+ }
1403
+ combinations = next;
1404
+ }
1405
+ return combinations;
1406
+ }
1407
+ function guardLeaves(model) {
1408
+ const leaves = [];
1409
+ const visit = (guard) => {
1410
+ if ('variable' in guard)
1411
+ leaves.push(guard);
1412
+ else if ('all' in guard)
1413
+ guard.all.forEach(visit);
1414
+ else if ('any' in guard)
1415
+ guard.any.forEach(visit);
1416
+ else if ('not' in guard)
1417
+ visit(guard.not);
1418
+ };
1419
+ for (const transition of model.transitions)
1420
+ if (transition.guard !== undefined)
1421
+ visit(transition.guard);
1422
+ return leaves;
1423
+ }
1424
+ function collectGuardConstants(model) {
1425
+ return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value);
1426
+ }
1427
+ /** How a variable value prints in a message. Shared by every finding that quotes one. */
1428
+ function varValueLabel(value) {
1429
+ return typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value);
1430
+ }
1431
+ /** Deterministic value order: booleans first, then numbers ascending. */
1432
+ function compareVarValues(a, b) {
1433
+ const rank = (value) => typeof value === 'number' ? [1, value] : [0, value ? 1 : 0];
1434
+ const [rankA, numberA] = rank(a);
1435
+ const [rankB, numberB] = rank(b);
1436
+ return rankA === rankB ? numberA - numberB : rankA - rankB;
1437
+ }
1438
+ /**
1439
+ * Values each variable can actually hold, as an over-approximation: `init` plus every
1440
+ * `set` constant the model writes. `null` means "unknown", which is what an `inc` or
1441
+ * `dec` update produces — the reachable set is then unbounded and nothing can be
1442
+ * concluded from it.
1443
+ *
1444
+ * The union is taken across the whole model rather than per state, so the answer can
1445
+ * only be too large, never too small. A guard this function calls dead is therefore
1446
+ * dead in every state, while a live guard may still be unreachable in a particular one.
1447
+ */
1448
+ function attainableValues(model) {
1449
+ const attainable = new Map();
1450
+ for (const variable of model.variables ?? [])
1451
+ attainable.set(variable.name, new Set([variable.init]));
1452
+ for (const transition of model.transitions) {
1453
+ for (const update of transition.updates ?? []) {
1454
+ if (!attainable.has(update.variable))
1455
+ continue;
1456
+ if (update.op === 'inc' || update.op === 'dec') {
1457
+ attainable.set(update.variable, null);
1458
+ continue;
1459
+ }
1460
+ const values = attainable.get(update.variable);
1461
+ if (values === null || values === undefined)
1462
+ continue;
1463
+ values.add(update.value ?? 0);
1464
+ }
1465
+ }
1466
+ return attainable;
1467
+ }
1468
+ /**
1469
+ * Guarded transitions whose guard no reachable variable value can satisfy: the branch
1470
+ * is dead code, and a model that relies on it is not the model the author wrote. This
1471
+ * is the dual of S6_INCOMPLETE_GUARD — a group that never reaches a branch, rather
1472
+ * than one that falls through every branch.
1473
+ *
1474
+ * The test runs over {@link attainableValues}, not over the declared domain: a guard
1475
+ * on a variable the model only ever sets to one of its declared values is satisfiable
1476
+ * on paper and dead in practice, which is exactly the defect worth reporting.
1477
+ */
1478
+ function deadGuardFindings(model) {
1479
+ const attainable = attainableValues(model);
1480
+ const findings = [];
1481
+ for (const [key, group] of groupTransitions(model)) {
1482
+ const [from, event] = key.split('|');
1483
+ for (const transition of group) {
1484
+ if (transition.guard === undefined)
1485
+ continue;
1486
+ const names = [...new Set(guardVariables(transition.guard))].filter((name) => attainable.has(name)).sort();
1487
+ if (names.length === 0)
1488
+ continue;
1489
+ const domains = [];
1490
+ let known = true;
1491
+ for (const name of names) {
1492
+ const values = attainable.get(name);
1493
+ if (values === undefined || values === null || values.size === 0) {
1494
+ known = false;
1495
+ break;
1496
+ }
1497
+ domains.push(values);
1498
+ }
1499
+ if (!known)
1500
+ continue;
1501
+ let size = 1;
1502
+ for (const values of domains)
1503
+ size *= values.size;
1504
+ if (size > 10_000)
1505
+ continue;
1506
+ const assignment = {};
1507
+ const satisfiable = (index) => {
1508
+ if (index === names.length)
1509
+ return evalGuard(transition.guard, assignment);
1510
+ for (const value of [...domains[index]].sort(compareVarValues)) {
1511
+ assignment[names[index]] = value;
1512
+ if (satisfiable(index + 1))
1513
+ return true;
1514
+ }
1515
+ return false;
1516
+ };
1517
+ if (satisfiable(0))
1518
+ continue;
1519
+ findings.push({
1520
+ code: 'S6_DEAD_GUARD',
1521
+ severity: 'warning',
1522
+ message: from + ' + ' + event + ' has a guard that can never hold: ' + names.map((name) => name + ' is only ever ' + [...attainable.get(name)].sort(compareVarValues).map(varValueLabel).join('/')).join(', ') + '. The branch is dead.',
1523
+ evidence: { guard: transition.guard, attainable: names.map((name) => ({ variable: name, values: [...attainable.get(name)].sort(compareVarValues).map(varValueLabel) })) },
1524
+ });
1525
+ }
1526
+ }
1527
+ return findings;
1528
+ }
1529
+ function analyzeGuards(model) {
1530
+ const determinism = [];
1531
+ const completeness = [];
1532
+ let truncated = false;
1533
+ const groups = groupTransitions(model);
1534
+ for (const [key, group] of groups) {
1535
+ const [from, event] = key.split('|');
1536
+ const unguarded = group.filter((transition) => transition.guard === undefined);
1537
+ const guarded = group.filter((transition) => transition.guard !== undefined);
1538
+ if (unguarded.length > 1) {
1539
+ determinism.push({
1540
+ code: 'S4_AMBIGUOUS_DEFAULT',
1541
+ severity: 'error',
1542
+ message: from + ' + ' + event + ' has multiple unconditional transitions: ' + uniqueTargets(unguarded).join(', '),
1543
+ });
1544
+ }
1545
+ if (guarded.length === 0)
1546
+ continue;
1547
+ const variables = [...new Set(group.flatMap((transition) => guardVariables(transition.guard)))];
1548
+ const assignments = assignmentsFor(model, variables);
1549
+ if (assignments === null) {
1550
+ completeness.push({ code: 'S6_UNBOUNDED_DOMAIN', severity: 'warning', message: from + ' + ' + event + ': guard domain too large to enumerate; exhaustive check skipped.' });
1551
+ truncated = true;
1552
+ continue;
1553
+ }
1554
+ const targets = uniqueTargets(group);
1555
+ for (const assignment of assignments) {
1556
+ const trueBranches = guarded.filter((transition) => evalGuard(transition.guard, assignment));
1557
+ if (trueBranches.length > 1) {
1558
+ determinism.push({
1559
+ code: 'S4_NONDETERMINISTIC_GUARDS',
1560
+ severity: 'error',
1561
+ message: from + ' + ' + event + ' has ' + trueBranches.length + ' simultaneously true guards for assignment ' + stableStringify(assignment) + ': ' + uniqueTargets(trueBranches).join(', '),
1562
+ evidence: { assignment },
1563
+ });
1564
+ }
1565
+ if (trueBranches.length === 0 && unguarded.length === 0) {
1566
+ completeness.push({
1567
+ code: 'S6_INCOMPLETE_GUARD',
1568
+ severity: 'error',
1569
+ message: from + ' + ' + event + ' has no true guard and no default branch for assignment ' + stableStringify(assignment),
1570
+ evidence: { assignment, branches: targets },
1571
+ });
1572
+ }
1573
+ if (determinism.length + completeness.length > 200) {
1574
+ truncated = true;
1575
+ break;
1576
+ }
1577
+ }
1578
+ if (truncated)
1579
+ break;
1580
+ }
1581
+ completeness.push(...deadGuardFindings(model));
1582
+ return { determinism, completeness, truncated };
1583
+ }
1584
+ function S4_determinism(model) {
1585
+ const analysis = analyzeGuards(model);
1586
+ return checkResult('S4', 'Determinism', analysis.determinism, analysis.determinism.length === 0 ? 'Deterministic' : 'Nondeterminism findings: ' + analysis.determinism.length);
1587
+ }
1588
+ /**
1589
+ * A variable whose reachable domain is provably infinite, decided from the model rather than from
1590
+ * a search that ran out of room.
1591
+ *
1592
+ * The engine already bounds every exploration with `steps > options.maxStates`, and reports a
1593
+ * search that hit the bound as undecided rather than as a pass. That is the right report, but it
1594
+ * is the wrong way to find out: the audit got a 4 GB heap and a fatal error from a `NaN` bound,
1595
+ * and a caller who passes no bound at all still pays for ten thousand states before being told the
1596
+ * answer was never decidable.
1597
+ *
1598
+ * Whether the space is infinite is a property of the model, and it can be read off the transition
1599
+ * graph:
1600
+ *
1601
+ * - a variable is MONOTONE over a cycle when every update to it on that cycle moves the same way
1602
+ * (`inc`, or `dec`). An unrelated update to another variable does not break it;
1603
+ * - if such a cycle is reachable from the initial state and nothing on it stops the climb, then
1604
+ * repeating the cycle yields a distinct runtime state every time. The reachable set is infinite,
1605
+ * so NO bound decides the question - not a larger one, not a smaller one;
1606
+ * - two things stop a climb, and both are read from the model: a `set` on the cycle pins the value
1607
+ * to a constant, and a guard that fails as the value moves that way disables the cycle. A declared
1608
+ * `min`/`max` does not stop it - `inc`/`dec` are never clamped to the declaration, so a counter can
1609
+ * walk past its own range (which S7 then reports);
1610
+ *
1611
+ * One direction is enough to make the space infinite, and any monotone direction counts, so this
1612
+ * reports the variable rather than trying to be exhaustive about which check suffers.
1613
+ *
1614
+ * The same reasoning answers the modelling question the audit's D-series kept circling: a model
1615
+ * whose state space is infinite cannot be refuted by enumeration, so "this model cannot be decided
1616
+ * by search" is itself a review result rather than an internal detail. It is reported as an error
1617
+ * for that reason - it is not the reviewer's problem, it is a gap in the model.
1618
+ */
1619
+ function unboundedDomainFindings(model) {
1620
+ const variables = new Map();
1621
+ for (const variable of model.variables ?? [])
1622
+ variables.set(variable.name, variable);
1623
+ // The state graph: which states each transition connects, taken from the transitions alone. No
1624
+ // runtime state is constructed, which is the point - this runs before any exploration.
1625
+ const successors = new Map();
1626
+ for (const state of model.states)
1627
+ successors.set(state.id, []);
1628
+ for (const transition of model.transitions) {
1629
+ if (transition.to === undefined)
1630
+ continue;
1631
+ const out = successors.get(transition.from);
1632
+ if (out === undefined)
1633
+ continue;
1634
+ for (const to of String(transition.to).split('|')) {
1635
+ if (successors.has(to))
1636
+ out.push({ to, step: { from: transition.from, event: transition.event, to } });
1637
+ }
1638
+ }
1639
+ const findings = [];
1640
+ const reported = new Set();
1641
+ // Only states reachable from the initial one matter: a monotone cycle on an island no event
1642
+ // reaches does not make the reachable set infinite, and reporting it would be a defect invented
1643
+ // by the analysis rather than found in the model.
1644
+ const start = initialState(model);
1645
+ const reachable = new Set([start.state]);
1646
+ const frontier = [start.state];
1647
+ while (frontier.length > 0) {
1648
+ const id = frontier.pop();
1649
+ for (const edge of successors.get(id) ?? []) {
1650
+ if (reachable.has(edge.to))
1651
+ continue;
1652
+ reachable.add(edge.to);
1653
+ frontier.push(edge.to);
1654
+ }
1655
+ }
1656
+ for (const state of model.states) {
1657
+ if (!reachable.has(state.id))
1658
+ continue;
1659
+ // Depth-first over the state graph with an explicit stack, so a deep model cannot overflow it.
1660
+ const onPath = new Map();
1661
+ const path = [];
1662
+ const visited = new Set();
1663
+ const stack = [{ id: state.id, index: 0 }];
1664
+ onPath.set(state.id, 0);
1665
+ while (stack.length > 0) {
1666
+ const frame = stack[stack.length - 1];
1667
+ const edges = (successors.get(frame.id) ?? []).filter((edge) => reachable.has(edge.to));
1668
+ if (frame.index >= edges.length) {
1669
+ onPath.delete(frame.id);
1670
+ visited.add(frame.id);
1671
+ stack.pop();
1672
+ path.pop();
1673
+ continue;
1674
+ }
1675
+ const edge = edges[frame.index++];
1676
+ const seenAt = onPath.get(edge.to);
1677
+ if (seenAt !== undefined) {
1678
+ // A cycle: the edges from `seenAt` on the current path, plus this closing edge.
1679
+ const cycleSteps = [...path.slice(seenAt).map((entry) => entry.step), edge.step];
1680
+ for (const variable of variables.values()) {
1681
+ if (variable.kind !== 'integer')
1682
+ continue;
1683
+ if (reported.has(variable.name))
1684
+ continue;
1685
+ const analysis = monotoneOverCycle(model, cycleSteps, variable);
1686
+ if (analysis === undefined)
1687
+ continue;
1688
+ reported.add(variable.name);
1689
+ const advice = analysis.direction === 'inc'
1690
+ ? 'a guard that pins it from above (`{ "variable": "' + variable.name + '", "op": "<", "value": 4 }`)'
1691
+ : 'a guard that pins it from below (`{ "variable": "' + variable.name + '", "op": ">", "value": 0 }`)';
1692
+ findings.push({
1693
+ code: 'S6_UNBOUNDED_VARIABLE',
1694
+ severity: 'error',
1695
+ message: 'the reachable state space is infinite: ' + variable.name + ' is only ever ' + analysis.word + ' around the cycle ' + cycleSteps.map((step) => step.from + ' -> ' + step.to).join(' -> ') + ', and nothing on that cycle stops it.',
1696
+ detail: 'Repeating this cycle produces a distinct runtime state every time, so no search bound decides any question about this model: a larger bound walks further without deciding, and a smaller one reports truncation sooner. Declare ' + advice + ', or a `set` that resets it on the cycle, to make the model decidable. A declared min/max does not bound it: inc/dec are never clamped.',
1697
+ evidence: { variable: variable.name, direction: analysis.direction, events: cycleSteps.map((step) => step.event) },
1698
+ });
1699
+ }
1700
+ continue;
1701
+ }
1702
+ if (visited.has(edge.to))
1703
+ continue;
1704
+ onPath.set(edge.to, path.length + 1);
1705
+ path.push(edge);
1706
+ stack.push({ id: edge.to, index: 0 });
1707
+ }
1708
+ }
1709
+ return findings;
1710
+ }
1711
+ const NEGATED_OP = { '<': '>=', '<=': '>', '>': '<=', '>=': '<', '==': '!=', '!=': '==' };
1712
+ /** Whether `op` is a comparison that stops holding once the value moves far enough in `direction`. */
1713
+ function pinningComparison(op, direction) {
1714
+ if (op === '==')
1715
+ return true;
1716
+ return direction === 'inc' ? op === '<' || op === '<=' : op === '>' || op === '>=';
1717
+ }
1718
+ /**
1719
+ * Whether a guard pins `variable` against a climb in `direction`, i.e. eventually disables the transition.
1720
+ *
1721
+ * Only shapes that certainly fail count, because the finding this feeds accuses the model of an infinite
1722
+ * reachable set, and a guess in that direction is a false accusation:
1723
+ *
1724
+ * - `all` is a conjunction, so one pinning clause is enough; `any` is a disjunction, so every branch has to pin;
1725
+ * - a negation is read through De Morgan rather than skipped, so `not { n >= 40 }` counts as `n < 40`;
1726
+ * - a guard on another variable says nothing about this one.
1727
+ */
1728
+ function guardPinsVariable(guard, variable, direction, negated = false) {
1729
+ if (isStateGuard(guard))
1730
+ return false;
1731
+ if ('all' in guard) {
1732
+ return negated
1733
+ ? guard.all.every((part) => guardPinsVariable(part, variable, direction, true))
1734
+ : guard.all.some((part) => guardPinsVariable(part, variable, direction, false));
1735
+ }
1736
+ if ('any' in guard) {
1737
+ return negated
1738
+ ? guard.any.some((part) => guardPinsVariable(part, variable, direction, true))
1739
+ : guard.any.every((part) => guardPinsVariable(part, variable, direction, false));
1740
+ }
1741
+ if ('not' in guard)
1742
+ return guardPinsVariable(guard.not, variable, direction, !negated);
1743
+ if (guard.variable !== variable)
1744
+ return false;
1745
+ return pinningComparison(negated ? NEGATED_OP[guard.op] : guard.op, direction);
1746
+ }
1747
+ /**
1748
+ * Whether one variable climbs one way along the whole cycle and nothing stops it.
1749
+ *
1750
+ * Three things stop a climb, and the analysis has to read all three because it reports "the reachable state
1751
+ * space is infinite":
1752
+ *
1753
+ * - a `set` anywhere on the cycle pins the value to a constant, so lap after lap cannot climb (the constant's
1754
+ * own value does not matter);
1755
+ * - a guard that fails as the value moves that way disables the cycle, which is how the schema reference tells
1756
+ * a modeller to bound a counter;
1757
+ * - a declared `min`/`max` does NOT stop it: `inc`/`dec` are never clamped to the declaration, so a model that
1758
+ * declares `max: 5` and increments unguarded climbs past 5. S7 reports the range violation that proves it,
1759
+ * and reading the declaration as a bound here made the two checks disagree about the same model.
1760
+ *
1761
+ * A `dec` on the same cycle means it does not climb after all, and `undefined` says so.
1762
+ */
1763
+ function monotoneOverCycle(model, steps, variable) {
1764
+ let direction;
1765
+ const moving = [];
1766
+ for (const step of steps) {
1767
+ const matching = model.transitions.filter((transition) => transition.from === step.from && transition.event === step.event && transition.to === step.to);
1768
+ // Only the transitions that actually move the variable matter: a branch of the same event that leaves it alone
1769
+ // neither climbs nor stops a climb, and counting it as an unpinned climber would accuse a bounded model.
1770
+ const climbers = matching.filter((transition) => (transition.updates ?? []).some((update) => update.variable === variable.name && update.op !== 'set'));
1771
+ for (const transition of matching) {
1772
+ for (const update of transition.updates ?? []) {
1773
+ if (update.variable !== variable.name)
1774
+ continue;
1775
+ // A `set` fixes the value at a constant, so no lap sequence climbs without bound.
1776
+ if (update.op === 'set')
1777
+ return undefined;
1778
+ if (direction === undefined)
1779
+ direction = update.op;
1780
+ else if (direction !== update.op)
1781
+ return undefined;
1782
+ }
1783
+ }
1784
+ if (climbers.length > 0)
1785
+ moving.push({ op: direction, climbers });
1786
+ }
1787
+ if (direction === undefined)
1788
+ return undefined;
1789
+ // One pinned edge is enough: the cycle cannot complete once that edge is disabled, whichever other edges
1790
+ // still allow a move.
1791
+ const pinned = moving
1792
+ .filter((edge) => edge.op === direction)
1793
+ .some((edge) => edge.climbers.every((transition) => transition.guard !== undefined && guardPinsVariable(transition.guard, variable.name, direction)));
1794
+ if (pinned)
1795
+ return undefined;
1796
+ return { direction, word: direction === 'inc' ? 'incremented' : 'decremented' };
1797
+ }
1798
+ function S6_guardCompleteness(model) {
1799
+ const analysis = analyzeGuards(model);
1800
+ // The domain findings come first: an infinite state space is why the guard enumeration could not
1801
+ // finish, so a reader meets the cause before the symptom.
1802
+ const findings = [...unboundedDomainFindings(model), ...analysis.completeness];
1803
+ const dead = analysis.completeness.filter((finding) => finding.code === 'S6_DEAD_GUARD').length;
1804
+ const unbounded = findings.filter((finding) => finding.code === 'S6_UNBOUNDED_VARIABLE').length;
1805
+ const detail = unbounded > 0
1806
+ ? 'The reachable state space is infinite: ' + String(unbounded) + ' variable(s) climb around a reachable cycle that nothing stops'
1807
+ : findings.length === 0
1808
+ ? 'All guard branches defined'
1809
+ : dead === analysis.completeness.length ? 'Dead guard branches: ' + String(dead) : 'Guard findings: ' + String(findings.length);
1810
+ return checkResult('S6', 'Guard Completeness', findings, detail);
1811
+ }
1812
+ function S5_eventCompleteness(model) {
1813
+ const events = allEvents(model);
1814
+ const findings = [];
1815
+ for (const state of model.states) {
1816
+ if (state.terminal === true)
1817
+ continue;
1818
+ const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event));
1819
+ for (const event of events) {
1820
+ if (!handled.has(event)) {
1821
+ findings.push({
1822
+ code: 'S5_UNHANDLED_EVENT',
1823
+ severity: 'warning',
1824
+ message: state.id + ' silently ignores event ' + event,
1825
+ });
1826
+ }
1827
+ }
1828
+ }
1829
+ return checkResult('S5', 'Event Completeness', findings, findings.length === 0 ? 'All states handle all relevant events' : findings.length + ' unhandled (state, event) pairs');
1830
+ }
1831
+ /**
1832
+ * Why a walk that stopped at its budget reports undecided rather than a verdict. One phrasing for
1833
+ * every invariant walk: a reader who sees two different sentences for the same situation starts
1834
+ * looking for a difference between them.
1835
+ */
1836
+ function invariantCapReason(options) {
1837
+ return 'the walk stopped at the ' + String(options.maxStates) + ' state budget before it finished';
1838
+ }
1839
+ /** Whether a `var-in-range` `when` scope selects this runtime state. */
1840
+ function whenScopeHolds(when, runtime) {
1841
+ return isStateGuard(when) ? when.state === runtime.state : evalGuard(when, runtime.vars);
1842
+ }
1843
+ function invariantHolds(invariant, runtime, isInitial = false) {
1844
+ if (invariant.kind === 'never-states')
1845
+ return !invariant.states.includes(runtime.state);
1846
+ if (invariant.kind === 'var-in-range') {
1847
+ // The initial runtime state is checked unconditionally: a machine must never be
1848
+ // able to escape the range simply by starting outside the scope.
1849
+ if (!isInitial && invariant.when !== undefined && !whenScopeHolds(invariant.when, runtime))
1850
+ return true;
1851
+ const value = runtime.vars[invariant.variable];
1852
+ if (typeof value !== 'number')
1853
+ return false;
1854
+ if (invariant.min !== undefined && value < invariant.min)
1855
+ return false;
1856
+ if (invariant.max !== undefined && value > invariant.max)
1857
+ return false;
1858
+ return true;
1859
+ }
1860
+ return true;
1861
+ }
1862
+ function shortestViolationForInvariant(model, options, invariant) {
1863
+ if (invariant.kind === 'event-before-state') {
1864
+ return shortestEventBeforeStateViolation(model, options, invariant);
1865
+ }
1866
+ const init = initialState(model);
1867
+ if (!invariantHolds(invariant, init, true)) {
1868
+ return { invariant, path: [], reason: 'Initial state violates the invariant.' };
1869
+ }
1870
+ const visited = new Set([runtimeKey(init)]);
1871
+ const queue = [{ runtime: init, path: [] }];
1872
+ let steps = 0;
1873
+ // Whether the walk stopped on its budget rather than on an exhausted space. The two used to share the
1874
+ // `undefined` return, so S7 answered "All invariants hold" for a search that never finished.
1875
+ let capped = false;
1876
+ while (queue.length > 0) {
1877
+ const entry = queue.shift();
1878
+ if (++steps > options.maxStates) {
1879
+ capped = true;
1880
+ break;
1881
+ }
1882
+ for (const event of allEvents(model)) {
1883
+ for (const next of stepRuntime(model, entry.runtime, event)) {
1884
+ const key = runtimeKey(next);
1885
+ if (visited.has(key))
1886
+ continue;
1887
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
1888
+ if (!invariantHolds(invariant, next)) {
1889
+ return { invariant, path, reason: 'Invariant violated after ' + path.length + ' events.' };
1890
+ }
1891
+ visited.add(key);
1892
+ queue.push({ runtime: next, path });
1893
+ }
1894
+ }
1895
+ }
1896
+ if (capped)
1897
+ return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
1898
+ return undefined;
1899
+ }
1900
+ /**
1901
+ * Whether an invariant asserts a property of whole runs rather than of a single
1902
+ * runtime state. These kinds cannot be decided by a state predicate, so they are not
1903
+ * part of `shortestViolationForInvariant` (and therefore not of S7), which owns the
1904
+ * states; A9-A13 own the verdicts, and `pathPropertyViolation` below makes the same
1905
+ * verdicts available to A7 and D2.
1906
+ */
1907
+ function isPathPropertyInvariant(invariant) {
1908
+ return invariant.kind === 'leads-to'
1909
+ || invariant.kind === 'sequence'
1910
+ || invariant.kind === 'atomicity'
1911
+ || invariant.kind === 'budget'
1912
+ || invariant.kind === 'probability';
1913
+ }
1914
+ /** Whether a probability invariant's bound fails for the computed hit probability. */
1915
+ function probabilityViolated(invariant, probability) {
1916
+ const eps = 1e-9;
1917
+ if (invariant.op === '>=')
1918
+ return probability < invariant.p - eps;
1919
+ if (invariant.op === '>')
1920
+ return probability <= invariant.p + eps;
1921
+ if (invariant.op === '<=')
1922
+ return probability > invariant.p + eps;
1923
+ return probability >= invariant.p - eps;
1924
+ }
1925
+ /** Every runtime state reachable from init, capped like the other searches. */
1926
+ function reachableRuntimeStates(model, options) {
1927
+ const init = initialState(model);
1928
+ const visited = new Set([runtimeKey(init)]);
1929
+ const reached = [init];
1930
+ const queue = [init];
1931
+ let steps = 0;
1932
+ while (queue.length > 0) {
1933
+ if (++steps > options.maxStates)
1934
+ break;
1935
+ const current = queue.shift();
1936
+ for (const event of allEvents(model)) {
1937
+ for (const next of stepRuntime(model, current, event)) {
1938
+ const key = runtimeKey(next);
1939
+ if (visited.has(key))
1940
+ continue;
1941
+ visited.add(key);
1942
+ reached.push(next);
1943
+ queue.push(next);
1944
+ }
1945
+ }
1946
+ }
1947
+ return reached;
1948
+ }
1949
+ /**
1950
+ * Violation of a path-property invariant, or undefined when it holds. Each kind
1951
+ * delegates to the search its dedicated probe uses (A9 leads-to, A10 sequence, A11
1952
+ * atomicity, A12 budget, A13 probability), so a verdict reported here can never
1953
+ * disagree with the probe that owns the kind.
1954
+ */
1955
+ function pathPropertyViolation(model, options, invariant) {
1956
+ if (invariant.kind === 'leads-to') {
1957
+ const targets = new Set(leadsToTargets(invariant));
1958
+ const label = leadsToTargetLabel(invariant);
1959
+ const budget = leadsToWalkBudget(options);
1960
+ const walk = { remaining: budget };
1961
+ for (const runtime of reachableRuntimeStates(model, options)) {
1962
+ if (runtime.state !== invariant.from)
1963
+ continue;
1964
+ const outcome = findLeadsToBadPath(model, runtime, targets, label, walk);
1965
+ if (outcome.undecided) {
1966
+ return { invariant, path: [], reason: 'the walk spent its budget of ' + budget + ' steps without finding a run that avoids ' + label + ', or proving that none exists', undecided: true };
1967
+ }
1968
+ if (outcome.violation !== undefined)
1969
+ return { invariant, path: outcome.violation.path, reason: outcome.violation.reason };
1970
+ }
1971
+ return undefined;
1972
+ }
1973
+ if (invariant.kind === 'sequence')
1974
+ return findSequenceViolation(model, options, invariant);
1975
+ if (invariant.kind === 'atomicity')
1976
+ return findAtomicityViolation(model, options, invariant);
1977
+ if (invariant.kind === 'budget') {
1978
+ const violation = findBudgetViolation(model, options, invariant);
1979
+ // The walk can also stop early, and that is the third outcome: no witness was found, and the walk did not
1980
+ // finish looking. Folding it into `undefined` made A7 (and D2) report "every invariant holds" about a search
1981
+ // that decided nothing - the same defect the other walks had, in the one caller that reads this function.
1982
+ if (violation === 'undecided')
1983
+ return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
1984
+ if (violation === undefined)
1985
+ return undefined;
1986
+ return {
1987
+ invariant,
1988
+ path: violation.path,
1989
+ reason: violation.unbounded
1990
+ ? 'a reachable cycle keeps accumulating cost, so no finite budget holds'
1991
+ : 'a run accumulates cost ' + String(violation.cost) + ', above the declared budget ' + String(invariant.budget),
1992
+ };
1993
+ }
1994
+ if (invariant.kind === 'probability') {
1995
+ const { probability, converged } = computeHitProbability(model, options, invariant.target);
1996
+ if (!probabilityViolated(invariant, probability))
1997
+ return undefined;
1998
+ return {
1999
+ invariant,
2000
+ path: [],
2001
+ reason: 'P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + String(invariant.p)
2002
+ + (converged ? '' : ' (value iteration did not converge)'),
2003
+ };
2004
+ }
2005
+ return undefined;
2006
+ }
2007
+ function shortestEventBeforeStateViolation(model, options, invariant) {
2008
+ const init = initialState(model);
2009
+ const seenInitially = false;
2010
+ if (init.state === invariant.state && !seenInitially) {
2011
+ return { invariant, path: [], reason: 'Target state ' + invariant.state + ' is initial and the required event ' + invariant.event + ' has not occurred.' };
2012
+ }
2013
+ const key = (runtime, seen) => runtimeKey(runtime) + '|' + (seen ? '1' : '0');
2014
+ const visited = new Set([key(init, false)]);
2015
+ const queue = [{ runtime: init, seen: false, path: [] }];
2016
+ let steps = 0;
2017
+ let capped = false;
2018
+ while (queue.length > 0) {
2019
+ const entry = queue.shift();
2020
+ if (++steps > options.maxStates) {
2021
+ capped = true;
2022
+ break;
2023
+ }
2024
+ for (const event of allEvents(model)) {
2025
+ for (const next of stepRuntime(model, entry.runtime, event)) {
2026
+ const seen = entry.seen || event === invariant.event;
2027
+ const nextKey = key(next, seen);
2028
+ if (visited.has(nextKey))
2029
+ continue;
2030
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
2031
+ if (next.state === invariant.state && !seen) {
2032
+ return { invariant, path, reason: 'Target state ' + invariant.state + ' is reachable without event ' + invariant.event + ' in ' + path.length + ' steps.' };
2033
+ }
2034
+ visited.add(nextKey);
2035
+ queue.push({ runtime: next, seen, path });
2036
+ }
2037
+ }
2038
+ }
2039
+ // Same cap, same answer: this walk is S7's too, so an unfinished search here must not read as a pass.
2040
+ if (capped)
2041
+ return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
2042
+ return undefined;
2043
+ }
2044
+ function runInvariants(model, options, includePathProperties = false) {
2045
+ const violations = [];
2046
+ for (const invariant of model.invariants ?? []) {
2047
+ // A path property is not a predicate of one runtime state, so without `includePathProperties` this loop
2048
+ // has nothing to say about it. Walking the space anyway was harmless only while a cap read as a pass: the
2049
+ // walk can never find a violation (the predicate holds for these kinds by construction), so once it
2050
+ // reports its budget honestly it reports undecided about a question it was never asked. S7's roster is
2051
+ // the state kinds; A9-A13 and D2 decide the rest.
2052
+ if (!includePathProperties && isPathPropertyInvariant(invariant))
2053
+ continue;
2054
+ const violation = isPathPropertyInvariant(invariant)
2055
+ ? pathPropertyViolation(model, options, invariant)
2056
+ : shortestViolationForInvariant(model, options, invariant);
2057
+ if (violation !== undefined)
2058
+ violations.push(violation);
2059
+ }
2060
+ return violations;
2061
+ }
2062
+ function S7_invariants(model, options) {
2063
+ const violations = runInvariants(model, options);
2064
+ const findings = violations.map((violation) => violation.undecided ? {
2065
+ code: 'S7_UNDECIDED',
2066
+ severity: 'warning',
2067
+ message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') could not be decided: ' + violation.reason + '. Absence is not proven.',
2068
+ evidence: { invariant: violation.invariant },
2069
+ } : {
2070
+ code: 'S7_INVARIANT_VIOLATION',
2071
+ severity: 'error',
2072
+ message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
2073
+ path: violation.path,
2074
+ evidence: { invariant: violation.invariant },
2075
+ });
2076
+ const undecided = findings.some((finding) => finding.code === 'S7_UNDECIDED');
2077
+ return checkResult('S7', 'Invariant Validity', findings, violations.length === 0
2078
+ ? 'All invariants hold'
2079
+ : undecided && findings.every((finding) => finding.code === 'S7_UNDECIDED') ? 'Invariants undecided: the search budget ran out' : 'Invariant violations: ' + violations.length);
2080
+ }
2081
+ // ---------------------------------------------------------------------------
2082
+ // A1-A14
2083
+ // ---------------------------------------------------------------------------
2084
+ function A1_unexpectedEvents(model) {
2085
+ const events = allEvents(model);
2086
+ const findings = [];
2087
+ for (const state of model.states) {
2088
+ if (state.terminal === true)
2089
+ continue;
2090
+ const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event));
2091
+ for (const event of events) {
2092
+ if (!handled.has(event)) {
2093
+ findings.push({
2094
+ code: 'A1_UNHANDLED_EVENT',
2095
+ severity: 'warning',
2096
+ message: 'Event ' + event + ' in state ' + state.id + ' has no defined transition (silent ignore).',
2097
+ });
2098
+ }
2099
+ }
2100
+ }
2101
+ return checkResult('A1', 'Unexpected Event Injection', findings, findings.length === 0 ? 'All event/state combinations defined' : findings.length + ' unhandled combinations');
2102
+ }
2103
+ function firstOutcome(runtime, outcomes) {
2104
+ return outcomes[0];
2105
+ }
2106
+ function A2_raceInterleaving(model, exploration) {
2107
+ const findings = [];
2108
+ for (const [e1, e2] of model.concurrentPairs ?? []) {
2109
+ for (const runtime of exploration.reachable) {
2110
+ const first = firstOutcome(runtime, stepRuntime(model, runtime, e1));
2111
+ const second = firstOutcome(runtime, stepRuntime(model, runtime, e2));
2112
+ if (first === undefined && second === undefined)
2113
+ continue;
2114
+ const final12 = first === undefined ? runtime : firstOutcome(first, stepRuntime(model, first, e2)) ?? first;
2115
+ const final21 = second === undefined ? runtime : firstOutcome(second, stepRuntime(model, second, e1)) ?? second;
2116
+ if (runtimeKey(final12) !== runtimeKey(final21)) {
2117
+ findings.push({
2118
+ code: 'A2_ORDER_DEPENDENT',
2119
+ severity: 'warning',
2120
+ message: 'Events ' + e1 + ' and ' + e2 + ' produce order-dependent outcomes from state ' + runtime.state,
2121
+ evidence: { e1ThenE2: final12, e2ThenE1: final21 },
2122
+ });
2123
+ }
2124
+ }
2125
+ }
2126
+ return checkResult('A2', 'Race Interleaving', withUnresolved(model, 'A2', findings), findings.length === 0 ? 'No race conditions detected' : 'Order-dependent outcomes: ' + findings.length);
2127
+ }
2128
+ function stepSequence(model, init, events) {
2129
+ let runtime = init;
2130
+ for (const event of events) {
2131
+ const next = firstOutcome(runtime, stepRuntime(model, runtime, event));
2132
+ if (next === undefined)
2133
+ continue;
2134
+ runtime = next;
2135
+ }
2136
+ return runtime;
2137
+ }
2138
+ function permutations(items) {
2139
+ if (items.length === 0)
2140
+ return [[]];
2141
+ const result = [];
2142
+ for (let index = 0; index < items.length; index++) {
2143
+ const rest = items.slice(0, index).concat(items.slice(index + 1));
2144
+ for (const suffix of permutations(rest))
2145
+ result.push([items[index], ...suffix]);
2146
+ }
2147
+ return result;
2148
+ }
2149
+ function A3_orderPermutation(model, options) {
2150
+ const events = allEvents(model).slice(0, options.maxPermutationEvents);
2151
+ if (events.length < 2)
2152
+ return checkResult('A3', 'Order Permutation', [], 'Fewer than 2 events — skipped');
2153
+ const init = initialState(model);
2154
+ const outcomes = new Map();
2155
+ for (const permutation of permutations(events)) {
2156
+ const final = stepSequence(model, init, permutation);
2157
+ const key = runtimeKey(final);
2158
+ const list = outcomes.get(key) ?? [];
2159
+ if (list.length < 3)
2160
+ list.push(permutation.join(','));
2161
+ outcomes.set(key, list);
2162
+ }
2163
+ if (outcomes.size > 1) {
2164
+ const examples = [...outcomes.entries()].map(([key, sequences]) => ({ final: key.split('|')[0], example: sequences[0] }));
2165
+ return checkResult('A3', 'Order Permutation', [{
2166
+ code: 'A3_ORDER_DEPENDENT',
2167
+ severity: 'warning',
2168
+ message: 'Same event set produces ' + outcomes.size + ' different outcomes depending on order.',
2169
+ evidence: { examples },
2170
+ }], 'Order-dependent outcomes: ' + outcomes.size);
2171
+ }
2172
+ return checkResult('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + events.length + ' events)');
2173
+ }
2174
+ function stateActionList(model, stateId, kind) {
2175
+ const state = model.states.find((entry) => entry.id === stateId);
2176
+ const list = state === undefined ? undefined : state[kind];
2177
+ return list ?? [];
2178
+ }
2179
+ function actionsDeclared(model) {
2180
+ return model.states.some((state) => (state.onEntry ?? []).length > 0 || (state.onExit ?? []).length > 0);
2181
+ }
2182
+ /** Apply an ordered action list to the held flag. Actions never change state or variables; they only matter to pairing checks. */
2183
+ function applyActionList(list, acquireEvent, releaseEvent, held) {
2184
+ let next = held;
2185
+ let reacquired = false;
2186
+ for (const action of list) {
2187
+ if (action === acquireEvent) {
2188
+ if (next)
2189
+ reacquired = true;
2190
+ next = true;
2191
+ }
2192
+ else if (action === releaseEvent) {
2193
+ next = false;
2194
+ }
2195
+ }
2196
+ return { held: next, reacquired };
2197
+ }
2198
+ function A4_pairSymmetry(model) {
2199
+ const findings = [];
2200
+ const hasActions = actionsDeclared(model);
2201
+ const transitionEvents = new Set(model.transitions.map((transition) => transition.event));
2202
+ const actionEvents = new Set();
2203
+ if (hasActions) {
2204
+ for (const state of model.states) {
2205
+ for (const action of stateActionList(model, state.id, 'onEntry'))
2206
+ actionEvents.add(action);
2207
+ for (const action of stateActionList(model, state.id, 'onExit'))
2208
+ actionEvents.add(action);
2209
+ }
2210
+ }
2211
+ const pairHasEvent = (event) => transitionEvents.has(event) || actionEvents.has(event);
2212
+ const releaseReachableFrom = (stateId, releaseEvent) => {
2213
+ const reachable = stateGraphReachable(model, stateId);
2214
+ return [...reachable].some((state) => model.transitions.some((transition) => transition.from === state && transition.event === releaseEvent)
2215
+ || (hasActions && (stateActionList(model, state, 'onEntry').includes(releaseEvent) || stateActionList(model, state, 'onExit').includes(releaseEvent))));
2216
+ };
2217
+ const edges = stateGraphEdges(model);
2218
+ for (const pair of model.resourcePairs ?? []) {
2219
+ const acquireTransitions = model.transitions.filter((transition) => transition.event === pair.acquireEvent);
2220
+ const actionAcquire = hasActions && model.states.some((state) => stateActionList(model, state.id, 'onEntry').includes(pair.acquireEvent) || stateActionList(model, state.id, 'onExit').includes(pair.acquireEvent));
2221
+ const releaseExists = pairHasEvent(pair.releaseEvent);
2222
+ if (acquireTransitions.length === 0 && !actionAcquire && !releaseExists)
2223
+ continue;
2224
+ if ((acquireTransitions.length > 0 || actionAcquire) && !releaseExists) {
2225
+ findings.push({
2226
+ code: 'A4_NO_RELEASE_EVENT',
2227
+ severity: 'error',
2228
+ message: 'Resource "' + pair.resource + '": acquire event ' + pair.acquireEvent + ' exists but release event ' + pair.releaseEvent + ' is never defined.',
2229
+ // In the evidence so the sweep can see that this finding already accounts for the name:
2230
+ // one defect, one finding, in the vocabulary of the check that owns it.
2231
+ evidence: { resource: pair.resource, acquireEvent: pair.acquireEvent, releaseEvent: pair.releaseEvent, event: pair.releaseEvent },
2232
+ });
2233
+ continue;
2234
+ }
2235
+ // Collect acquire sites: transitions whose event acquires, and state entry/exit actions that end holding the resource.
2236
+ const seeds = [];
2237
+ for (const acquire of acquireTransitions) {
2238
+ if (!releaseReachableFrom(acquire.to, pair.releaseEvent)) {
2239
+ findings.push({
2240
+ code: 'A4_NO_RELEASE_REACHABLE',
2241
+ severity: 'error',
2242
+ message: 'Resource "' + pair.resource + '": after ' + pair.acquireEvent + ' into ' + acquire.to + ', no ' + pair.releaseEvent + ' is reachable.',
2243
+ evidence: { acquireTransition: acquire },
2244
+ });
2245
+ continue;
2246
+ }
2247
+ seeds.push({ state: acquire.to, held: true, path: [] });
2248
+ }
2249
+ if (hasActions) {
2250
+ for (const state of model.states) {
2251
+ // acquire fired by onEntry: resource is held for the whole stay in the state
2252
+ const entry = stateActionList(model, state.id, 'onEntry');
2253
+ if (entry.includes(pair.acquireEvent)) {
2254
+ const sim = applyActionList(entry, pair.acquireEvent, pair.releaseEvent, false);
2255
+ if (sim.reacquired) {
2256
+ findings.push({
2257
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
2258
+ severity: 'warning',
2259
+ message: 'Resource "' + pair.resource + '" is acquired more than once inside onEntry of ' + state.id + ' before ' + pair.releaseEvent + '.',
2260
+ });
2261
+ }
2262
+ if (sim.held)
2263
+ seeds.push({ state: state.id, held: true, path: [] });
2264
+ }
2265
+ // acquire fired by onExit: happens on every outgoing edge, never on a terminal state
2266
+ const exit = stateActionList(model, state.id, 'onExit');
2267
+ if (exit.includes(pair.acquireEvent) && !state.terminal) {
2268
+ const sim = applyActionList(exit, pair.acquireEvent, pair.releaseEvent, false);
2269
+ if (sim.reacquired) {
2270
+ findings.push({
2271
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
2272
+ severity: 'warning',
2273
+ message: 'Resource "' + pair.resource + '" is acquired more than once inside onExit of ' + state.id + ' before ' + pair.releaseEvent + '.',
2274
+ });
2275
+ }
2276
+ if (sim.held)
2277
+ seeds.push({ state: state.id, held: false, path: [] });
2278
+ }
2279
+ }
2280
+ }
2281
+ // Path-sensitive probe: track the held flag along state-graph paths, firing
2282
+ // onExit of the source state before each transition and onEntry of the target after it.
2283
+ for (const seed of seeds) {
2284
+ const visited = new Set();
2285
+ const queue = [seed];
2286
+ let steps = 0;
2287
+ while (queue.length > 0) {
2288
+ const entry = queue.shift();
2289
+ if (++steps > 1000)
2290
+ break;
2291
+ const key = entry.state + '|' + (entry.held ? '1' : '0');
2292
+ if (visited.has(key))
2293
+ continue;
2294
+ visited.add(key);
2295
+ if (entry.held && isTerminal(model, entry.state)) {
2296
+ findings.push({
2297
+ code: 'A4_TERMINAL_WITH_RESOURCE',
2298
+ severity: 'error',
2299
+ message: 'Resource "' + pair.resource + '" is still held when entering terminal state ' + entry.state + '.',
2300
+ path: entry.path,
2301
+ });
2302
+ continue;
2303
+ }
2304
+ for (const transition of edges.get(entry.state) ?? []) {
2305
+ let held = entry.held;
2306
+ if (hasActions) {
2307
+ const sim = applyActionList(stateActionList(model, entry.state, 'onExit'), pair.acquireEvent, pair.releaseEvent, held);
2308
+ if (sim.reacquired) {
2309
+ findings.push({
2310
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
2311
+ severity: 'warning',
2312
+ message: 'Resource "' + pair.resource + '" is acquired again in onExit of ' + entry.state + ' before ' + pair.releaseEvent + '.',
2313
+ path: entry.path,
2314
+ });
2315
+ continue;
2316
+ }
2317
+ held = sim.held;
2318
+ }
2319
+ if (transition.event === pair.releaseEvent)
2320
+ held = false;
2321
+ else if (transition.event === pair.acquireEvent) {
2322
+ if (held) {
2323
+ findings.push({
2324
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
2325
+ severity: 'warning',
2326
+ message: 'Resource "' + pair.resource + '" is acquired again in state ' + entry.state + ' before ' + pair.releaseEvent + '.',
2327
+ path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
2328
+ });
2329
+ continue;
2330
+ }
2331
+ held = true;
2332
+ }
2333
+ if (hasActions) {
2334
+ const sim = applyActionList(stateActionList(model, transition.to, 'onEntry'), pair.acquireEvent, pair.releaseEvent, held);
2335
+ if (sim.reacquired) {
2336
+ findings.push({
2337
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
2338
+ severity: 'warning',
2339
+ message: 'Resource "' + pair.resource + '" is acquired again inside onEntry of ' + transition.to + ' before ' + pair.releaseEvent + '.',
2340
+ path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
2341
+ });
2342
+ continue;
2343
+ }
2344
+ held = sim.held;
2345
+ }
2346
+ queue.push({ state: transition.to, held, path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }] });
2347
+ }
2348
+ }
2349
+ }
2350
+ }
2351
+ return checkResult('A4', 'Pair Symmetry', withUnresolved(model, 'A4', findings), findings.length === 0 ? 'All pairs balanced' : 'Asymmetric pairs: ' + findings.length);
2352
+ }
2353
+ function stateGraphEdges(model) {
2354
+ const edges = new Map();
2355
+ for (const state of model.states)
2356
+ edges.set(state.id, []);
2357
+ for (const transition of model.transitions)
2358
+ edges.get(transition.from)?.push(transition);
2359
+ return edges;
2360
+ }
2361
+ function stateGraphReachable(model, start) {
2362
+ const edges = new Map();
2363
+ for (const state of model.states)
2364
+ edges.set(state.id, []);
2365
+ for (const transition of model.transitions)
2366
+ edges.get(transition.from)?.push(transition.to);
2367
+ const visited = new Set();
2368
+ const queue = [start];
2369
+ while (queue.length > 0) {
2370
+ const current = queue.shift();
2371
+ if (visited.has(current))
2372
+ continue;
2373
+ visited.add(current);
2374
+ for (const next of edges.get(current) ?? [])
2375
+ queue.push(next);
2376
+ }
2377
+ return visited;
2378
+ }
2379
+ function A5_boundaryBlast(model) {
2380
+ const findings = [];
2381
+ for (const check of model.boundaryChecks ?? []) {
2382
+ const variable = (model.variables ?? []).find((entry) => entry.name === check.variable);
2383
+ if (variable === undefined)
2384
+ continue;
2385
+ for (const value of check.values) {
2386
+ if (variable.min !== undefined && value < variable.min) {
2387
+ findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is below min ' + variable.min });
2388
+ }
2389
+ if (variable.max !== undefined && value > variable.max) {
2390
+ findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is above max ' + variable.max });
2391
+ }
2392
+ const assignment = { ...(model.variables ?? []).reduce((result, entry) => { result[entry.name] = entry.init; return result; }, {}), [check.variable]: value };
2393
+ for (const [key, group] of groupTransitions(model)) {
2394
+ const [from, event] = key.split('|');
2395
+ if (!group.some((transition) => guardVariables(transition.guard).includes(check.variable)))
2396
+ continue;
2397
+ const guarded = group.filter((transition) => transition.guard !== undefined);
2398
+ const unguarded = group.filter((transition) => transition.guard === undefined);
2399
+ const matched = guarded.filter((transition) => evalGuard(transition.guard, assignment));
2400
+ if (matched.length === 0 && unguarded.length === 0) {
2401
+ findings.push({
2402
+ code: 'A5_GUARD_HOLE',
2403
+ severity: 'warning',
2404
+ message: from + ' + ' + event + ' has no branch for ' + check.variable + '=' + value,
2405
+ evidence: { assignment },
2406
+ });
2407
+ }
2408
+ if (matched.length > 1) {
2409
+ findings.push({
2410
+ code: 'A5_GUARD_OVERLAP',
2411
+ severity: 'warning',
2412
+ message: from + ' + ' + event + ' has ' + matched.length + ' true branches for ' + check.variable + '=' + value,
2413
+ evidence: { assignment },
2414
+ });
2415
+ }
2416
+ }
2417
+ }
2418
+ }
2419
+ return checkResult('A5', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length);
2420
+ }
2421
+ function A6_resourceInjection(model) {
2422
+ const findings = [];
2423
+ for (const pair of model.resourcePairs ?? []) {
2424
+ if (pair.failEvent === undefined)
2425
+ continue;
2426
+ const acquireStates = new Set(model.transitions.filter((transition) => transition.event === pair.acquireEvent).map((transition) => transition.from));
2427
+ for (const state of acquireStates) {
2428
+ const handled = model.transitions.some((transition) => transition.from === state && transition.event === pair.failEvent);
2429
+ if (!handled) {
2430
+ findings.push({
2431
+ code: 'A6_NO_FAILURE_HANDLER',
2432
+ severity: 'warning',
2433
+ message: 'State ' + state + ' can ' + pair.acquireEvent + ' for "' + pair.resource + '" but has no ' + pair.failEvent + ' transition.',
2434
+ });
2435
+ }
2436
+ }
2437
+ }
2438
+ return checkResult('A6', 'Resource Injection', withUnresolved(model, 'A6', findings), findings.length === 0 ? 'No resource vulnerabilities detected' : 'Resource failure paths missing: ' + findings.length);
2439
+ }
2440
+ function A7_shortestViolations(model, options) {
2441
+ // Path-property invariants are included: "for any invariant that fails" is this
2442
+ // check's contract, and a check that silently skips five of the eight kinds cannot
2443
+ // honour it. The searches for the state kinds, for sequence, atomicity and budget
2444
+ // are breadth-first, so their first witness is the shortest one; the leads-to walk
2445
+ // and the probability bound have no such guarantee, and their message says what it
2446
+ // found instead of claiming minimality.
2447
+ const violations = runInvariants(model, options, true);
2448
+ const findings = violations.map((violation) => {
2449
+ if (violation.undecided) {
2450
+ return {
2451
+ code: 'A7_UNDECIDED',
2452
+ severity: 'warning',
2453
+ message: 'Invariant "' + violation.invariant.id + '" could not be decided: ' + violation.reason + '. Absence is not proven, so there is no shortest counterexample to report.',
2454
+ evidence: { invariant: violation.invariant.id },
2455
+ };
2456
+ }
2457
+ const pathLength = violation.path.length;
2458
+ const message = isPathPropertyInvariant(violation.invariant)
2459
+ ? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
2460
+ + (pathLength === 0 ? '' : ' (witness path length: ' + pathLength + ')')
2461
+ : 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + pathLength + (pathLength === 0 ? ' (initial state)' : '');
2462
+ return {
2463
+ code: 'A7_SHORTEST_COUNTEREXAMPLE',
2464
+ severity: 'warning',
2465
+ message,
2466
+ path: violation.path,
2467
+ evidence: { invariant: violation.invariant.id },
2468
+ };
2469
+ });
2470
+ const undecided = findings.some((finding) => finding.code === 'A7_UNDECIDED');
2471
+ return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0
2472
+ ? 'All invariants hold for all reachable paths'
2473
+ : undecided && findings.every((finding) => finding.code === 'A7_UNDECIDED') ? 'Invariant walks stopped at the state budget: undecided' : 'Violated invariants: ' + findings.length);
2474
+ }
2475
+ function mapStateId(mapping, state) {
2476
+ return mapping[state] ?? state;
2477
+ }
2478
+ function D1_behavioralPreservation(before, after, mapping) {
2479
+ const findings = [];
2480
+ const afterStates = new Set(after.states.map((state) => state.id));
2481
+ const afterByFromEvent = new Map();
2482
+ for (const transition of after.transitions) {
2483
+ const key = transition.from + '|' + transition.event;
2484
+ const set = afterByFromEvent.get(key) ?? new Set();
2485
+ set.add(transition.to);
2486
+ afterByFromEvent.set(key, set);
2487
+ }
2488
+ const beforeInitMapped = mapStateId(mapping, before.init);
2489
+ if (beforeInitMapped !== after.init) {
2490
+ findings.push({
2491
+ code: 'D1_INIT_MISMATCH',
2492
+ severity: 'warning',
2493
+ message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
2494
+ evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
2495
+ });
2496
+ }
2497
+ for (const state of before.states) {
2498
+ const mapped = mapStateId(mapping, state.id);
2499
+ if (!afterStates.has(mapped)) {
2500
+ findings.push({
2501
+ code: 'D1_MAPPED_STATE_MISSING',
2502
+ severity: 'error',
2503
+ message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
2504
+ evidence: { beforeState: state.id, mappedState: mapped },
2505
+ });
2506
+ continue;
2507
+ }
2508
+ for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
2509
+ const key = mapped + '|' + transition.event;
2510
+ const targets = afterByFromEvent.get(key);
2511
+ if (targets === undefined || targets.size === 0) {
2512
+ findings.push({
2513
+ code: 'D1_EVENT_DISABLED',
2514
+ severity: 'error',
2515
+ message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
2516
+ path: [{ from: state.id, event: transition.event, to: transition.to }],
2517
+ evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
2518
+ });
2519
+ }
2520
+ }
2521
+ }
2522
+ return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length);
2523
+ }
2524
+ function mapInvariantForComparison(invariant, mapping) {
2525
+ if (invariant.kind === 'never-states') {
2526
+ return {
2527
+ ...invariant,
2528
+ id: invariant.id + ':before',
2529
+ description: invariant.description + ' (from BEFORE)',
2530
+ states: invariant.states.map((state) => mapStateId(mapping, state)),
2531
+ };
2532
+ }
2533
+ if (invariant.kind === 'event-before-state') {
2534
+ return {
2535
+ ...invariant,
2536
+ id: invariant.id + ':before',
2537
+ description: invariant.description + ' (from BEFORE)',
2538
+ state: mapStateId(mapping, invariant.state),
2539
+ };
2540
+ }
2541
+ if (invariant.kind === 'var-in-range') {
2542
+ const mapped = { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
2543
+ // A state scope must follow the state rename, otherwise D2 reports a spurious
2544
+ // regression when the scope later refers to a state id that no longer exists.
2545
+ if (mapped.when !== undefined && isStateGuard(mapped.when)) {
2546
+ mapped.when = { state: mapStateId(mapping, mapped.when.state) };
2547
+ }
2548
+ return mapped;
2549
+ }
2550
+ if (invariant.kind === 'leads-to') {
2551
+ // A target set maps member by member; a rename that drops a state is reported by
2552
+ // D2's own mapped-state check rather than silently changing the property.
2553
+ return {
2554
+ ...invariant,
2555
+ id: invariant.id + ':before',
2556
+ description: invariant.description + ' (from BEFORE)',
2557
+ from: mapStateId(mapping, invariant.from),
2558
+ to: typeof invariant.to === 'string' ? mapStateId(mapping, invariant.to) : invariant.to.map((state) => mapStateId(mapping, state)),
2559
+ };
2560
+ }
2561
+ if (invariant.kind === 'probability') {
2562
+ return {
2563
+ ...invariant,
2564
+ id: invariant.id + ':before',
2565
+ description: invariant.description + ' (from BEFORE)',
2566
+ target: mapStateId(mapping, invariant.target),
2567
+ };
2568
+ }
2569
+ return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
2570
+ }
2571
+ function D2_invariantContinuity(before, after, options, mapping) {
2572
+ const findings = [];
2573
+ const afterStates = new Set(after.states.map((state) => state.id));
2574
+ const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name));
2575
+ for (const invariant of before.invariants ?? []) {
2576
+ const mapped = mapInvariantForComparison(invariant, mapping);
2577
+ if (mapped.kind === 'never-states') {
2578
+ for (const state of mapped.states) {
2579
+ if (!afterStates.has(state)) {
2580
+ findings.push({
2581
+ code: 'D2_MAPPED_STATE_MISSING',
2582
+ severity: 'warning',
2583
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
2584
+ evidence: { invariant: invariant.id, state },
2585
+ });
2586
+ }
2587
+ }
2588
+ }
2589
+ else if (mapped.kind === 'event-before-state') {
2590
+ if (!afterStates.has(mapped.state)) {
2591
+ findings.push({
2592
+ code: 'D2_MAPPED_STATE_MISSING',
2593
+ severity: 'warning',
2594
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
2595
+ evidence: { invariant: invariant.id, state: mapped.state },
2596
+ });
2597
+ }
2598
+ }
2599
+ else if (mapped.kind === 'var-in-range') {
2600
+ if (!afterVariables.has(mapped.variable)) {
2601
+ findings.push({
2602
+ code: 'D2_VARIABLE_MISSING',
2603
+ severity: 'warning',
2604
+ message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
2605
+ evidence: { invariant: invariant.id, variable: mapped.variable },
2606
+ });
2607
+ continue;
2608
+ }
2609
+ }
2610
+ else if (mapped.kind === 'leads-to') {
2611
+ // The state references of a path-property invariant must follow stateMapping too,
2612
+ // otherwise a pure rename searches for a state AFTER does not declare.
2613
+ for (const state of [mapped.from, ...leadsToTargets(mapped)]) {
2614
+ if (!afterStates.has(state)) {
2615
+ findings.push({
2616
+ code: 'D2_MAPPED_STATE_MISSING',
2617
+ severity: 'warning',
2618
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
2619
+ evidence: { invariant: invariant.id, state },
2620
+ });
2621
+ }
2622
+ }
2623
+ }
2624
+ else if (mapped.kind === 'probability') {
2625
+ if (!afterStates.has(mapped.target)) {
2626
+ findings.push({
2627
+ code: 'D2_MAPPED_STATE_MISSING',
2628
+ severity: 'warning',
2629
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.target + ', which is not declared in AFTER.',
2630
+ evidence: { invariant: invariant.id, state: mapped.target },
2631
+ });
2632
+ }
2633
+ }
2634
+ // Every invariant the engine can decide is re-decided against AFTER. Path-property
2635
+ // kinds go through the same searches their dedicated probes use, so D2 can no
2636
+ // longer report "all BEFORE invariants continue to hold" while A9-A13 fail on the
2637
+ // very same model.
2638
+ const violation = isPathPropertyInvariant(mapped)
2639
+ ? pathPropertyViolation(after, options, mapped)
2640
+ : shortestViolationForInvariant(after, options, mapped);
2641
+ if (violation !== undefined) {
2642
+ findings.push(violation.undecided ? {
2643
+ code: 'D2_UNDECIDED',
2644
+ severity: 'warning',
2645
+ message: 'BEFORE invariant "' + invariant.id + '" could not be re-decided against AFTER: ' + violation.reason + '. Absence is not proven.',
2646
+ evidence: { beforeInvariant: invariant, afterInvariant: mapped },
2647
+ } : {
2648
+ code: 'D2_INVARIANT_REGRESSION',
2649
+ severity: 'error',
2650
+ message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
2651
+ path: violation.path,
2652
+ evidence: { beforeInvariant: invariant, afterInvariant: mapped },
2653
+ });
2654
+ }
2655
+ }
2656
+ return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length);
2657
+ }
2658
+ function D3_regressionDelta(before, after, mapping) {
2659
+ const afterStateIds = new Set(after.states.map((state) => state.id));
2660
+ const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
2661
+ const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
2662
+ const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
2663
+ const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
2664
+ const afterEvents = new Set(after.transitions.map((transition) => transition.event));
2665
+ const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
2666
+ const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
2667
+ const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
2668
+ candidate.event === transition.event &&
2669
+ candidate.to === mapStateId(mapping, transition.to)));
2670
+ const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
2671
+ candidate.event === transition.event &&
2672
+ mapStateId(mapping, candidate.to) === transition.to));
2673
+ const findings = [];
2674
+ for (const state of removedStates) {
2675
+ findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } });
2676
+ }
2677
+ for (const event of removedEvents) {
2678
+ findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } });
2679
+ }
2680
+ for (const transition of removedTransitions) {
2681
+ findings.push({
2682
+ code: 'D3_REMOVED_TRANSITION',
2683
+ severity: 'warning',
2684
+ message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
2685
+ evidence: { transition },
2686
+ });
2687
+ }
2688
+ const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions';
2689
+ return checkResult('D3', 'Regression Delta', findings, detail);
2690
+ }
2691
+ function deadlockStateIds(model) {
2692
+ const outgoing = new Set(model.transitions.map((transition) => transition.from));
2693
+ return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id);
2694
+ }
2695
+ function closedSccStateSets(model) {
2696
+ return sccs(model)
2697
+ .filter((component) => {
2698
+ const componentSet = new Set(component);
2699
+ const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to));
2700
+ const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to));
2701
+ return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state));
2702
+ })
2703
+ .map((component) => component.sort());
2704
+ }
2705
+ function D4_deadlockLivenessRegression(before, after, mapping) {
2706
+ const findings = [];
2707
+ const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)));
2708
+ const afterDeadlock = deadlockStateIds(after);
2709
+ for (const state of afterDeadlock) {
2710
+ if (!beforeDeadlock.has(state)) {
2711
+ findings.push({
2712
+ code: 'D4_DEADLOCK_REGRESSION',
2713
+ severity: 'error',
2714
+ message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
2715
+ evidence: { state },
2716
+ });
2717
+ }
2718
+ }
2719
+ const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')));
2720
+ const afterScc = closedSccStateSets(after);
2721
+ for (const component of afterScc) {
2722
+ const key = component.join(',');
2723
+ if (!beforeScc.has(key)) {
2724
+ findings.push({
2725
+ code: 'D4_LIVENESS_REGRESSION',
2726
+ severity: 'error',
2727
+ message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
2728
+ evidence: { states: component },
2729
+ });
2730
+ }
2731
+ }
2732
+ return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length);
2733
+ }
2734
+ function buildComparisonSummary(before, after, mapping) {
2735
+ const afterStateIds = new Set(after.states.map((state) => state.id));
2736
+ const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
2737
+ const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
2738
+ const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
2739
+ const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
2740
+ const afterEvents = new Set(after.transitions.map((transition) => transition.event));
2741
+ const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
2742
+ const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
2743
+ const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
2744
+ candidate.event === transition.event &&
2745
+ candidate.to === mapStateId(mapping, transition.to)));
2746
+ const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
2747
+ candidate.event === transition.event &&
2748
+ mapStateId(mapping, candidate.to) === transition.to));
2749
+ return {
2750
+ beforeModelHash: modelHash(before),
2751
+ afterModelHash: modelHash(after),
2752
+ stateMapping: mapping,
2753
+ beforeStates: before.states.length,
2754
+ beforeTransitions: before.transitions.length,
2755
+ afterStates: after.states.length,
2756
+ afterTransitions: after.transitions.length,
2757
+ addedStates,
2758
+ removedStates,
2759
+ addedEvents,
2760
+ removedEvents,
2761
+ addedTransitions,
2762
+ removedTransitions,
2763
+ };
2764
+ }
2765
+ function A8_idempotentReplay(model, exploration) {
2766
+ const events = model.idempotentEvents ?? [];
2767
+ const findings = [];
2768
+ for (const event of events) {
2769
+ for (const runtime of exploration.reachable) {
2770
+ const onceOptions = stepRuntime(model, runtime, event);
2771
+ if (onceOptions.length === 0)
2772
+ continue;
2773
+ for (const once of onceOptions) {
2774
+ const twiceOptions = stepRuntime(model, once, event);
2775
+ if (twiceOptions.length === 0) {
2776
+ findings.push({
2777
+ code: 'A8_NOT_REPLAYABLE',
2778
+ severity: 'warning',
2779
+ message: 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime.state + '.',
2780
+ path: [{ from: runtime.state, event, to: once.state }],
2781
+ evidence: { state: runtime.state, event },
2782
+ });
2783
+ continue;
2784
+ }
2785
+ for (const twice of twiceOptions) {
2786
+ if (runtimeKey(twice) !== runtimeKey(once)) {
2787
+ findings.push({
2788
+ code: 'A8_NOT_IDEMPOTENT',
2789
+ severity: 'error',
2790
+ message: 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime.state + '.',
2791
+ path: [{ from: runtime.state, event, to: once.state }, { from: once.state, event, to: twice.state }],
2792
+ evidence: { state: runtime.state, event, afterOnce: once, afterTwice: twice },
2793
+ });
2794
+ break;
2795
+ }
2796
+ }
2797
+ }
2798
+ }
2799
+ }
2800
+ return checkResult('A8', 'Idempotent Replay', withUnresolved(model, 'A8', findings), findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length);
2801
+ }
2802
+ /**
2803
+ * Whether the guard proves `variable` stays at or below `bound` wherever it holds.
2804
+ *
2805
+ * Deliberately conservative in one direction only: a guard this function cannot read
2806
+ * answers `false`, so an unreadable guard is reported as "not proven" rather than as
2807
+ * "enforced". A conjunction bounds the value if any conjunct does; a disjunction has to
2808
+ * bound it on every branch; a negation is never reasoned about.
2809
+ */
2810
+ function guardImpliesUpperBound(guard, variable, bound) {
2811
+ if (guard === undefined)
2812
+ return false;
2813
+ if ('variable' in guard) {
2814
+ if (guard.variable !== variable || typeof guard.value !== 'number')
2815
+ return false;
2816
+ if (guard.op === '<')
2817
+ return guard.value - 1 <= bound;
2818
+ if (guard.op === '<=' || guard.op === '==')
2819
+ return guard.value <= bound;
2820
+ return false;
2821
+ }
2822
+ if ('all' in guard)
2823
+ return guard.all.some((child) => guardImpliesUpperBound(child, variable, bound));
2824
+ if ('any' in guard)
2825
+ return guard.any.every((child) => guardImpliesUpperBound(child, variable, bound));
2826
+ return false;
2827
+ }
2828
+ /** Mirror of {@link guardImpliesUpperBound} for the lower bound. */
2829
+ function guardImpliesLowerBound(guard, variable, bound) {
2830
+ if (guard === undefined)
2831
+ return false;
2832
+ if ('variable' in guard) {
2833
+ if (guard.variable !== variable || typeof guard.value !== 'number')
2834
+ return false;
2835
+ if (guard.op === '>')
2836
+ return guard.value + 1 >= bound;
2837
+ if (guard.op === '>=' || guard.op === '==')
2838
+ return guard.value >= bound;
2839
+ return false;
2840
+ }
2841
+ if ('all' in guard)
2842
+ return guard.all.some((child) => guardImpliesLowerBound(child, variable, bound));
2843
+ if ('any' in guard)
2844
+ return guard.any.every((child) => guardImpliesLowerBound(child, variable, bound));
2845
+ return false;
2846
+ }
2847
+ /**
2848
+ * Whether the model's own declarations about a variable hold against what its
2849
+ * transitions do. `inc` and `dec` are never clamped to `min`/`max` — the declared
2850
+ * range is a property to check, not a bound on the search — so a counter whose guard
2851
+ * does not pin it ranges past its own declaration, and the reachable set is unbounded
2852
+ * however narrow the declaration looks.
2853
+ *
2854
+ * Reporting the declaration rather than silently clamping is deliberate. Clamping would
2855
+ * delete the very states a range violation is witnessed by, turning a counterexample
2856
+ * into a false pass.
2857
+ */
2858
+ function declaredDomainFindings(model) {
2859
+ const findings = [];
2860
+ for (const transition of model.transitions) {
2861
+ for (const update of transition.updates ?? []) {
2862
+ const variable = (model.variables ?? []).find((entry) => entry.name === update.variable);
2863
+ if (variable === undefined)
2864
+ continue;
2865
+ const delta = typeof update.value === 'number' ? update.value : 1;
2866
+ const at = transition.from + ' + ' + transition.event;
2867
+ if (update.op === 'inc' && variable.max !== undefined && !guardImpliesUpperBound(transition.guard, variable.name, variable.max - delta)) {
2868
+ findings.push({
2869
+ code: 'S8_DOMAIN_NOT_ENFORCED',
2870
+ severity: 'warning',
2871
+ message: at + ' increments ' + variable.name + ', but ' + variable.name + ' declares max ' + variable.max + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
2872
+ evidence: { variable: variable.name, declaredMax: variable.max, transition: at },
2873
+ });
2874
+ }
2875
+ if (update.op === 'dec' && variable.min !== undefined && !guardImpliesLowerBound(transition.guard, variable.name, variable.min + delta)) {
2876
+ findings.push({
2877
+ code: 'S8_DOMAIN_NOT_ENFORCED',
2878
+ severity: 'warning',
2879
+ message: at + ' decrements ' + variable.name + ', but ' + variable.name + ' declares min ' + variable.min + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
2880
+ evidence: { variable: variable.name, declaredMin: variable.min, transition: at },
2881
+ });
2882
+ }
2883
+ if (update.op === 'set' && typeof update.value === 'number') {
2884
+ if (variable.min !== undefined && update.value < variable.min) {
2885
+ findings.push({
2886
+ code: 'S8_DOMAIN_SET_OUTSIDE',
2887
+ severity: 'error',
2888
+ message: at + ' sets ' + variable.name + ' to ' + update.value + ', below its declared min ' + variable.min + '.',
2889
+ evidence: { variable: variable.name, value: update.value, declaredMin: variable.min },
2890
+ });
2891
+ }
2892
+ if (variable.max !== undefined && update.value > variable.max) {
2893
+ findings.push({
2894
+ code: 'S8_DOMAIN_SET_OUTSIDE',
2895
+ severity: 'error',
2896
+ message: at + ' sets ' + variable.name + ' to ' + update.value + ', above its declared max ' + variable.max + '.',
2897
+ evidence: { variable: variable.name, value: update.value, declaredMax: variable.max },
2898
+ });
2899
+ }
2900
+ }
2901
+ }
2902
+ }
2903
+ return findings;
2904
+ }
2905
+ function S8_monotonicVariables(model) {
2906
+ const findings = [];
2907
+ for (const variable of model.variables ?? []) {
2908
+ if (variable.monotonic === undefined)
2909
+ continue;
2910
+ for (const transition of model.transitions) {
2911
+ for (const update of transition.updates ?? []) {
2912
+ if (update.variable !== variable.name)
2913
+ continue;
2914
+ if (variable.monotonic === 'inc' && update.op === 'dec') {
2915
+ findings.push({ code: 'S8_MONOTONIC_DECREASE', severity: 'error', message: 'Monotonic (inc) variable ' + variable.name + ' is decreased by ' + transition.event + '.', evidence: { variable: variable.name, transition } });
2916
+ }
2917
+ else if (variable.monotonic === 'dec' && update.op === 'inc') {
2918
+ findings.push({ code: 'S8_MONOTONIC_INCREASE', severity: 'error', message: 'Monotonic (dec) variable ' + variable.name + ' is increased by ' + transition.event + '.', evidence: { variable: variable.name, transition } });
2919
+ }
2920
+ else if (update.op === 'set') {
2921
+ findings.push({ code: 'S8_MONOTONIC_SET_REVIEW', severity: 'warning', message: 'Monotonic variable ' + variable.name + ' uses set in ' + transition.event + '; verify it cannot move backwards.', evidence: { variable: variable.name, transition } });
2922
+ }
2923
+ }
2924
+ }
2925
+ }
2926
+ findings.push(...declaredDomainFindings(model));
2927
+ return checkResult('S8', 'Variable Declarations', findings, findings.length === 0 ? 'Variable declarations are respected' : 'Variable declaration findings: ' + findings.length);
2928
+ }
2929
+ /**
2930
+ * Target states of a `leads-to` invariant. The schema accepts one state id or a
2931
+ * non-empty array of them, so "reach any of these" needs no separate invariant kind.
2932
+ */
2933
+ function leadsToTargets(invariant) {
2934
+ return typeof invariant.to === 'string' ? [invariant.to] : invariant.to;
2935
+ }
2936
+ /** Target list as findings print it. A single target renders as its bare id, so existing messages stay byte-identical. */
2937
+ function leadsToTargetLabel(invariant) {
2938
+ return leadsToTargets(invariant).join(', ');
2939
+ }
2940
+ /**
2941
+ * Find a run from `start` that reaches none of `targets`, or return undefined when
2942
+ * every run reaches at least one of them. The property is universal (see the A9 row
2943
+ * in `SKILL.md`): one branch that loops forever, or that stops before any target,
2944
+ * refutes it.
2945
+ *
2946
+ * The search is a depth-first walk of the run graph with three colours, and runs at a
2947
+ * target are success leaves that are never expanded:
2948
+ *
2949
+ * - a node reached while it is on the current walk (GRAY) closes a cycle that avoids
2950
+ * every target, so some run never reaches one;
2951
+ * - a node with no outgoing step at all stops the machine where it stands, which is
2952
+ * the same violation for a different reason;
2953
+ * - a node whose walk completed without a violation is BLACK, and reaching it again
2954
+ * from another branch is a shared sub-graph, not a cycle.
2955
+ *
2956
+ * That last point is why the colour map cannot be a plain visited set: a diamond
2957
+ * (two branches rejoining) is acyclic and must pass, while a genuine cycle must not.
2958
+ * A run is identified by its state plus its variable values, so the same state id
2959
+ * with different values is a different node.
2960
+ *
2961
+ * Two properties keep the walk from taking the host down, and both are load-bearing:
2962
+ * the walk is **budgeted**, and it stores a **parent link** per node instead of a copy
2963
+ * of the path. Without the budget an unbounded counter makes the run graph infinite, so
2964
+ * the three colours above never repeat a key and the walk never returns; without the
2965
+ * parent link every frame retains its own copy of the path, which grows the walk's
2966
+ * memory quadratically in its depth. Either one alone turns "the model is wrong" into
2967
+ * an out-of-memory abort, which is the failure this function must not have.
2968
+ *
2969
+ * An exhausted budget is reported as `undecided`, never as `undefined`: a walk that
2970
+ * did not finish has not proven that every run reaches a target.
2971
+ */
2972
+ function findLeadsToBadPath(model, start, targets, label, walk) {
2973
+ if (targets.has(start.state))
2974
+ return { undecided: false };
2975
+ const GRAY = 1;
2976
+ const BLACK = 2;
2977
+ const pathOf = (frame) => {
2978
+ const steps = [];
2979
+ let node = frame;
2980
+ while (node !== undefined && node.step !== undefined) {
2981
+ steps.push(node.step);
2982
+ node = node.parent;
2983
+ }
2984
+ return steps.reverse();
2985
+ };
2986
+ const color = new Map();
2987
+ const stack = [{ runtime: start, parent: undefined, step: undefined, nexts: undefined, index: 0 }];
2988
+ color.set(runtimeKey(start), GRAY);
2989
+ while (stack.length > 0) {
2990
+ if (walk.remaining <= 0)
2991
+ return { undecided: true };
2992
+ walk.remaining -= 1;
2993
+ const frame = stack[stack.length - 1];
2994
+ if (frame.nexts === undefined) {
2995
+ const nexts = [];
2996
+ for (const event of allEvents(model)) {
2997
+ for (const next of stepRuntime(model, frame.runtime, event))
2998
+ nexts.push({ next, event });
2999
+ }
3000
+ frame.nexts = nexts;
3001
+ if (nexts.length === 0)
3002
+ return { violation: { path: pathOf(frame), reason: 'Dead end before target ' + label }, undecided: false };
3003
+ }
3004
+ if (frame.index >= frame.nexts.length) {
3005
+ color.set(runtimeKey(frame.runtime), BLACK);
3006
+ stack.pop();
3007
+ continue;
3008
+ }
3009
+ const { next, event } = frame.nexts[frame.index];
3010
+ frame.index += 1;
3011
+ const step = { from: frame.runtime.state, event, to: next.state };
3012
+ if (targets.has(next.state))
3013
+ continue;
3014
+ const key = runtimeKey(next);
3015
+ const seen = color.get(key);
3016
+ if (seen === GRAY)
3017
+ return { violation: { path: [...pathOf(frame), step], reason: 'Cycle avoids target ' + label }, undecided: false };
3018
+ if (seen === BLACK)
3019
+ continue;
3020
+ color.set(key, GRAY);
3021
+ stack.push({ runtime: next, parent: frame, step, nexts: undefined, index: 0 });
3022
+ }
3023
+ // Every branch either reached a target or rejoined a branch that did.
3024
+ return { undecided: false };
3025
+ }
3026
+ function A9_leadsTo(model, exploration, options) {
3027
+ const findings = [];
3028
+ for (const invariant of model.invariants ?? []) {
3029
+ if (invariant.kind !== 'leads-to')
3030
+ continue;
3031
+ const targets = new Set(leadsToTargets(invariant));
3032
+ const label = leadsToTargetLabel(invariant);
3033
+ const budget = leadsToWalkBudget(options);
3034
+ const walk = { remaining: budget };
3035
+ for (const runtime of exploration.reachable) {
3036
+ if (runtime.state !== invariant.from)
3037
+ continue;
3038
+ const outcome = findLeadsToBadPath(model, runtime, targets, label, walk);
3039
+ if (outcome.undecided) {
3040
+ findings.push({
3041
+ code: 'A9_LEADS_TO_UNDECIDED',
3042
+ severity: 'warning',
3043
+ message: 'Leads-to invariant "' + invariant.id + '" is undecided: the walk spent its budget of ' + budget + ' steps from ' + invariant.from + ' without finding a run that avoids ' + label + ', or proving that none exists. Absence is not proven.',
3044
+ evidence: { invariant },
3045
+ });
3046
+ break;
3047
+ }
3048
+ if (outcome.violation !== undefined) {
3049
+ findings.push({
3050
+ code: 'A9_LEADS_TO_VIOLATION',
3051
+ severity: 'error',
3052
+ message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + outcome.violation.reason,
3053
+ path: outcome.violation.path,
3054
+ evidence: { invariant },
3055
+ });
3056
+ break;
3057
+ }
3058
+ }
3059
+ }
3060
+ const undecided = findings.some((finding) => finding.code === 'A9_LEADS_TO_UNDECIDED');
3061
+ return checkResult('A9', 'Leads-To', findings, findings.length === 0
3062
+ ? 'All leads-to invariants hold'
3063
+ : undecided ? 'Leads-to undecided: the search budget ran out' : 'Leads-to findings: ' + findings.length);
3064
+ }
3065
+ function findSequenceViolation(model, options, invariant) {
3066
+ const events = invariant.events;
3067
+ const init = initialState(model);
3068
+ const key = (runtime, progress) => runtimeKey(runtime) + '|' + progress;
3069
+ const visited = new Set([key(init, 0)]);
3070
+ const queue = [{ runtime: init, progress: 0, path: [] }];
3071
+ let steps = 0;
3072
+ // A cap is not a verdict. This walk keys on the runtime plus the progress along the declared order, so it
3073
+ // outlasts the exploration, and the caller has to be able to tell "no violation" from "not finished".
3074
+ let capped = false;
3075
+ while (queue.length > 0) {
3076
+ const entry = queue.shift();
3077
+ if (++steps > options.maxStates) {
3078
+ capped = true;
3079
+ break;
3080
+ }
3081
+ for (const event of allEvents(model)) {
3082
+ for (const next of stepRuntime(model, entry.runtime, event)) {
3083
+ let progress = entry.progress;
3084
+ let violation = false;
3085
+ if (progress < events.length && event === events[progress]) {
3086
+ progress += 1;
3087
+ }
3088
+ else {
3089
+ const index = events.indexOf(event);
3090
+ if (index > progress)
3091
+ violation = true;
3092
+ }
3093
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
3094
+ if (violation) {
3095
+ return { invariant, path, reason: 'Event ' + event + ' occurred before ' + events[progress] };
3096
+ }
3097
+ const nextKey = key(next, progress);
3098
+ if (!visited.has(nextKey)) {
3099
+ visited.add(nextKey);
3100
+ queue.push({ runtime: next, progress, path });
3101
+ }
3102
+ }
3103
+ }
3104
+ }
3105
+ if (capped)
3106
+ return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
3107
+ return undefined;
3108
+ }
3109
+ function A10_sequenceOrder(model, options) {
3110
+ const findings = [];
3111
+ for (const invariant of model.invariants ?? []) {
3112
+ if (invariant.kind !== 'sequence')
3113
+ continue;
3114
+ const violation = findSequenceViolation(model, options, invariant);
3115
+ if (violation === undefined)
3116
+ continue;
3117
+ if (violation.undecided) {
3118
+ findings.push({
3119
+ code: 'A10_UNDECIDED',
3120
+ severity: 'warning',
3121
+ message: 'Sequence invariant "' + invariant.id + '" (' + invariant.description + ') could not be decided: ' + violation.reason + '. Absence of a violation is not proven.',
3122
+ evidence: { invariant, maxStates: options.maxStates },
3123
+ });
3124
+ continue;
3125
+ }
3126
+ findings.push({
3127
+ code: 'A10_SEQUENCE_VIOLATION',
3128
+ severity: 'error',
3129
+ message: 'Sequence invariant "' + invariant.id + '" violated: ' + violation.reason,
3130
+ path: violation.path,
3131
+ evidence: { invariant },
3132
+ });
3133
+ }
3134
+ const undecided = findings.some((finding) => finding.code === 'A10_UNDECIDED');
3135
+ return checkResult('A10', 'Sequence Order', findings, findings.length === 0
3136
+ ? 'All sequence invariants hold'
3137
+ : undecided && findings.every((finding) => finding.code === 'A10_UNDECIDED') ? 'Sequence walk stopped at the state budget: undecided' : 'Sequence findings: ' + findings.length);
3138
+ }
3139
+ function findAtomicityViolation(model, options, invariant) {
3140
+ const atomic = new Set(invariant.events);
3141
+ const init = initialState(model);
3142
+ const key = (runtime, started, closed) => runtimeKey(runtime) + '|' + (started ? '1' : '0') + '|' + (closed ? '1' : '0');
3143
+ const visited = new Set([key(init, false, false)]);
3144
+ const queue = [{ runtime: init, started: false, closed: false, path: [] }];
3145
+ let steps = 0;
3146
+ // Same third outcome as the other walks: this one keys on the runtime plus the two flags of the atomic group,
3147
+ // so it can outlast the exploration, and "no leak" is not the same answer as "not finished".
3148
+ let capped = false;
3149
+ while (queue.length > 0) {
3150
+ const entry = queue.shift();
3151
+ if (++steps > options.maxStates) {
3152
+ capped = true;
3153
+ break;
3154
+ }
3155
+ for (const event of allEvents(model)) {
3156
+ for (const next of stepRuntime(model, entry.runtime, event)) {
3157
+ const started = entry.started || atomic.has(event);
3158
+ const closed = entry.closed || event === invariant.commit || (invariant.rollback !== undefined && event === invariant.rollback);
3159
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
3160
+ if (entry.started && !entry.closed && !atomic.has(event) && event !== invariant.commit && event !== invariant.rollback) {
3161
+ return { invariant, path, reason: 'Left atomic scope via ' + event + ' without commit/rollback' };
3162
+ }
3163
+ if (started && !closed && isTerminal(model, next.state)) {
3164
+ return { invariant, path, reason: 'Terminal state reached with incomplete atomic group' };
3165
+ }
3166
+ const nextKey = key(next, started, closed);
3167
+ if (!visited.has(nextKey)) {
3168
+ visited.add(nextKey);
3169
+ queue.push({ runtime: next, started, closed, path });
3170
+ }
3171
+ }
3172
+ }
3173
+ }
3174
+ if (capped)
3175
+ return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
3176
+ return undefined;
3177
+ }
3178
+ function A11_atomicity(model, options) {
3179
+ const findings = [];
3180
+ for (const invariant of model.invariants ?? []) {
3181
+ if (invariant.kind !== 'atomicity')
3182
+ continue;
3183
+ const violation = findAtomicityViolation(model, options, invariant);
3184
+ if (violation === undefined)
3185
+ continue;
3186
+ if (violation.undecided) {
3187
+ findings.push({
3188
+ code: 'A11_UNDECIDED',
3189
+ severity: 'warning',
3190
+ message: 'Atomicity invariant "' + invariant.id + '" (' + invariant.description + ') could not be decided: ' + violation.reason + '. Absence of a violation is not proven.',
3191
+ evidence: { invariant, maxStates: options.maxStates },
3192
+ });
3193
+ continue;
3194
+ }
3195
+ findings.push({
3196
+ code: 'A11_ATOMICITY_VIOLATION',
3197
+ severity: 'error',
3198
+ message: 'Atomicity invariant "' + invariant.id + '" violated: ' + violation.reason,
3199
+ path: violation.path,
3200
+ evidence: { invariant },
3201
+ });
3202
+ }
3203
+ const undecided = findings.some((finding) => finding.code === 'A11_UNDECIDED');
3204
+ return checkResult('A11', 'Atomicity', findings, findings.length === 0
3205
+ ? 'All atomicity invariants hold'
3206
+ : undecided && findings.every((finding) => finding.code === 'A11_UNDECIDED') ? 'Atomicity walk stopped at the state budget: undecided' : 'Atomicity findings: ' + findings.length);
3207
+ }
3208
+ // ---------------------------------------------------------------------------
3209
+ // A12 — Budget (worst-case path cost)
3210
+ // ---------------------------------------------------------------------------
3211
+ function transitionCost(transition) {
3212
+ return transition.cost ?? 1;
3213
+ }
3214
+ function runtimeGraph(model, options) {
3215
+ const groupMap = groupTransitions(model);
3216
+ const exploration = explore(model, options);
3217
+ const edges = new Map();
3218
+ for (const runtime of exploration.reachable) {
3219
+ const out = [];
3220
+ for (const event of allEvents(model)) {
3221
+ const group = groupMap.get(runtime.state + '|' + event);
3222
+ if (group === undefined || group.length === 0)
3223
+ continue;
3224
+ for (const transition of applicableTransitions(group, runtime)) {
3225
+ const next = applyUpdates(model, transition, runtime);
3226
+ out.push({
3227
+ to: runtimeKey(next),
3228
+ step: { from: runtime.state, event: transition.event, to: next.state },
3229
+ cost: transitionCost(transition),
3230
+ });
3231
+ }
3232
+ }
3233
+ edges.set(runtimeKey(runtime), out);
3234
+ }
3235
+ return {
3236
+ edges,
3237
+ keys: exploration.reachable.map((runtime) => runtimeKey(runtime)),
3238
+ initKey: runtimeKey(exploration.initialState),
3239
+ };
3240
+ }
3241
+ /** Shortest path from init to targetKey over the runtime graph, or undefined when unreachable. */
3242
+ function runtimePathToKey(graph, targetKey) {
3243
+ const parent = new Map();
3244
+ const visited = new Set([graph.initKey]);
3245
+ const queue = [graph.initKey];
3246
+ while (queue.length > 0) {
3247
+ const current = queue.shift();
3248
+ if (current === targetKey) {
3249
+ const steps = [];
3250
+ let key = targetKey;
3251
+ while (key !== graph.initKey) {
3252
+ const step = parent.get(key);
3253
+ if (step === undefined)
3254
+ break;
3255
+ steps.unshift(step);
3256
+ key = step.from === undefined ? graph.initKey : keyOfFromStep(graph, step, key);
3257
+ }
3258
+ return steps;
3259
+ }
3260
+ for (const edge of graph.edges.get(current) ?? []) {
3261
+ if (visited.has(edge.to))
3262
+ continue;
3263
+ visited.add(edge.to);
3264
+ parent.set(edge.to, edge.step);
3265
+ queue.push(edge.to);
3266
+ }
3267
+ }
3268
+ return undefined;
3269
+ }
3270
+ function keyOfFromStep(graph, step, targetKey) {
3271
+ // Reconstruct the source key by scanning predecessors of targetKey whose step matches.
3272
+ for (const [from, edges] of graph.edges) {
3273
+ for (const edge of edges) {
3274
+ if (edge.to === targetKey && edge.step.event === step.event && edge.step.from === step.from && edge.step.to === step.to) {
3275
+ return from;
3276
+ }
3277
+ }
3278
+ }
3279
+ return graph.initKey;
3280
+ }
3281
+ /** Find a reachable cycle whose total cost is positive; return a witness path init -> one full round. */
3282
+ function findUnboundedCycle(model, options) {
3283
+ const graph = runtimeGraph(model, options);
3284
+ const color = new Map();
3285
+ for (const start of graph.keys) {
3286
+ if (color.has(start))
3287
+ continue;
3288
+ const nodeStack = [start];
3289
+ const idxStack = [0];
3290
+ const depthOf = new Map([[start, 0]]);
3291
+ const costAt = new Map([[start, 0]]);
3292
+ const prevKey = new Map();
3293
+ const prevStep = new Map();
3294
+ color.set(start, 1);
3295
+ while (nodeStack.length > 0) {
3296
+ const node = nodeStack[nodeStack.length - 1];
3297
+ const outs = graph.edges.get(node) ?? [];
3298
+ if (idxStack[idxStack.length - 1] < outs.length) {
3299
+ const edge = outs[idxStack[idxStack.length - 1]++];
3300
+ const seen = color.get(edge.to);
3301
+ if (seen === undefined) {
3302
+ color.set(edge.to, 1);
3303
+ depthOf.set(edge.to, depthOf.get(node) + 1);
3304
+ costAt.set(edge.to, costAt.get(node) + edge.cost);
3305
+ prevKey.set(edge.to, node);
3306
+ prevStep.set(edge.to, edge.step);
3307
+ nodeStack.push(edge.to);
3308
+ idxStack.push(0);
3309
+ }
3310
+ else if (seen === 1) {
3311
+ // Back edge on the current path: this is a true cycle.
3312
+ const cycleCost = costAt.get(node) + edge.cost - costAt.get(edge.to);
3313
+ if (cycleCost > 0) {
3314
+ const entry = runtimePathToKey(graph, edge.to) ?? [];
3315
+ const round = [];
3316
+ let cursor = node;
3317
+ while (cursor !== edge.to && prevStep.has(cursor)) {
3318
+ round.unshift(prevStep.get(cursor));
3319
+ cursor = prevKey.get(cursor);
3320
+ }
3321
+ round.push(edge.step);
3322
+ return [...entry, ...round];
3323
+ }
3324
+ }
3325
+ }
3326
+ else {
3327
+ color.set(node, 2);
3328
+ nodeStack.pop();
3329
+ idxStack.pop();
3330
+ depthOf.delete(node);
3331
+ costAt.delete(node);
3332
+ prevKey.delete(node);
3333
+ prevStep.delete(node);
3334
+ }
3335
+ }
3336
+ }
3337
+ return undefined;
3338
+ }
3339
+ function findBudgetViolation(model, options, invariant) {
3340
+ const groupMap = groupTransitions(model);
3341
+ const init = initialState(model);
3342
+ const bestCost = new Map([[runtimeKey(init), 0]]);
3343
+ const queue = [{ runtime: init, cost: 0, path: [] }];
3344
+ let steps = 0;
3345
+ while (queue.length > 0) {
3346
+ const entry = queue.shift();
3347
+ // Stopping here decides nothing: the cost of the paths beyond the budget is unknown, which is not the
3348
+ // same answer as 'no violation'. It is the third outcome this function now has.
3349
+ if (++steps > options.maxStates)
3350
+ return 'undecided';
3351
+ for (const event of allEvents(model)) {
3352
+ const group = groupMap.get(entry.runtime.state + '|' + event);
3353
+ if (group === undefined || group.length === 0)
3354
+ continue;
3355
+ for (const transition of applicableTransitions(group, entry.runtime)) {
3356
+ const next = applyUpdates(model, transition, entry.runtime);
3357
+ const cost = entry.cost + transitionCost(transition);
3358
+ const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }];
3359
+ // Costs are non-negative, so the first step that pushes the accumulated cost
3360
+ // over the budget is already a witness.
3361
+ if (cost > invariant.budget)
3362
+ return { path, cost, unbounded: false };
3363
+ const key = runtimeKey(next);
3364
+ const best = bestCost.get(key);
3365
+ if (best === undefined || cost < best) {
3366
+ bestCost.set(key, cost);
3367
+ queue.push({ runtime: next, cost, path });
3368
+ }
3369
+ }
3370
+ }
3371
+ }
3372
+ // No finite path exceeds the budget within reachable space; a reachable positive-cost
3373
+ // cycle still makes the worst-case unbounded. Plain DAG merges are not cycles.
3374
+ const cycle = findUnboundedCycle(model, options);
3375
+ if (cycle !== undefined)
3376
+ return { path: cycle, unbounded: true };
3377
+ return undefined;
3378
+ }
3379
+ function A12_budget(model, options) {
3380
+ const budgets = (model.invariants ?? []).filter((invariant) => invariant.kind === 'budget');
3381
+ const usesCost = model.transitions.some((transition) => transition.cost !== undefined);
3382
+ const findings = [];
3383
+ if (usesCost && budgets.length === 0) {
3384
+ findings.push({
3385
+ code: 'A12_COST_WITHOUT_BUDGET',
3386
+ severity: 'warning',
3387
+ 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.',
3388
+ });
3389
+ }
3390
+ for (const invariant of budgets) {
3391
+ const violation = findBudgetViolation(model, options, invariant);
3392
+ if (violation === 'undecided') {
3393
+ findings.push({
3394
+ code: 'A12_UNDECIDED',
3395
+ severity: 'warning',
3396
+ message: 'The worst-case path search for budget invariant "' + invariant.id + '" stopped at the ' + String(options.maxStates) + ' state budget before it finished, so the budget is undecided rather than met. Raise maxStates, or narrow the model.',
3397
+ evidence: { invariant, maxStates: options.maxStates },
3398
+ });
3399
+ continue;
3400
+ }
3401
+ if (violation !== undefined) {
3402
+ findings.push({
3403
+ code: 'A12_BUDGET_OVER',
3404
+ severity: 'error',
3405
+ message: violation.unbounded
3406
+ ? '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.'
3407
+ : 'Budget invariant "' + invariant.id + '" (' + invariant.description + ') exceeded: worst-case path cost ' + violation.cost + ' is over budget ' + invariant.budget + '.',
3408
+ path: violation.path,
3409
+ evidence: violation.unbounded ? { invariant, unbounded: true } : { invariant, totalCost: violation.cost, unbounded: false },
3410
+ });
3411
+ }
3412
+ }
3413
+ const undecided = findings.some((finding) => finding.code === 'A12_UNDECIDED');
3414
+ return checkResult('A12', 'Budget', findings, undecided ? 'Budget walk stopped at the state budget: undecided' : findings.length === 0 ? (budgets.length === 0 ? 'No budget invariants declared' : 'All budget invariants hold') : 'Budget findings: ' + findings.length);
3415
+ }
3416
+ // ---------------------------------------------------------------------------
3417
+ // A13 — Probability reachability (DTMC)
3418
+ // ---------------------------------------------------------------------------
3419
+ function probabilityOutcomes(model, runtime) {
3420
+ const groupMap = groupTransitions(model);
3421
+ const outcomes = new Map();
3422
+ for (const event of allEvents(model)) {
3423
+ const group = groupMap.get(runtime.state + '|' + event);
3424
+ if (group === undefined || group.length === 0)
3425
+ continue;
3426
+ for (const transition of applicableTransitions(group, runtime)) {
3427
+ const weight = transition.weight ?? 1;
3428
+ if (!(weight > 0))
3429
+ continue;
3430
+ const next = applyUpdates(model, transition, runtime);
3431
+ const key = runtimeKey(next);
3432
+ const current = outcomes.get(key);
3433
+ if (current === undefined)
3434
+ outcomes.set(key, { state: next.state, weight });
3435
+ else
3436
+ current.weight += weight;
3437
+ }
3438
+ }
3439
+ return outcomes;
3440
+ }
3441
+ /**
3442
+ * Probability of ever reaching targetState from the initial state under the DTMC
3443
+ * interpretation: at each runtime state every applicable transition with weight > 0 is an
3444
+ * outcome chosen proportionally to its weight. Solved by value iteration (least fixed
3445
+ * point of the absorbing chain).
3446
+ */
3447
+ function computeHitProbability(model, options, targetState) {
3448
+ const exploration = explore(model, options);
3449
+ const reachable = exploration.reachable;
3450
+ const n = reachable.length;
3451
+ const keyIndex = new Map();
3452
+ reachable.forEach((runtime, i) => keyIndex.set(runtimeKey(runtime), i));
3453
+ const fixedValue = new Array(n).fill(null);
3454
+ const chains = [];
3455
+ for (let i = 0; i < n; i++) {
3456
+ const runtime = reachable[i];
3457
+ if (runtime.state === targetState) {
3458
+ fixedValue[i] = 1;
3459
+ chains.push([]);
3460
+ continue;
3461
+ }
3462
+ const outs = probabilityOutcomes(model, runtime);
3463
+ let sum = 0;
3464
+ const list = [];
3465
+ for (const [key, entry] of outs) {
3466
+ const j = keyIndex.get(key);
3467
+ if (j === undefined)
3468
+ continue;
3469
+ list.push({ j, w: entry.weight });
3470
+ sum += entry.weight;
3471
+ }
3472
+ if (sum <= 0) {
3473
+ fixedValue[i] = 0;
3474
+ chains.push([]);
3475
+ continue;
3476
+ }
3477
+ chains.push(list.map((item) => ({ j: item.j, w: item.w / sum })));
3478
+ }
3479
+ const p = new Array(n).fill(0);
3480
+ let converged = false;
3481
+ for (let iter = 0; iter < 20000; iter++) {
3482
+ let maxDelta = 0;
3483
+ for (let i = 0; i < n; i++) {
3484
+ if (fixedValue[i] !== null)
3485
+ continue;
3486
+ const list = chains[i];
3487
+ let acc = 0;
3488
+ for (const item of list) {
3489
+ const fixed = fixedValue[item.j];
3490
+ acc += item.w * (fixed === null ? p[item.j] : fixed);
3491
+ }
3492
+ const delta = Math.abs(acc - p[i]);
3493
+ if (delta > maxDelta)
3494
+ maxDelta = delta;
3495
+ p[i] = acc;
3496
+ }
3497
+ if (maxDelta < 1e-9) {
3498
+ converged = true;
3499
+ break;
3500
+ }
3501
+ }
3502
+ const initIndex = keyIndex.get(runtimeKey(exploration.initialState)) ?? 0;
3503
+ const fixed = fixedValue[initIndex];
3504
+ return { probability: fixed === null ? p[initIndex] : fixed, converged };
3505
+ }
3506
+ function A13_probability(model, options) {
3507
+ const findings = [];
3508
+ for (const invariant of model.invariants ?? []) {
3509
+ if (invariant.kind !== 'probability')
3510
+ continue;
3511
+ const { probability, converged } = computeHitProbability(model, options, invariant.target);
3512
+ if (probabilityViolated(invariant, probability)) {
3513
+ findings.push({
3514
+ code: 'A13_PROBABILITY_VIOLATION',
3515
+ severity: 'error',
3516
+ message: 'Probability invariant "' + invariant.id + '" (' + invariant.description + ') violated: P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + invariant.p + '.',
3517
+ evidence: { invariant, computed: probability, converged },
3518
+ });
3519
+ }
3520
+ else if (!converged) {
3521
+ findings.push({
3522
+ code: 'A13_NO_CONVERGENCE',
3523
+ severity: 'warning',
3524
+ message: 'Probability invariant "' + invariant.id + '" passes, but value iteration did not fully converge within the iteration cap.',
3525
+ evidence: { invariant, computed: probability, converged },
3526
+ });
3527
+ }
3528
+ }
3529
+ return checkResult('A13', 'Probability Reachability', findings, findings.length === 0 ? 'No probability invariants declared or all hold' : 'Probability findings: ' + findings.length);
3530
+ }
3531
+ // ---------------------------------------------------------------------------
3532
+ // A14 — Deadline (discrete tick clock)
3533
+ // ---------------------------------------------------------------------------
3534
+ function A14_deadline(model, options) {
3535
+ const findings = [];
3536
+ const tickEvents = new Set(model.tickEvents ?? []);
3537
+ const limits = new Map();
3538
+ let hasLimit = false;
3539
+ for (const state of model.states) {
3540
+ if (state.maxTicks !== undefined) {
3541
+ limits.set(state.id, state.maxTicks);
3542
+ hasLimit = true;
3543
+ }
3544
+ }
3545
+ if (!hasLimit) {
3546
+ return checkResult('A14', 'Deadline', withUnresolved(model, 'A14', []), 'No state declares maxTicks');
3547
+ }
3548
+ if (tickEvents.size === 0) {
3549
+ return checkResult('A14', 'Deadline', withUnresolved(model, 'A14', [{
3550
+ code: 'A14_NO_TICK_EVENTS',
3551
+ severity: 'warning',
3552
+ message: 'States declare maxTicks, but no tickEvents are declared, so deadline compliance cannot be verified.',
3553
+ }]), 'Missing tick events');
3554
+ }
3555
+ const groupMap = groupTransitions(model);
3556
+ const init = initialState(model);
3557
+ const limitOf = (stateId) => limits.get(stateId);
3558
+ const queue = [{ runtime: init, ticks: 0, path: [] }];
3559
+ const seen = new Set();
3560
+ let steps = 0;
3561
+ // Whether the walk stopped because the budget ran out rather than because the space was exhausted. A
3562
+ // search that did not finish cannot answer "all deadlines hold", so the check has to say which it was.
3563
+ let capped = false;
3564
+ while (queue.length > 0) {
3565
+ const entry = queue.shift();
3566
+ if (++steps > options.maxStates) {
3567
+ capped = true;
3568
+ break;
3569
+ }
3570
+ const key = runtimeKey(entry.runtime) + '|' + entry.ticks;
3571
+ if (seen.has(key))
3572
+ continue;
3573
+ seen.add(key);
3574
+ for (const event of allEvents(model)) {
3575
+ const group = groupMap.get(entry.runtime.state + '|' + event);
3576
+ if (group === undefined || group.length === 0)
3577
+ continue;
3578
+ for (const transition of applicableTransitions(group, entry.runtime)) {
3579
+ const next = applyUpdates(model, transition, entry.runtime);
3580
+ const isTick = tickEvents.has(event);
3581
+ const stays = next.state === entry.runtime.state;
3582
+ const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }];
3583
+ let ticks;
3584
+ if (isTick && stays) {
3585
+ const lim = limitOf(next.state);
3586
+ if (lim !== undefined && entry.ticks + 1 > lim) {
3587
+ findings.push({
3588
+ code: 'A14_DEADLINE_MISS',
3589
+ severity: 'error',
3590
+ message: 'Deadline missed: state ' + next.state + ' can remain resident for more than ' + lim + ' tick(s).',
3591
+ path,
3592
+ evidence: { state: next.state, maxTicks: lim, ticks: entry.ticks + 1 },
3593
+ });
3594
+ continue;
3595
+ }
3596
+ ticks = entry.ticks + 1;
3597
+ }
3598
+ else if (isTick && !stays) {
3599
+ ticks = 0;
3600
+ }
3601
+ else if (!isTick && stays) {
3602
+ ticks = entry.ticks;
3603
+ }
3604
+ else {
3605
+ ticks = 0;
3606
+ }
3607
+ const nextKey = runtimeKey(next) + '|' + ticks;
3608
+ if (!seen.has(nextKey))
3609
+ queue.push({ runtime: next, ticks, path });
3610
+ }
3611
+ }
3612
+ }
3613
+ // A capped walk decided nothing about the part it never reached. `_UNDECIDED` is the vocabulary the schema
3614
+ // reference names for exactly this, and a code ending in `_UNDECIDED` carries it into `summary.truncated`.
3615
+ if (capped) {
3616
+ findings.push({
3617
+ code: 'A14_UNDECIDED',
3618
+ severity: 'warning',
3619
+ message: 'The deadline walk stopped at the ' + String(options.maxStates) + ' state budget before it finished, so deadline compliance is undecided rather than proven. Raise maxStates, or narrow the model.',
3620
+ evidence: { maxStates: options.maxStates, steps },
3621
+ });
3622
+ }
3623
+ return checkResult('A14', 'Deadline', withUnresolved(model, 'A14', findings), capped ? 'Deadline walk stopped at the state budget: undecided' : findings.length === 0 ? 'All deadlines respected' : 'Deadline findings: ' + findings.length);
3624
+ }
3625
+ // ---------------------------------------------------------------------------
3626
+ // Coverage notes (informational gap notices)
3627
+ // ---------------------------------------------------------------------------
3628
+ // Underscore-separated identifiers like watchdog_timeout or isr_cmd are common in
3629
+ // state machines, so boundaries must treat '_' as a separator rather than a word char.
3630
+ const TIMING_VOCABULARY = /(?<![a-z0-9])(timeout|watchdog|timer|tick|deadline|period|delay|elapsed|latency)(?![a-z0-9])/i;
3631
+ const PREEMPTION_VOCABULARY = /(?<![a-z0-9])(isr|irq|interrupt|task|thread|preempt|rtos)(?![a-z0-9])/i;
3632
+ 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;
3633
+ const PROBABILISTIC_VOCABULARY = /(?<![a-z0-9])(mtbf|mttf|failure.?rate|reliability|probability|probabilistic|markov|stochastic|fault.?tree|fmea)(?![a-z0-9])/i;
3634
+ function computeCoverageNotes(model) {
3635
+ const names = [];
3636
+ for (const state of model.states) {
3637
+ names.push(state.id);
3638
+ for (const action of state.onEntry ?? [])
3639
+ names.push(action);
3640
+ for (const action of state.onExit ?? [])
3641
+ names.push(action);
3642
+ }
3643
+ for (const transition of model.transitions)
3644
+ names.push(transition.event);
3645
+ const corpus = names.join(' ');
3646
+ const notes = [];
3647
+ if (TIMING_VOCABULARY.test(corpus)) {
3648
+ notes.push('The model references time-like vocabulary (timeout/watchdog/timer/deadline...). This plugin 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).');
3649
+ }
3650
+ if (PREEMPTION_VOCABULARY.test(corpus)) {
3651
+ notes.push('The model references preemption/concurrency vocabulary (ISR/IRQ/task/interrupt...). This plugin 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+).');
3652
+ }
3653
+ if (HYBRID_VOCABULARY.test(corpus)) {
3654
+ notes.push('The model references control/hybrid vocabulary (pid/plant/feedback/stability/motor...). This plugin verifies discrete transitions only; stability, settling time, and mode-switch dynamics over a continuous plant need hybrid verification (SpaceEx, Flow*, or Simulink/Stateflow analysis).');
3655
+ }
3656
+ if (PROBABILISTIC_VOCABULARY.test(corpus)) {
3657
+ notes.push('The model references probabilistic/reliability vocabulary (mtbf/failure rate/probability...). This plugin is a qualitative model checker; reliability or probability claims need a stochastic model checker (PRISM, Storm) or fault-tree analysis.');
3658
+ }
3659
+ return notes;
3660
+ }
3661
+ // ---------------------------------------------------------------------------
3662
+ // main entry
3663
+ // ---------------------------------------------------------------------------
3664
+ export function runVerification(input, options = {}) {
3665
+ // A bound that was replaced or clamped is recorded here and reported through the summary, so a
3666
+ // caller who asked for 10^12 learns the answer came from a smaller search instead of reading a
3667
+ // truncated report as a complete one.
3668
+ const boundNotes = [];
3669
+ const normalized = {
3670
+ maxStates: boundedOption(options.maxStates, DEFAULT_MAX_STATES, MAX_STATES_CEILING, 'maxStates', boundNotes),
3671
+ maxPermutationEvents: boundedOption(options.maxPermutationEvents, DEFAULT_MAX_PERMUTATION_EVENTS, MAX_PERMUTATION_EVENTS_CEILING, 'maxPermutationEvents', boundNotes),
3672
+ };
3673
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
3674
+ const metadataKeys = metadataKeysOf(input);
3675
+ const metadata = metadataKeys.length === 0 ? {} : { metadataKeys };
3676
+ const validation = validateModel(input);
3677
+ if (!validation.ok) {
3678
+ const findings = validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message }));
3679
+ const modelCheck = {
3680
+ id: 'MODEL',
3681
+ name: 'Model Validation',
3682
+ status: 'fail',
3683
+ detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
3684
+ findings,
3685
+ };
3686
+ return {
3687
+ ok: false,
3688
+ ran: true,
3689
+ ...verdictOfFindings(findings),
3690
+ schema: REPORT_SCHEMAS.verify,
3691
+ schemaVersion: 1,
3692
+ hashSpec,
3693
+ modelHash: '',
3694
+ hashes: { hashSpec, modelHash: '' },
3695
+ ...metadata,
3696
+ summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
3697
+ checks: [modelCheck],
3698
+ nextSteps: [...refusalGuidance(validation.errors), ...verificationNextSteps([modelCheck], undefined)],
3699
+ };
3700
+ }
3701
+ const model = validation.model;
3702
+ const exploration = explore(model, normalized);
3703
+ const checks = [
3704
+ S1_reachability(model, exploration),
3705
+ S2_deadlock(model),
3706
+ S3_liveness(model),
3707
+ S4_determinism(model),
3708
+ S5_eventCompleteness(model),
3709
+ S6_guardCompleteness(model),
3710
+ S7_invariants(model, normalized),
3711
+ S8_monotonicVariables(model),
3712
+ A1_unexpectedEvents(model),
3713
+ A2_raceInterleaving(model, exploration),
3714
+ A3_orderPermutation(model, normalized),
3715
+ A4_pairSymmetry(model),
3716
+ A5_boundaryBlast(model),
3717
+ A6_resourceInjection(model),
3718
+ A7_shortestViolations(model, normalized),
3719
+ A8_idempotentReplay(model, exploration),
3720
+ A9_leadsTo(model, exploration, normalized),
3721
+ A10_sequenceOrder(model, normalized),
3722
+ A11_atomicity(model, normalized),
3723
+ A12_budget(model, normalized),
3724
+ A13_probability(model, normalized),
3725
+ A14_deadline(model, normalized),
3726
+ ];
3727
+ let comparison;
3728
+ if (options.beforeModel !== undefined) {
3729
+ const beforeValidation = validateModel(options.beforeModel);
3730
+ if (!beforeValidation.ok) {
3731
+ const beforeFindings = beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message }));
3732
+ const allFindings = [...checks.flatMap((check) => check.findings), ...beforeFindings];
3733
+ return {
3734
+ ok: false,
3735
+ ran: true,
3736
+ ...verdictOfFindings(allFindings),
3737
+ schema: REPORT_SCHEMAS.verify,
3738
+ schemaVersion: 1,
3739
+ hashSpec,
3740
+ modelHash: modelHash(model, hashSpec),
3741
+ hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
3742
+ ...metadata,
3743
+ summary: {
3744
+ states: model.states.length,
3745
+ transitions: model.transitions.length,
3746
+ errors: beforeValidation.errors.length,
3747
+ warnings: 0,
3748
+ checksRun: checks.length + 1,
3749
+ truncated: exploration.truncated || hasUndecidedFinding(checks),
3750
+ },
3751
+ checks: [
3752
+ ...checks,
3753
+ {
3754
+ id: 'BEFORE_MODEL',
3755
+ name: 'Before Model Validation',
3756
+ status: 'fail',
3757
+ detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
3758
+ findings: beforeFindings,
3759
+ },
3760
+ ],
3761
+ nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
3762
+ };
3763
+ }
3764
+ const before = beforeValidation.model;
3765
+ const mapping = options.stateMapping ?? {};
3766
+ checks.push(D1_behavioralPreservation(before, model, mapping), D2_invariantContinuity(before, model, normalized, mapping), D3_regressionDelta(before, model, mapping), D4_deadlockLivenessRegression(before, model, mapping));
3767
+ comparison = buildComparisonSummary(before, model, mapping);
3768
+ }
3769
+ // A check whose vocabulary the model never declared examined nothing, and says so. It has to happen
3770
+ // before the summary and `nextSteps` are derived, or both keep counting it as a run.
3771
+ for (const check of checks) {
3772
+ if (check.status !== 'pass')
3773
+ continue;
3774
+ const why = nothingToExamine(check.id, model);
3775
+ if (why === undefined)
3776
+ continue;
3777
+ check.status = 'skip';
3778
+ check.detail = 'skipped: ' + why;
3779
+ }
3780
+ const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
3781
+ const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
3782
+ const coverageNotes = computeCoverageNotes(model);
3783
+ const narrativeCoverage = narrativeCoverageOf(model);
3784
+ // R3: a partial narrative is valid, but the gap must be machine-readable instead of only a
3785
+ // sentence in `nextSteps`. It rides on S5 — the check that already reports undocumented
3786
+ // (state, event) combinations — so the documented check count stays 22.
3787
+ if (narrativeCoverage !== undefined && !narrativeComplete(narrativeCoverage)) {
3788
+ checks.find((check) => check.id === 'S5')?.findings.push({
3789
+ code: 'NARRATIVE_PARTIAL',
3790
+ severity: 'info',
3791
+ message: 'Narrative coverage is partial (states ' + narrativeCoverage.states + ', events ' + narrativeCoverage.events + ', scenarios ' + narrativeCoverage.scenarios + '): the checks still run over the whole model, but the undocumented symbols have to be re-derived by the reader.',
3792
+ });
3793
+ }
3794
+ return {
3795
+ ok: true,
3796
+ ran: true,
3797
+ ...verdictOfFindings(checks.flatMap((check) => check.findings)),
3798
+ schema: REPORT_SCHEMAS.verify,
3799
+ schemaVersion: 1,
3800
+ hashSpec,
3801
+ modelHash: modelHash(model, hashSpec),
3802
+ hashes: {
3803
+ hashSpec,
3804
+ modelHash: modelHash(model, hashSpec),
3805
+ ...(comparison === undefined ? {} : { beforeModelHash: comparison.beforeModelHash, afterModelHash: comparison.afterModelHash }),
3806
+ },
3807
+ ...metadata,
3808
+ ...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
3809
+ summary: {
3810
+ states: model.states.length,
3811
+ transitions: model.transitions.length,
3812
+ errors,
3813
+ warnings,
3814
+ // `checksRun` counts the checks that examined the model, which is what the name promises. It used to
3815
+ // count every check object, so a model that declared nothing still read as "22 checks ran".
3816
+ checksRun: checks.filter((check) => check.status !== 'skip').length,
3817
+ ...(() => {
3818
+ const skipped = checks.filter((check) => check.status === 'skip').map((check) => check.id);
3819
+ return skipped.length === 0 ? {} : { checksSkipped: skipped };
3820
+ })(),
3821
+ truncated: exploration.truncated || hasUndecidedFinding(checks),
3822
+ ...(boundNotes.length === 0 ? {} : { boundNotes }),
3823
+ },
3824
+ checks,
3825
+ nextSteps: verificationNextSteps(checks, narrativeCoverage),
3826
+ ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
3827
+ ...(comparison === undefined ? {} : { comparison }),
3828
+ ...(coverageNotes.length === 0 ? {} : { coverageNotes }),
3829
+ };
3830
+ }
3831
+ /**
3832
+ * Check the sharing declarations before the search runs.
3833
+ *
3834
+ * A declaration that names a machine, an event or a location that does not exist would
3835
+ * otherwise quietly exclude that access from every later check - the access simply would not
3836
+ * be found, and the report would say "no race" about a pair it never looked at.
3837
+ */
3838
+ function analyzeSharing(models, machineEventSets, options) {
3839
+ const findings = [];
3840
+ const byMachine = new Map();
3841
+ const locations = new Set();
3842
+ for (const location of options.shared ?? []) {
3843
+ if (locations.has(location.id)) {
3844
+ findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: 'shared location "' + location.id + '" is declared twice; one declaration per location' });
3845
+ continue;
3846
+ }
3847
+ locations.add(location.id);
3848
+ }
3849
+ const accesses = options.accesses ?? [];
3850
+ if (accesses.length > 0 && locations.size === 0) {
3851
+ findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: 'accesses were declared but `shared` names no location, so no access can be checked' });
3852
+ }
3853
+ accesses.forEach((access, index) => {
3854
+ const where = 'accesses[' + String(index) + ']';
3855
+ if (!Number.isInteger(access.machine) || access.machine < 0 || access.machine >= models.length) {
3856
+ findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' names machine ' + String(access.machine) + ', and this composition has ' + String(models.length) });
3857
+ return;
3858
+ }
3859
+ if (machineEventSets[access.machine]?.has(access.event) !== true) {
3860
+ findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' names event "' + access.event + '", which machine ' + String(access.machine) + ' does not declare in any transition' });
3861
+ return;
3862
+ }
3863
+ if (!locations.has(access.location)) {
3864
+ findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' names location "' + access.location + '", which `shared` does not declare' });
3865
+ return;
3866
+ }
3867
+ if (access.kind !== 'read' && access.kind !== 'write' && access.kind !== 'read-write') {
3868
+ findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' has kind "' + String(access.kind) + '"; expected read, write or read-write' });
3869
+ return;
3870
+ }
3871
+ if (access.protectedBy !== undefined && !locations.has(access.protectedBy)) {
3872
+ findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' is protected by "' + access.protectedBy + '", which `shared` does not declare' });
3873
+ return;
3874
+ }
3875
+ const perMachine = byMachine.get(access.machine) ?? new Map();
3876
+ const list = perMachine.get(access.event) ?? [];
3877
+ list.push(access);
3878
+ perMachine.set(access.event, list);
3879
+ byMachine.set(access.machine, perMachine);
3880
+ });
3881
+ return { findings, byMachine, locations, accessCount: accesses.length };
3882
+ }
3883
+ /**
3884
+ * N-machine composition semantics (documented in dsh-model-schema.md):
3885
+ * - a non-rendezvous event advances exactly ONE non-terminal machine that fires it;
3886
+ * - a rendezvous event (handshake) fires only when AT LEAST TWO machines declare it in
3887
+ * their alphabet and EVERY such non-terminal machine has it enabled (guards held);
3888
+ * all participants then advance simultaneously. A machine that is terminal, or whose
3889
+ * alphabet does not include the event, does not participate;
3890
+ * - a terminal machine is stopped and takes no further part.
3891
+ * Checks: C1 composition deadlock (reachable node where no machine can advance while at
3892
+ * least one is not terminal) and C2 rendezvous that can never fire.
3893
+ */
3894
+ export function runCompositionVerification(machinesInput, options = {}) {
3895
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
3896
+ const models = [];
3897
+ const hashes = [];
3898
+ const modelFindings = [];
3899
+ machinesInput.forEach((input, index) => {
3900
+ const validation = validateModel(input);
3901
+ if (!validation.ok) {
3902
+ modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'machine ' + index + ' invalid: ' + validation.errors.join('; ') });
3903
+ }
3904
+ else {
3905
+ models.push(validation.model);
3906
+ hashes.push(modelHash(validation.model, hashSpec));
3907
+ }
3908
+ });
3909
+ const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }));
3910
+ if (modelFindings.length > 0 || models.length < 2) {
3911
+ if (models.length < 2 && modelFindings.length === 0) {
3912
+ modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'composition requires at least two machines' });
3913
+ }
3914
+ return {
3915
+ ok: false,
3916
+ ran: true,
3917
+ ...verdictOfFindings(modelFindings),
3918
+ schema: REPORT_SCHEMAS.compose,
3919
+ hashSpec,
3920
+ hashes: { hashSpec, machines: hashes },
3921
+ summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
3922
+ checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
3923
+ nextSteps: ['Fix the machine that failed validation, then re-run the composition.'],
3924
+ };
3925
+ }
3926
+ const rendezvous = new Set(options.rendezvous ?? []);
3927
+ const maxStates = options.maxStates ?? DEFAULT_MAX_STATES;
3928
+ const machineEventSets = models.map((model) => new Set(model.transitions.map((transition) => transition.event)));
3929
+ // Sharing: which machine events touch which declared location, and how. The declarations are
3930
+ // checked first (C3), because an access that names a machine or location that does not exist
3931
+ // simply never enters the scan below - and the report would then say "no race" about a pair it
3932
+ // never looked at.
3933
+ const sharing = analyzeSharing(models, machineEventSets, options);
3934
+ const c3Findings = [...sharing.findings];
3935
+ const c4Findings = [];
3936
+ const c5Findings = [];
3937
+ const accessEventsByMachine = new Map();
3938
+ for (const [machine, perEvent] of sharing.byMachine)
3939
+ accessEventsByMachine.set(machine, [...perEvent.keys()]);
3940
+ const seenSharedPairs = new Set();
3941
+ let excludedPairs = 0;
3942
+ /** A cap, so a large model cannot turn this into a quadratic report: 64 witnesses is plenty. */
3943
+ const MAX_SHARED_WITNESSES = 64;
3944
+ /**
3945
+ * Look for two accesses on the same location that are both enabled in this composite state.
3946
+ *
3947
+ * Co-enabled means the model does not exclude the interleaving: with a read-modify-write on
3948
+ * each side, read(A), read(B), write(A), write(B) is reachable and loses one update. Two
3949
+ * accesses that name the SAME lock are treated as exclusive and counted, not reported; that
3950
+ * the lock is actually held across the whole operation is a single-machine question and is
3951
+ * not decided here.
3952
+ */
3953
+ const scanSharedAccesses = (node) => {
3954
+ if (c4Findings.length + c5Findings.length >= MAX_SHARED_WITNESSES)
3955
+ return;
3956
+ const enabled = [];
3957
+ for (const [machine, events] of accessEventsByMachine) {
3958
+ if (machine >= models.length)
3959
+ continue;
3960
+ if (isTerminal(models[machine], node.runtimes[machine].state))
3961
+ continue;
3962
+ for (const event of events) {
3963
+ if (stepRuntime(models[machine], node.runtimes[machine], event).length === 0)
3964
+ continue;
3965
+ for (const access of sharing.byMachine.get(machine)?.get(event) ?? [])
3966
+ enabled.push({ machine, event, access });
3967
+ }
3968
+ }
3969
+ for (let left = 0; left < enabled.length; left += 1) {
3970
+ for (let right = left + 1; right < enabled.length; right += 1) {
3971
+ const first = enabled[left];
3972
+ const second = enabled[right];
3973
+ if (first.machine === second.machine)
3974
+ continue;
3975
+ if (first.access.location !== second.access.location)
3976
+ continue;
3977
+ const bothRead = first.access.kind === 'read' && second.access.kind === 'read';
3978
+ if (bothRead)
3979
+ continue;
3980
+ if (first.access.protectedBy !== undefined && first.access.protectedBy === second.access.protectedBy) {
3981
+ excludedPairs += 1;
3982
+ continue;
3983
+ }
3984
+ const pairKey = [first.access.location, String(first.machine) + ':' + first.event, String(second.machine) + ':' + second.event].join('|');
3985
+ if (seenSharedPairs.has(pairKey))
3986
+ continue;
3987
+ seenSharedPairs.add(pairKey);
3988
+ if (c4Findings.length + c5Findings.length >= MAX_SHARED_WITNESSES)
3989
+ return;
3990
+ const nonAtomic = first.access.kind === 'read-write' && second.access.kind === 'read-write';
3991
+ const where = 'machine ' + String(first.machine) + ' "' + first.event + '" (' + first.access.kind + ') and machine ' + String(second.machine) + ' "' + second.event + '" (' + second.access.kind + ')';
3992
+ const guarding = first.access.protectedBy === undefined && second.access.protectedBy === undefined
3993
+ ? 'Neither access names a protecting lock.'
3994
+ : 'The two accesses name different protecting locks (' + (first.access.protectedBy ?? 'none') + ' / ' + (second.access.protectedBy ?? 'none') + '), so neither excludes the other.';
3995
+ const at = node.runtimes.map((runtime) => runtime.state).join(', ');
3996
+ const finding = {
3997
+ code: nonAtomic ? 'C5_NON_ATOMIC_UPDATE' : 'C4_SHARED_WRITE_RACE',
3998
+ severity: nonAtomic ? 'error' : 'warning',
3999
+ message: nonAtomic
4000
+ ? 'Non-atomic update of "' + first.access.location + '": ' + where + ' both declare read-modify-write and are both enabled at (' + at + ').'
4001
+ : 'Shared write to "' + first.access.location + '" without exclusion: ' + where + ' are both enabled at (' + at + ') and at least one writes.',
4002
+ detail: 'Shortest interleaving to the co-enabled state: ' + String(node.path.length) + ' step(s) from the initial state. ' + guarding + (nonAtomic
4003
+ ? ' Read(A), read(B), write(A), write(B) is an interleaving this model does not exclude, and it loses one update. What makes it safe is outside the model: a single atomic step (compare-and-set, atomic increment), one owner, or a lock both sides name and hold across the whole operation.'
4004
+ : ' Which write lands last is decided at runtime. If last-writer-wins is the design, say so; otherwise order the accesses with a lock both sides name, or with a rendezvous.'),
4005
+ evidence: {
4006
+ location: first.access.location,
4007
+ machines: [first.machine, second.machine],
4008
+ events: [first.event, second.event],
4009
+ kinds: [first.access.kind, second.access.kind],
4010
+ states: node.runtimes.map((runtime) => runtime.state),
4011
+ depth: node.path.length,
4012
+ path: node.path,
4013
+ },
4014
+ };
4015
+ if (nonAtomic)
4016
+ c5Findings.push(finding);
4017
+ else
4018
+ c4Findings.push(finding);
4019
+ }
4020
+ }
4021
+ };
4022
+ const compositionMoves = (node) => {
4023
+ const moves = [];
4024
+ // single-machine (non-rendezvous) steps
4025
+ for (let i = 0; i < models.length; i++) {
4026
+ if (isTerminal(models[i], node.runtimes[i].state))
4027
+ continue;
4028
+ for (const event of allEvents(models[i])) {
4029
+ if (rendezvous.has(event))
4030
+ continue;
4031
+ for (const next of stepRuntime(models[i], node.runtimes[i], event)) {
4032
+ const runtimes = node.runtimes.slice();
4033
+ runtimes[i] = next;
4034
+ moves.push({ next: { runtimes, path: [] }, event, machines: [i] });
4035
+ }
4036
+ }
4037
+ }
4038
+ // synchronized rendezvous steps (>= 2 participants, all enabled)
4039
+ for (const event of rendezvous) {
4040
+ const participants = [];
4041
+ for (let i = 0; i < models.length; i++) {
4042
+ if (isTerminal(models[i], node.runtimes[i].state))
4043
+ continue;
4044
+ if (!machineEventSets[i].has(event))
4045
+ continue;
4046
+ participants.push(i);
4047
+ }
4048
+ if (participants.length < 2)
4049
+ continue; // handshake needs a partner
4050
+ const outcomes = participants.map((i) => stepRuntime(models[i], node.runtimes[i], event));
4051
+ if (outcomes.some((list) => list.length === 0))
4052
+ continue; // not all participants ready
4053
+ let combos = [node.runtimes.slice()];
4054
+ participants.forEach((i, slot) => {
4055
+ const nextCombos = [];
4056
+ for (const combo of combos) {
4057
+ for (const outcome of outcomes[slot]) {
4058
+ const copy = combo.slice();
4059
+ copy[i] = outcome;
4060
+ nextCombos.push(copy);
4061
+ }
4062
+ }
4063
+ combos = nextCombos;
4064
+ });
4065
+ for (const runtimes of combos) {
4066
+ moves.push({ next: { runtimes, path: [] }, event, machines: participants });
4067
+ }
4068
+ }
4069
+ return moves;
4070
+ };
4071
+ const keyOf = (node) => node.runtimes.map((runtime) => runtimeKey(runtime)).join('|');
4072
+ const init = { runtimes: models.map((model) => initialState(model)), path: [] };
4073
+ const visited = new Set();
4074
+ const queue = [init];
4075
+ let compositeStates = 0;
4076
+ let truncated = false;
4077
+ const firedCount = new Map();
4078
+ for (const event of rendezvous)
4079
+ firedCount.set(event, 0);
4080
+ // Per-machine enablement, collected while the state space is explored: a rendezvous
4081
+ // that never fires needs to say *which* machine never had it enabled, not just that
4082
+ // the handshake did not happen.
4083
+ const everEnabled = new Map();
4084
+ const enabledStates = new Map();
4085
+ const coEnabledNodes = new Map();
4086
+ for (const event of rendezvous) {
4087
+ everEnabled.set(event, new Set());
4088
+ enabledStates.set(event, models.map(() => []));
4089
+ coEnabledNodes.set(event, 0);
4090
+ }
4091
+ const c1Findings = [];
4092
+ while (queue.length > 0) {
4093
+ const node = queue.shift();
4094
+ const key = keyOf(node);
4095
+ if (visited.has(key))
4096
+ continue;
4097
+ visited.add(key);
4098
+ compositeStates += 1;
4099
+ if (compositeStates > maxStates) {
4100
+ truncated = true;
4101
+ break;
4102
+ }
4103
+ if (accessEventsByMachine.size > 0)
4104
+ scanSharedAccesses(node);
4105
+ const moves = compositionMoves(node);
4106
+ const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state));
4107
+ // Enablement census for C2: which machines could fire each rendezvous here.
4108
+ for (const event of rendezvous) {
4109
+ const declaring = [];
4110
+ for (let i = 0; i < models.length; i++)
4111
+ if (machineEventSets[i].has(event))
4112
+ declaring.push(i);
4113
+ if (declaring.length === 0)
4114
+ continue;
4115
+ let allEnabled = declaring.length >= 2;
4116
+ for (const i of declaring) {
4117
+ const enabled = !isTerminal(models[i], node.runtimes[i].state) && stepRuntime(models[i], node.runtimes[i], event).length > 0;
4118
+ if (!enabled) {
4119
+ allEnabled = false;
4120
+ continue;
4121
+ }
4122
+ everEnabled.get(event)?.add(i);
4123
+ const states = enabledStates.get(event)?.[i];
4124
+ if (states !== undefined && !states.includes(node.runtimes[i].state) && states.length < 8)
4125
+ states.push(node.runtimes[i].state);
4126
+ }
4127
+ if (allEnabled)
4128
+ coEnabledNodes.set(event, (coEnabledNodes.get(event) ?? 0) + 1);
4129
+ }
4130
+ if (moves.length === 0 && !allTerminal) {
4131
+ // Why nobody can advance, per machine: its state, its alphabet, and for every event
4132
+ // either the rendezvous partner that is not ready or the transition that does not fire.
4133
+ const perMachine = node.runtimes.map((runtime, i) => {
4134
+ const terminal = isTerminal(models[i], runtime.state);
4135
+ const blocked = terminal ? [] : allEvents(models[i]).map((event) => {
4136
+ const enabled = stepRuntime(models[i], runtime, event).length > 0;
4137
+ // A rendezvous event is not fireable just because this machine has a transition:
4138
+ // it needs every declaring machine enabled at the same time. Check that first, or
4139
+ // the deadlock census would report "enabled" for a handshake nobody can complete.
4140
+ if (!rendezvous.has(event))
4141
+ return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' };
4142
+ const declaring = [];
4143
+ for (let j = 0; j < models.length; j++)
4144
+ if (machineEventSets[j].has(event))
4145
+ declaring.push(j);
4146
+ if (declaring.length < 2)
4147
+ return { event, reason: 'rendezvous-needs-partner' };
4148
+ const partnersReady = declaring.every((j) => !isTerminal(models[j], node.runtimes[j].state)
4149
+ && stepRuntime(models[j], node.runtimes[j], event).length > 0);
4150
+ if (!partnersReady)
4151
+ return { event, reason: 'rendezvous-partner-not-ready' };
4152
+ return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' };
4153
+ });
4154
+ return { index: i, state: runtime.state, terminal, blocked };
4155
+ });
4156
+ const reasons = perMachine
4157
+ .filter((machine) => !machine.terminal)
4158
+ .map((machine) => 'machine ' + String(machine.index) + ' at ' + machine.state + ': ' + (machine.blocked.length === 0
4159
+ ? 'empty event alphabet'
4160
+ : machine.blocked.map((entry) => entry.event + ' (' + entry.reason + ')').join(', ')));
4161
+ // A non-terminal state with no outgoing transition anywhere in its own model is a
4162
+ // modelling gap, not a design defect: without this note the census reads as
4163
+ // "the architecture deadlocks" when the model was simply left unfinished.
4164
+ const gaps = node.runtimes
4165
+ .map((runtime, index) => ({ index, state: runtime.state }))
4166
+ .filter((entry) => !isTerminal(models[entry.index], entry.state)
4167
+ && !models[entry.index].transitions.some((transition) => transition.from === entry.state));
4168
+ const gapNote = gaps.length === 0
4169
+ ? ''
4170
+ : ' Modelling note: ' + gaps.map((gap) => 'machine ' + String(gap.index) + ' state ' + gap.state).join(', ') + ' has no outgoing transition in its own model, so this deadlock may be a modelling gap rather than a design defect.';
4171
+ c1Findings.push({
4172
+ code: 'C1_COMPOSITION_DEADLOCK',
4173
+ severity: 'error',
4174
+ message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
4175
+ detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | ') + gapNote,
4176
+ evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state), depth: node.path.length, perMachine, reasons },
4177
+ });
4178
+ continue;
4179
+ }
4180
+ for (const move of moves) {
4181
+ if (move.machines.length > 1)
4182
+ firedCount.set(move.event, (firedCount.get(move.event) ?? 0) + 1);
4183
+ const nextKey = keyOf(move.next);
4184
+ if (visited.has(nextKey))
4185
+ continue;
4186
+ queue.push({ runtimes: move.next.runtimes, path: [...node.path, { event: move.event, machines: move.machines }] });
4187
+ }
4188
+ }
4189
+ const c2Findings = [];
4190
+ const allEventsSet = new Set();
4191
+ for (const set of machineEventSets)
4192
+ for (const event of set)
4193
+ allEventsSet.add(event);
4194
+ // A name the machines never declare resolves to nothing, and the loop below used to `continue` past it: a
4195
+ // typo in `rendezvous` skipped the handshake entirely while the detail still reported that every rendezvous
4196
+ // can fire - a pass over a check that never ran. The verify family answers the same mistake with
4197
+ // MODEL_UNRESOLVED_EVENT; this is that answer for the composition.
4198
+ for (const event of rendezvous) {
4199
+ if (allEventsSet.has(event))
4200
+ continue;
4201
+ c2Findings.push({
4202
+ code: 'C2_RENDEZVOUS_UNRESOLVED',
4203
+ // An error: the caller named a handshake and the composition could not check it. As a warning the report said
4204
+ // `pass_with_findings` for a composition whose named handshake was never verified - the false green the rest of
4205
+ // this report contract exists to prevent.
4206
+ severity: 'error',
4207
+ message: 'Rendezvous event "' + event + '" is declared nowhere: no machine has a transition or action on it, so this handshake was not checked. Name an event the machines declare, or remove it from `rendezvous`.',
4208
+ evidence: { event, declared: [...allEventsSet].sort() },
4209
+ });
4210
+ }
4211
+ for (const event of rendezvous) {
4212
+ if (!allEventsSet.has(event))
4213
+ continue;
4214
+ if ((firedCount.get(event) ?? 0) === 0) {
4215
+ // Why the handshake never happens: who declares it, who ever had it enabled, and
4216
+ // whether the enabling states could ever coincide.
4217
+ const machines = models.map((model, index) => ({
4218
+ index,
4219
+ declares: machineEventSets[index].has(event),
4220
+ everEnabled: everEnabled.get(event)?.has(index) === true,
4221
+ enabledAt: enabledStates.get(event)?.[index] ?? [],
4222
+ }));
4223
+ const declaring = machines.filter((machine) => machine.declares);
4224
+ const neverEnabled = declaring.filter((machine) => !machine.everEnabled);
4225
+ const coEnabled = coEnabledNodes.get(event) ?? 0;
4226
+ let reason;
4227
+ if (declaring.length < 2) {
4228
+ reason = 'only ' + String(declaring.length) + ' machine(s) declare it (machine ' + declaring.map((machine) => machine.index).join(', ') + '); a handshake needs at least two';
4229
+ }
4230
+ else if (neverEnabled.length > 0) {
4231
+ reason = 'machine ' + neverEnabled.map((machine) => machine.index).join(', ') + ' declares it but never has it enabled (no transition, or the guard never holds)';
4232
+ }
4233
+ else {
4234
+ reason = 'every declaring machine enables it somewhere, but never at the same time (' + String(coEnabled) + ' composite state(s) had all of them ready)';
4235
+ }
4236
+ // A truncated exploration cannot prove that a handshake never happens: it may simply
4237
+ // not have reached the composite state where both machines are ready.
4238
+ const caveat = truncated ? ' The search was truncated at ' + String(maxStates) + ' composite states, so this may be an artefact of the cap rather than a property of the model.' : '';
4239
+ c2Findings.push({
4240
+ code: 'C2_RENDEZVOUS_NEVER_FIRES',
4241
+ severity: 'error',
4242
+ message: 'Rendezvous event ' + event + ' can never fire: ' + reason + '.' + caveat,
4243
+ detail: machines.map((machine) => 'machine ' + String(machine.index) + (machine.declares ? ' declares' : ' does not declare')
4244
+ + (machine.everEnabled ? ', enabled at ' + (machine.enabledAt.length === 0 ? 'no visited state' : machine.enabledAt.join('/')) : ', never enabled')).join(' | '),
4245
+ evidence: { event, machines, declaring: declaring.map((machine) => machine.index), coEnabledNodes: coEnabled, reason, truncated },
4246
+ });
4247
+ }
4248
+ }
4249
+ // C2 is an error, not a warning: a rendezvous that can never fire means the composition
4250
+ // cannot advance through that handshake — the same "stuck forever" outcome C1 reports,
4251
+ // so a gate that only reads `verdict` must be able to stop on it.
4252
+ const allCompositionFindings = [...c1Findings, ...c2Findings, ...c3Findings, ...c4Findings, ...c5Findings];
4253
+ const errors = allCompositionFindings.filter((finding) => finding.severity === 'error').length;
4254
+ const warnings = allCompositionFindings.filter((finding) => finding.severity === 'warning').length;
4255
+ const c1Detail = c1Findings.length > 0
4256
+ ? 'Composition deadlocks: ' + String(c1Findings.length)
4257
+ : truncated
4258
+ ? 'No composition deadlock found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
4259
+ : 'No composition deadlock reachable';
4260
+ // The detail has to describe what was established. A name that resolved to nothing was never examined, so
4261
+ // the count of findings is not the same statement as "every rendezvous can fire".
4262
+ const unresolvedCount = c2Findings.filter((finding) => finding.code === 'C2_RENDEZVOUS_UNRESOLVED').length;
4263
+ const c2Detail = c2Findings.length > 0
4264
+ ? (unresolvedCount === c2Findings.length && c2Findings.length > 0
4265
+ ? String(unresolvedCount) + ' rendezvous name(s) resolved to no event, so that handshake was not checked'
4266
+ : 'Rendezvous errors: ' + String(c2Findings.length) + (unresolvedCount > 0 ? ' (' + String(unresolvedCount) + ' unresolved name(s))' : ''))
4267
+ : truncated
4268
+ ? 'All rendezvous events fired, but the search was truncated at ' + String(maxStates) + ' composite states'
4269
+ : 'All rendezvous events can fire';
4270
+ // The sharing checks say what they established, not only how many findings they produced: a
4271
+ // composition that declares nothing shares nothing, and that is a different statement from
4272
+ // "the shared accesses were checked and they are exclusive".
4273
+ const c3Detail = c3Findings.length > 0
4274
+ ? 'Sharing declarations are inconsistent: ' + String(c3Findings.length) + ' declaration error(s); those accesses were not checked'
4275
+ : sharing.accessCount === 0
4276
+ ? 'No shared location is declared, so nothing is shared between the machines'
4277
+ : 'Sharing declarations resolve: ' + String(sharing.locations.size) + ' location(s), ' + String(sharing.accessCount) + ' access(es)';
4278
+ const sharedExclusion = excludedPairs === 0 ? '' : ' ' + String(excludedPairs) + ' co-enabled access pair(s) were excluded by a lock both sides name.';
4279
+ const c4Detail = c4Findings.length > 0
4280
+ ? 'Shared writes that the model does not order: ' + String(c4Findings.length) + '.' + sharedExclusion
4281
+ : sharing.accessCount === 0
4282
+ ? 'Not checked: no shared access was declared'
4283
+ : truncated
4284
+ ? 'No unordered shared write found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
4285
+ : 'Every co-enabled shared write names one lock on both sides, or never overlaps.' + sharedExclusion;
4286
+ const c5Detail = c5Findings.length > 0
4287
+ ? 'Read-modify-write pairs that are not atomic together: ' + String(c5Findings.length) + '.' + sharedExclusion
4288
+ : sharing.accessCount === 0
4289
+ ? 'Not checked: no shared access was declared'
4290
+ : truncated
4291
+ ? 'No non-atomic update found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
4292
+ : 'No read-modify-write pair is left co-enabled without a shared lock.' + sharedExclusion;
4293
+ const checks = [
4294
+ checkResult('C1', 'Composition Deadlock', c1Findings, c1Detail),
4295
+ checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
4296
+ checkResult('C3', 'Shared Access Declarations', c3Findings, c3Detail),
4297
+ checkResult('C4', 'Shared Write Order', c4Findings, c4Detail),
4298
+ checkResult('C5', 'Atomic Shared Update', c5Findings, c5Detail),
4299
+ ];
4300
+ const nextSteps = [];
4301
+ if (errors > 0) {
4302
+ const why = c1Findings.length > 0
4303
+ ? 'the composition deadlocks at a reachable state, so at least one machine is missing an exit or a handshake partner'
4304
+ : c5Findings.length > 0
4305
+ ? 'two machines update one shared location without one atomic step between them, so an update can be lost'
4306
+ : c3Findings.length > 0
4307
+ ? 'a sharing declaration names something the composition does not have, so those accesses were never checked'
4308
+ : 'a rendezvous event can never fire, so the composition cannot advance through that handshake';
4309
+ nextSteps.push('Resolve the error findings first: ' + why + '.');
4310
+ }
4311
+ if (c5Findings.length > 0) {
4312
+ nextSteps.push('Make the update atomic: one machine owns the value, a compare-and-set or atomic increment replaces read-then-write, or both sides name one lock in `protectedBy` and hold it across the whole operation.');
4313
+ }
4314
+ if (c4Findings.length > 0) {
4315
+ nextSteps.push('Decide what the overlapping write means: last-writer-wins by design (say so in the narrative), a lock both sides name in `protectedBy`, or a rendezvous that orders the two events.');
4316
+ }
4317
+ if (excludedPairs > 0) {
4318
+ nextSteps.push('The lock names make ' + String(excludedPairs) + ' pair(s) exclusive, and that is all this checks: whether the lock is acquired and released around the whole operation is a single-machine question - model that machine and run `model_verify` on it.');
4319
+ }
4320
+ // Composition couples machines through rendezvous and, since the sharing declarations exist,
4321
+ // through a location both declare. Each machine's `variables` are still private to it, so two
4322
+ // machines cannot share a predicate. Name them when the input looks like it is trying to.
4323
+ const owners = models
4324
+ .map((entry, index) => ({ index, names: (entry.variables ?? []).map((variable) => variable.name) }))
4325
+ .filter((entry) => entry.names.length > 0);
4326
+ if (owners.length >= 2) {
4327
+ nextSteps.push('Each machine\'s variables are private to it (' + owners.map((entry) => 'machine ' + String(entry.index) + ': ' + entry.names.join(', ')).join('; ') + '): a value two machines must agree on is either declared in `shared` with an `accesses` entry per side, or modelled as a handshake.' + (sharing.accessCount === 0 ? ' This composition declares no shared location, so nothing is shared between its machines.' : ''));
4328
+ }
4329
+ // A composition answers a question no single machine can, so the next step names the tool that answers
4330
+ // the other one. Without it a reader who wanted per-machine detail has nowhere to go from here.
4331
+ nextSteps.push('Re-run the composition after the change; every machine must be able to advance, or explain why it is terminal. For one machine on its own - its unreachable states, its invariants, its cost against budget - use `model_verify`, then compare the two compositions with `report_diff`.');
4332
+ return {
4333
+ ok: errors === 0,
4334
+ ran: true,
4335
+ ...verdictOf(errors, warnings, [...c1Findings, ...c2Findings, ...c3Findings, ...c4Findings, ...c5Findings][0]?.code),
4336
+ schema: REPORT_SCHEMAS.compose,
4337
+ hashSpec,
4338
+ hashes: { hashSpec, machines: hashes },
4339
+ summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
4340
+ checks,
4341
+ nextSteps,
4342
+ };
4343
+ }