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