dsh-logicprobe 0.8.1 → 1.0.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 (65) hide show
  1. package/README.en-US.md +79 -14
  2. package/README.md +79 -14
  3. package/lib/baseline.js +142 -0
  4. package/lib/client.js +56 -1
  5. package/lib/compose-tool.js +1 -1
  6. package/lib/concurrency-tool.js +1 -1
  7. package/lib/concurrency.js +14 -0
  8. package/lib/data-engine.js +42 -8
  9. package/lib/engine.js +609 -118
  10. package/lib/export-tool.js +27 -2
  11. package/lib/granularity.js +237 -0
  12. package/lib/index.js +63 -32
  13. package/lib/report-diff-tool.js +39 -0
  14. package/lib/structure-tool.js +64 -0
  15. package/lib/structure.js +648 -0
  16. package/lib/tool.js +3 -3
  17. package/lib/types/baseline.d.ts +69 -0
  18. package/lib/types/concurrency.d.ts +10 -0
  19. package/lib/types/data-engine.d.ts +13 -1
  20. package/lib/types/engine.d.ts +139 -2
  21. package/lib/types/granularity.d.ts +106 -0
  22. package/lib/types/index.d.ts +9 -8
  23. package/lib/types/report-diff-tool.d.ts +8 -0
  24. package/lib/types/structure-tool.d.ts +10 -0
  25. package/lib/types/structure.d.ts +156 -0
  26. package/lib/types/tool.d.ts +2 -2
  27. package/lib/types/uml.d.ts +89 -0
  28. package/lib/uml-tool.js +36 -7
  29. package/lib/uml.js +217 -23
  30. package/locale/en.json +6 -0
  31. package/locale/zh.json +6 -0
  32. package/package.json +16 -14
  33. package/skills/logicprobe/SKILL.md +97 -58
  34. package/skills/logicprobe/references/dsh-model-schema.md +87 -15
  35. package/skills/logicprobe/references/gap-routing-guide.md +37 -36
  36. package/skills/logicprobe/references/hash-spec.md +128 -0
  37. package/skills/logicprobe/references/logic-verification-guide.md +29 -248
  38. package/skills/logicprobe-concurrency/SKILL.md +43 -0
  39. package/skills/{logicprobe → logicprobe-concurrency}/references/concurrency-risk-guide.md +1 -1
  40. package/skills/logicprobe-datamodel/SKILL.md +5 -4
  41. package/skills/logicprobe-datamodel/references/data-model-guide.md +1 -1
  42. package/skills/logicprobe-datamodel/references/data-model-schema.md +1 -1
  43. package/skills/logicprobe-structure/SKILL.md +95 -0
  44. package/skills/logicprobe-structure/references/structure-review-guide.md +177 -0
  45. package/skills/logicprobe-uml/SKILL.md +55 -0
  46. package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +120 -8
  47. package/src/baseline.ts +180 -0
  48. package/src/client.js +56 -1
  49. package/src/compose-tool.ts +47 -47
  50. package/src/concurrency-tool.ts +2 -2
  51. package/src/concurrency.ts +23 -0
  52. package/src/data-engine.ts +51 -9
  53. package/src/engine.ts +656 -101
  54. package/src/export-tool.ts +73 -48
  55. package/src/granularity.ts +312 -0
  56. package/src/index.ts +63 -32
  57. package/src/report-diff-tool.ts +43 -0
  58. package/src/structure-tool.ts +68 -0
  59. package/src/structure.ts +724 -0
  60. package/src/tool.ts +4 -4
  61. package/src/uml-tool.ts +38 -8
  62. package/src/uml.ts +275 -24
  63. package/skills/logicprobe/references/logicprobe-engine.py +0 -4780
  64. package/skills/logicprobe/references/verification-harness.py +0 -959
  65. package/skills/logicprobe-datamodel/references/data-model-harness.py +0 -528
package/lib/engine.js CHANGED
@@ -35,6 +35,24 @@ const MODEL_KEYS = {
35
35
  narrative: new Set(['states', 'events', 'scenarios']),
36
36
  scenario: new Set(['from', 'event', 'scenario']),
37
37
  };
38
+ /**
39
+ * The next-steps list every verification report carries. Derived only from the checks
40
+ * and the narrative coverage, so two runs over the same model produce the same list.
41
+ */
42
+ function verificationNextSteps(checks, coverage) {
43
+ const steps = [];
44
+ const failed = checks.filter((check) => check.status === 'fail').map((check) => check.id + ' ' + check.name);
45
+ const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort();
46
+ if (failed.length > 0)
47
+ steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.');
48
+ if (warningCodes.length > 0)
49
+ steps.push('Review the warning findings (' + warningCodes.join(', ') + '): they are not failures, but they are not silence either.');
50
+ if (coverage !== undefined && !narrativeComplete(coverage)) {
51
+ 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.');
52
+ }
53
+ steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress (D1-D4).');
54
+ return steps;
55
+ }
38
56
  const DEFAULT_MAX_STATES = 10_000;
39
57
  const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
40
58
  function stableStringify(value) {
@@ -45,8 +63,131 @@ function stableStringify(value) {
45
63
  const record = value;
46
64
  return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
47
65
  }
48
- export function modelHash(model) {
49
- return createHash('sha256').update(stableStringify(model)).digest('hex');
66
+ export function modelHash(model, spec = DEFAULT_HASH_SPEC) {
67
+ return createHash('sha256').update(stableStringify(hashPayload(model, spec))).digest('hex');
68
+ }
69
+ /**
70
+ * Deterministic JSON (sorted keys, no insignificant whitespace) — the serialization the
71
+ * hash specification is defined over. Exported so every report hashes inputs the same way.
72
+ */
73
+ export function canonicalJson(value) {
74
+ return stableStringify(value);
75
+ }
76
+ export const PUBLISHED_HASH_SPECS = ['v0', 'v1'];
77
+ export const DEFAULT_HASH_SPEC = 'v1';
78
+ /**
79
+ * Versioned report contracts. Every tool result carries `schema`, so a consumer can
80
+ * branch on the contract instead of sniffing fields, and a change to a report's shape
81
+ * is a version bump here rather than a silent break. `findings[]` keeps the same
82
+ * stable core in every family: `{code, severity, message, detail?, evidence?, path?}`.
83
+ */
84
+ export const REPORT_SCHEMAS = {
85
+ verify: 'logicprobe/verify/v1',
86
+ compose: 'logicprobe/compose/v1',
87
+ datamodel: 'logicprobe/datamodel/v1',
88
+ concurrency: 'logicprobe/concurrency/v1',
89
+ export: 'logicprobe/export/v1',
90
+ umlRender: 'logicprobe/uml/render/v1',
91
+ umlParse: 'logicprobe/uml/parse/v1',
92
+ umlReview: 'logicprobe/uml/review/v1',
93
+ structure: 'logicprobe/structure/v1',
94
+ granularity: 'logicprobe/granularity/v1',
95
+ baseline: 'logicprobe/baseline/v1',
96
+ };
97
+ export function narrativeCoverageOf(model) {
98
+ const narrative = model.narrative;
99
+ if (narrative === undefined)
100
+ return undefined;
101
+ const describedStates = narrative.states === undefined ? 0 : Object.keys(narrative.states).length;
102
+ const describedEvents = narrative.events === undefined ? 0 : Object.keys(narrative.events).length;
103
+ const groups = new Set(model.transitions.map((transition) => transition.from + '|' + transition.event));
104
+ const describedGroups = narrative.scenarios === undefined
105
+ ? 0
106
+ : new Set(narrative.scenarios.map((scenario) => scenario.from + '|' + scenario.event)).size;
107
+ const events = new Set(model.transitions.map((transition) => transition.event));
108
+ return {
109
+ states: String(describedStates) + '/' + String(model.states.length),
110
+ events: String(describedEvents) + '/' + String(events.size),
111
+ scenarios: String(describedGroups) + '/' + String(groups.size),
112
+ };
113
+ }
114
+ /** True when every dimension of the coverage is fully described. */
115
+ export function narrativeComplete(coverage) {
116
+ if (coverage === undefined)
117
+ return false;
118
+ return coverage.states.split('/')[0] === coverage.states.split('/')[1]
119
+ && coverage.events.split('/')[0] === coverage.events.split('/')[1]
120
+ && coverage.scenarios.split('/')[0] === coverage.scenarios.split('/')[1];
121
+ }
122
+ /** A tool that refused the request before running anything: it neither passed nor failed a review. */
123
+ export function refusalVerdict(reason) {
124
+ return { verdict: 'fail', verdictReason: 'the tool did not run: ' + reason };
125
+ }
126
+ /**
127
+ * Decide the verdict from finding counts. Any error-severity finding fails the review
128
+ * even though the tool ran successfully — that gap is exactly what `verdict` closes.
129
+ */
130
+ export function verdictOf(errors, warnings, firstCode) {
131
+ if (errors > 0) {
132
+ return {
133
+ verdict: 'fail',
134
+ verdictReason: String(errors) + ' error finding(s)' + (firstCode === undefined ? '' : ' (first: ' + firstCode + ')'),
135
+ };
136
+ }
137
+ if (warnings > 0) {
138
+ return { verdict: 'pass_with_findings', verdictReason: 'no error findings; ' + String(warnings) + ' warning finding(s)' };
139
+ }
140
+ return { verdict: 'pass', verdictReason: 'no error or warning findings' };
141
+ }
142
+ export function verdictOfFindings(findings) {
143
+ const errors = findings.filter((finding) => finding.severity === 'error');
144
+ const warnings = findings.filter((finding) => finding.severity === 'warning');
145
+ return verdictOf(errors.length, warnings.length, errors[0]?.code);
146
+ }
147
+ /**
148
+ * Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
149
+ * `states[0]._note`; a metadata key's own children are not listed, since the subtree is
150
+ * metadata as a whole). The schema ignores these keys as annotation metadata; echoing
151
+ * them in the report makes the ignored metadata auditable instead of invisible.
152
+ */
153
+ export function metadataKeysOf(input) {
154
+ const keys = [];
155
+ const walk = (value, path) => {
156
+ if (value === null || typeof value !== 'object')
157
+ return;
158
+ if (Array.isArray(value)) {
159
+ value.forEach((entry, index) => walk(entry, path + '[' + String(index) + ']'));
160
+ return;
161
+ }
162
+ for (const [key, entry] of Object.entries(value)) {
163
+ const child = path === '' ? key : path + '.' + key;
164
+ if (key.startsWith('_'))
165
+ keys.push(child);
166
+ walk(entry, child);
167
+ }
168
+ };
169
+ walk(input, '');
170
+ return keys.sort();
171
+ }
172
+ function withoutMetadata(value) {
173
+ if (value === null || typeof value !== 'object')
174
+ return value;
175
+ if (Array.isArray(value))
176
+ return value.map((entry) => withoutMetadata(entry));
177
+ const out = {};
178
+ for (const [key, entry] of Object.entries(value)) {
179
+ if (key.startsWith('_'))
180
+ continue;
181
+ out[key] = withoutMetadata(entry);
182
+ }
183
+ return out;
184
+ }
185
+ /**
186
+ * The exact payload a hash spec hashes. Exported so the specification has one
187
+ * implementation and the documentation can be checked against it.
188
+ */
189
+ export function hashPayload(model, spec = DEFAULT_HASH_SPEC) {
190
+ return spec === 'v1' ? withoutMetadata(model) : model;
50
191
  }
51
192
  export function validateModel(input) {
52
193
  const errors = [];
@@ -57,8 +198,12 @@ export function validateModel(input) {
57
198
  return { ok: false, errors: ['model: must be an object'] };
58
199
  }
59
200
  const root = input;
201
+ // `_`-prefixed keys are annotation metadata (provenance, verification snapshots,
202
+ // extraction caveats). They are ignored here and excluded from the hash, so a model
203
+ // can carry its own archive record without a sidecar that drifts away from it.
204
+ // Everything else stays closed: a mistyped `sttes` must still be an error.
60
205
  const rejectUnknownKeys = (value, allowed, path) => {
61
- const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
206
+ const unexpected = Object.keys(value).filter((key) => !allowed.has(key) && !key.startsWith('_'));
62
207
  if (unexpected.length > 0) {
63
208
  bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + [...allowed].join(', ') + ')');
64
209
  }
@@ -239,7 +384,7 @@ export function validateModel(input) {
239
384
  // older reader may not know are skipped so they still fail on `.kind` alone.
240
385
  if (KNOWN_INVARIANT_KINDS.has(invariant.kind)) {
241
386
  const allowed = INVARIANT_KEYS[invariant.kind];
242
- const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key));
387
+ const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key) && !key.startsWith('_'));
243
388
  if (unexpected.length > 0) {
244
389
  bad(path + '.' + unexpected[0], 'unknown field for kind ' + String(invariant.kind) + ' (allowed: ' + [...allowed].join(', ') + ')');
245
390
  }
@@ -271,7 +416,7 @@ export function validateModel(input) {
271
416
  else if ('state' in when) {
272
417
  const scope = when;
273
418
  for (const key of Object.keys(scope)) {
274
- if (key !== 'state')
419
+ if (key !== 'state' && !key.startsWith('_'))
275
420
  bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)');
276
421
  }
277
422
  const state = scope.state;
@@ -308,10 +453,36 @@ export function validateModel(input) {
308
453
  bad(path + '.from', 'must be a non-empty string');
309
454
  else if (!invariantStateIds.has(invariant.from))
310
455
  bad(path + '.from', 'references unknown state ' + invariant.from);
311
- if (typeof invariant.to !== 'string' || invariant.to.length === 0)
312
- bad(path + '.to', 'must be a non-empty string');
313
- else if (!invariantStateIds.has(invariant.to))
314
- bad(path + '.to', 'references unknown state ' + invariant.to);
456
+ // `to` is one state id or a target set. A set with a duplicate member is a
457
+ // model-authoring slip that would otherwise give one meaning two hashes, so it
458
+ // is rejected rather than silently deduplicated.
459
+ if (typeof invariant.to === 'string') {
460
+ if (invariant.to.length === 0)
461
+ bad(path + '.to', 'must be a non-empty string');
462
+ else if (!invariantStateIds.has(invariant.to))
463
+ bad(path + '.to', 'references unknown state ' + invariant.to);
464
+ }
465
+ else if (Array.isArray(invariant.to)) {
466
+ if (invariant.to.length === 0)
467
+ bad(path + '.to', 'must be a non-empty array of state ids');
468
+ else {
469
+ const targets = new Set();
470
+ invariant.to.forEach((target, targetIndex) => {
471
+ const at = path + '.to[' + targetIndex + ']';
472
+ if (typeof target !== 'string' || target.length === 0)
473
+ bad(at, 'must be a non-empty string');
474
+ else if (!invariantStateIds.has(target))
475
+ bad(at, 'references unknown state ' + target);
476
+ else if (targets.has(target))
477
+ bad(at, 'duplicates state ' + target + ' in the target set');
478
+ else
479
+ targets.add(target);
480
+ });
481
+ }
482
+ }
483
+ else {
484
+ bad(path + '.to', 'must be a state id or a non-empty array of state ids');
485
+ }
315
486
  }
316
487
  else if (invariant.kind === 'sequence') {
317
488
  if (!Array.isArray(invariant.events) || invariant.events.length === 0)
@@ -432,70 +603,70 @@ export function validateModel(input) {
432
603
  rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath);
433
604
  const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
434
605
  const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
435
- const fromEventGroups = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.from + '|' + transition.event) : []);
436
- if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
437
- bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description');
606
+ // A narrative is allowed to cover part of the model: states first, events and
607
+ // scenarios later is the natural authoring order, and `narrativeCoverage` reports
608
+ // what is still missing instead of rejecting the file. What stays an error is a
609
+ // narrative that is *wrong* (unknown id, empty description, duplicate scenario) or
610
+ // empty — an empty block claims documentation that does not exist.
611
+ const declaredDimensions = ['states', 'events', 'scenarios'].filter((key) => narrative[key] !== undefined);
612
+ if (declaredDimensions.length === 0) {
613
+ bad(narrativePath, 'declares no dimension: give at least one of states, events or scenarios (a partial narrative is fine)');
438
614
  }
439
- else {
440
- for (const [id, description] of Object.entries(narrative.states)) {
441
- if (!stateIds.has(id))
442
- bad(narrativePath + '.states', 'references unknown state ' + id);
443
- if (typeof description !== 'string' || description.length === 0)
444
- bad(narrativePath + '.states.' + id, 'must be a non-empty string');
615
+ if (narrative.states !== undefined) {
616
+ if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
617
+ bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description');
445
618
  }
446
- for (const id of stateIds) {
447
- if (typeof narrative.states[id] !== 'string')
448
- bad(narrativePath + '.states', 'missing description for state ' + id);
619
+ else {
620
+ for (const [id, description] of Object.entries(narrative.states)) {
621
+ if (!stateIds.has(id))
622
+ bad(narrativePath + '.states', 'references unknown state ' + id);
623
+ if (typeof description !== 'string' || description.length === 0)
624
+ bad(narrativePath + '.states.' + id, 'must be a non-empty string');
625
+ }
449
626
  }
450
627
  }
451
- if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
452
- bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description');
453
- }
454
- else {
455
- for (const [id, description] of Object.entries(narrative.events)) {
456
- if (!eventIds.has(id))
457
- bad(narrativePath + '.events', 'references unknown event ' + id);
458
- if (typeof description !== 'string' || description.length === 0)
459
- bad(narrativePath + '.events.' + id, 'must be a non-empty string');
628
+ if (narrative.events !== undefined) {
629
+ if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
630
+ bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description');
460
631
  }
461
- for (const id of eventIds) {
462
- if (typeof narrative.events[id] !== 'string')
463
- bad(narrativePath + '.events', 'missing description for event ' + id);
632
+ else {
633
+ for (const [id, description] of Object.entries(narrative.events)) {
634
+ if (!eventIds.has(id))
635
+ bad(narrativePath + '.events', 'references unknown event ' + id);
636
+ if (typeof description !== 'string' || description.length === 0)
637
+ bad(narrativePath + '.events.' + id, 'must be a non-empty string');
638
+ }
464
639
  }
465
640
  }
466
- if (!Array.isArray(narrative.scenarios)) {
467
- bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }');
468
- }
469
- else {
470
- const seen = new Set();
471
- narrative.scenarios.forEach((entry, index) => {
472
- const scenarioPath = narrativePath + '.scenarios[' + index + ']';
473
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
474
- bad(scenarioPath, 'must be an object');
475
- return;
476
- }
477
- const scenario = entry;
478
- rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath);
479
- if (typeof scenario.from !== 'string' || scenario.from.length === 0)
480
- bad(scenarioPath + '.from', 'must be a non-empty string');
481
- else if (!stateIds.has(scenario.from))
482
- bad(scenarioPath + '.from', 'unknown state ' + scenario.from);
483
- if (typeof scenario.event !== 'string' || scenario.event.length === 0)
484
- bad(scenarioPath + '.event', 'must be a non-empty string');
485
- else if (!eventIds.has(scenario.event))
486
- bad(scenarioPath + '.event', 'unknown event ' + scenario.event);
487
- if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0)
488
- bad(scenarioPath + '.scenario', 'must be a non-empty string');
489
- const key = String(scenario.from) + '|' + String(scenario.event);
490
- if (seen.has(key))
491
- bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')');
492
- seen.add(key);
493
- });
494
- for (const key of fromEventGroups) {
495
- if (!seen.has(key)) {
496
- const sep = key.indexOf('|');
497
- bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')');
498
- }
641
+ if (narrative.scenarios !== undefined) {
642
+ if (!Array.isArray(narrative.scenarios)) {
643
+ bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }');
644
+ }
645
+ else {
646
+ const seen = new Set();
647
+ narrative.scenarios.forEach((entry, index) => {
648
+ const scenarioPath = narrativePath + '.scenarios[' + index + ']';
649
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
650
+ bad(scenarioPath, 'must be an object');
651
+ return;
652
+ }
653
+ const scenario = entry;
654
+ rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath);
655
+ if (typeof scenario.from !== 'string' || scenario.from.length === 0)
656
+ bad(scenarioPath + '.from', 'must be a non-empty string');
657
+ else if (!stateIds.has(scenario.from))
658
+ bad(scenarioPath + '.from', 'unknown state ' + scenario.from);
659
+ if (typeof scenario.event !== 'string' || scenario.event.length === 0)
660
+ bad(scenarioPath + '.event', 'must be a non-empty string');
661
+ else if (!eventIds.has(scenario.event))
662
+ bad(scenarioPath + '.event', 'unknown event ' + scenario.event);
663
+ if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0)
664
+ bad(scenarioPath + '.scenario', 'must be a non-empty string');
665
+ const key = String(scenario.from) + '|' + String(scenario.event);
666
+ if (seen.has(key))
667
+ bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')');
668
+ seen.add(key);
669
+ });
499
670
  }
500
671
  }
501
672
  }
@@ -724,7 +895,7 @@ function checkResult(id, name, findings, detail) {
724
895
  };
725
896
  }
726
897
  // ---------------------------------------------------------------------------
727
- // S1-S7
898
+ // S1-S8
728
899
  // ---------------------------------------------------------------------------
729
900
  function S1_reachability(model, exploration) {
730
901
  const reached = new Set(exploration.reachable.map((runtime) => runtime.state));
@@ -1007,6 +1178,103 @@ function shortestViolationForInvariant(model, options, invariant) {
1007
1178
  }
1008
1179
  return undefined;
1009
1180
  }
1181
+ /**
1182
+ * Whether an invariant asserts a property of whole runs rather than of a single
1183
+ * runtime state. These kinds cannot be decided by a state predicate, so they are not
1184
+ * part of `shortestViolationForInvariant` (and therefore not of S7), which owns the
1185
+ * states; A9-A13 own the verdicts, and `pathPropertyViolation` below makes the same
1186
+ * verdicts available to A7 and D2.
1187
+ */
1188
+ function isPathPropertyInvariant(invariant) {
1189
+ return invariant.kind === 'leads-to'
1190
+ || invariant.kind === 'sequence'
1191
+ || invariant.kind === 'atomicity'
1192
+ || invariant.kind === 'budget'
1193
+ || invariant.kind === 'probability';
1194
+ }
1195
+ /** Whether a probability invariant's bound fails for the computed hit probability. */
1196
+ function probabilityViolated(invariant, probability) {
1197
+ const eps = 1e-9;
1198
+ if (invariant.op === '>=')
1199
+ return probability < invariant.p - eps;
1200
+ if (invariant.op === '>')
1201
+ return probability <= invariant.p + eps;
1202
+ if (invariant.op === '<=')
1203
+ return probability > invariant.p + eps;
1204
+ return probability >= invariant.p - eps;
1205
+ }
1206
+ /** Every runtime state reachable from init, capped like the other searches. */
1207
+ function reachableRuntimeStates(model, options) {
1208
+ const init = initialState(model);
1209
+ const visited = new Set([runtimeKey(init)]);
1210
+ const reached = [init];
1211
+ const queue = [init];
1212
+ let steps = 0;
1213
+ while (queue.length > 0) {
1214
+ if (++steps > options.maxStates)
1215
+ break;
1216
+ const current = queue.shift();
1217
+ for (const event of allEvents(model)) {
1218
+ for (const next of stepRuntime(model, current, event)) {
1219
+ const key = runtimeKey(next);
1220
+ if (visited.has(key))
1221
+ continue;
1222
+ visited.add(key);
1223
+ reached.push(next);
1224
+ queue.push(next);
1225
+ }
1226
+ }
1227
+ }
1228
+ return reached;
1229
+ }
1230
+ /**
1231
+ * Violation of a path-property invariant, or undefined when it holds. Each kind
1232
+ * delegates to the search its dedicated probe uses (A9 leads-to, A10 sequence, A11
1233
+ * atomicity, A12 budget, A13 probability), so a verdict reported here can never
1234
+ * disagree with the probe that owns the kind.
1235
+ */
1236
+ function pathPropertyViolation(model, options, invariant) {
1237
+ if (invariant.kind === 'leads-to') {
1238
+ const targets = new Set(leadsToTargets(invariant));
1239
+ const label = leadsToTargetLabel(invariant);
1240
+ for (const runtime of reachableRuntimeStates(model, options)) {
1241
+ if (runtime.state !== invariant.from)
1242
+ continue;
1243
+ const bad = findLeadsToBadPath(model, runtime, targets, label);
1244
+ if (bad !== undefined)
1245
+ return { invariant, path: bad.path, reason: bad.reason };
1246
+ }
1247
+ return undefined;
1248
+ }
1249
+ if (invariant.kind === 'sequence')
1250
+ return findSequenceViolation(model, options, invariant);
1251
+ if (invariant.kind === 'atomicity')
1252
+ return findAtomicityViolation(model, options, invariant);
1253
+ if (invariant.kind === 'budget') {
1254
+ const violation = findBudgetViolation(model, options, invariant);
1255
+ if (violation === undefined)
1256
+ return undefined;
1257
+ return {
1258
+ invariant,
1259
+ path: violation.path,
1260
+ reason: violation.unbounded
1261
+ ? 'a reachable cycle keeps accumulating cost, so no finite budget holds'
1262
+ : 'a run accumulates cost ' + String(violation.cost) + ', above the declared budget ' + String(invariant.budget),
1263
+ };
1264
+ }
1265
+ if (invariant.kind === 'probability') {
1266
+ const { probability, converged } = computeHitProbability(model, options, invariant.target);
1267
+ if (!probabilityViolated(invariant, probability))
1268
+ return undefined;
1269
+ return {
1270
+ invariant,
1271
+ path: [],
1272
+ reason: 'P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + String(invariant.p)
1273
+ + (converged ? '' : ' (value iteration did not converge)'),
1274
+ };
1275
+ }
1276
+ return undefined;
1277
+ }
1010
1278
  function shortestEventBeforeStateViolation(model, options, invariant) {
1011
1279
  const init = initialState(model);
1012
1280
  const seenInitially = false;
@@ -1038,10 +1306,12 @@ function shortestEventBeforeStateViolation(model, options, invariant) {
1038
1306
  }
1039
1307
  return undefined;
1040
1308
  }
1041
- function runInvariants(model, options) {
1309
+ function runInvariants(model, options, includePathProperties = false) {
1042
1310
  const violations = [];
1043
1311
  for (const invariant of model.invariants ?? []) {
1044
- const violation = shortestViolationForInvariant(model, options, invariant);
1312
+ const violation = includePathProperties && isPathPropertyInvariant(invariant)
1313
+ ? pathPropertyViolation(model, options, invariant)
1314
+ : shortestViolationForInvariant(model, options, invariant);
1045
1315
  if (violation !== undefined)
1046
1316
  violations.push(violation);
1047
1317
  }
@@ -1059,7 +1329,7 @@ function S7_invariants(model, options) {
1059
1329
  return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length);
1060
1330
  }
1061
1331
  // ---------------------------------------------------------------------------
1062
- // A1-A7
1332
+ // A1-A14
1063
1333
  // ---------------------------------------------------------------------------
1064
1334
  function A1_unexpectedEvents(model) {
1065
1335
  const events = allEvents(model);
@@ -1415,14 +1685,27 @@ function A6_resourceInjection(model) {
1415
1685
  return checkResult('A6', 'Resource Injection', findings, findings.length === 0 ? 'No resource vulnerabilities detected' : 'Resource failure paths missing: ' + findings.length);
1416
1686
  }
1417
1687
  function A7_shortestViolations(model, options) {
1418
- const violations = runInvariants(model, options);
1419
- const findings = violations.map((violation) => ({
1420
- code: 'A7_SHORTEST_COUNTEREXAMPLE',
1421
- severity: 'warning',
1422
- message: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + violation.path.length + (violation.path.length === 0 ? ' (initial state)' : ''),
1423
- path: violation.path,
1424
- evidence: { invariant: violation.invariant.id },
1425
- }));
1688
+ // Path-property invariants are included: "for any invariant that fails" is this
1689
+ // check's contract, and a check that silently skips five of the eight kinds cannot
1690
+ // honour it. The searches for the state kinds, for sequence, atomicity and budget
1691
+ // are breadth-first, so their first witness is the shortest one; the leads-to walk
1692
+ // and the probability bound have no such guarantee, and their message says what it
1693
+ // found instead of claiming minimality.
1694
+ const violations = runInvariants(model, options, true);
1695
+ const findings = violations.map((violation) => {
1696
+ const pathLength = violation.path.length;
1697
+ const message = isPathPropertyInvariant(violation.invariant)
1698
+ ? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
1699
+ + (pathLength === 0 ? '' : ' (witness path length: ' + pathLength + ')')
1700
+ : 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + pathLength + (pathLength === 0 ? ' (initial state)' : '');
1701
+ return {
1702
+ code: 'A7_SHORTEST_COUNTEREXAMPLE',
1703
+ severity: 'warning',
1704
+ message,
1705
+ path: violation.path,
1706
+ evidence: { invariant: violation.invariant.id },
1707
+ };
1708
+ });
1426
1709
  return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length);
1427
1710
  }
1428
1711
  function mapStateId(mapping, state) {
@@ -1500,6 +1783,25 @@ function mapInvariantForComparison(invariant, mapping) {
1500
1783
  }
1501
1784
  return mapped;
1502
1785
  }
1786
+ if (invariant.kind === 'leads-to') {
1787
+ // A target set maps member by member; a rename that drops a state is reported by
1788
+ // D2's own mapped-state check rather than silently changing the property.
1789
+ return {
1790
+ ...invariant,
1791
+ id: invariant.id + ':before',
1792
+ description: invariant.description + ' (from BEFORE)',
1793
+ from: mapStateId(mapping, invariant.from),
1794
+ to: typeof invariant.to === 'string' ? mapStateId(mapping, invariant.to) : invariant.to.map((state) => mapStateId(mapping, state)),
1795
+ };
1796
+ }
1797
+ if (invariant.kind === 'probability') {
1798
+ return {
1799
+ ...invariant,
1800
+ id: invariant.id + ':before',
1801
+ description: invariant.description + ' (from BEFORE)',
1802
+ target: mapStateId(mapping, invariant.target),
1803
+ };
1804
+ }
1503
1805
  return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
1504
1806
  }
1505
1807
  function D2_invariantContinuity(before, after, options, mapping) {
@@ -1541,7 +1843,37 @@ function D2_invariantContinuity(before, after, options, mapping) {
1541
1843
  continue;
1542
1844
  }
1543
1845
  }
1544
- const violation = shortestViolationForInvariant(after, options, mapped);
1846
+ else if (mapped.kind === 'leads-to') {
1847
+ // The state references of a path-property invariant must follow stateMapping too,
1848
+ // otherwise a pure rename searches for a state AFTER does not declare.
1849
+ for (const state of [mapped.from, ...leadsToTargets(mapped)]) {
1850
+ if (!afterStates.has(state)) {
1851
+ findings.push({
1852
+ code: 'D2_MAPPED_STATE_MISSING',
1853
+ severity: 'warning',
1854
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
1855
+ evidence: { invariant: invariant.id, state },
1856
+ });
1857
+ }
1858
+ }
1859
+ }
1860
+ else if (mapped.kind === 'probability') {
1861
+ if (!afterStates.has(mapped.target)) {
1862
+ findings.push({
1863
+ code: 'D2_MAPPED_STATE_MISSING',
1864
+ severity: 'warning',
1865
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.target + ', which is not declared in AFTER.',
1866
+ evidence: { invariant: invariant.id, state: mapped.target },
1867
+ });
1868
+ }
1869
+ }
1870
+ // Every invariant the engine can decide is re-decided against AFTER. Path-property
1871
+ // kinds go through the same searches their dedicated probes use, so D2 can no
1872
+ // longer report "all BEFORE invariants continue to hold" while A9-A13 fail on the
1873
+ // very same model.
1874
+ const violation = isPathPropertyInvariant(mapped)
1875
+ ? pathPropertyViolation(after, options, mapped)
1876
+ : shortestViolationForInvariant(after, options, mapped);
1545
1877
  if (violation !== undefined) {
1546
1878
  findings.push({
1547
1879
  code: 'D2_INVARIANT_REGRESSION',
@@ -1722,15 +2054,27 @@ function S8_monotonicVariables(model) {
1722
2054
  return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length);
1723
2055
  }
1724
2056
  /**
1725
- * Find a run from `start` that never reaches `target`, or return undefined when
1726
- * every run does. The property is universal (see the A9 row in `SKILL.md`): one
1727
- * branch that loops forever, or that stops before the target, refutes it.
2057
+ * Target states of a `leads-to` invariant. The schema accepts one state id or a
2058
+ * non-empty array of them, so "reach any of these" needs no separate invariant kind.
2059
+ */
2060
+ function leadsToTargets(invariant) {
2061
+ return typeof invariant.to === 'string' ? [invariant.to] : invariant.to;
2062
+ }
2063
+ /** Target list as findings print it. A single target renders as its bare id, so existing messages stay byte-identical. */
2064
+ function leadsToTargetLabel(invariant) {
2065
+ return leadsToTargets(invariant).join(', ');
2066
+ }
2067
+ /**
2068
+ * Find a run from `start` that reaches none of `targets`, or return undefined when
2069
+ * every run reaches at least one of them. The property is universal (see the A9 row
2070
+ * in `SKILL.md`): one branch that loops forever, or that stops before any target,
2071
+ * refutes it.
1728
2072
  *
1729
- * The search is a depth-first walk of the run graph with three colours, and target
1730
- * runs are success leaves that are never expanded:
2073
+ * The search is a depth-first walk of the run graph with three colours, and runs at a
2074
+ * target are success leaves that are never expanded:
1731
2075
  *
1732
2076
  * - a node reached while it is on the current walk (GRAY) closes a cycle that avoids
1733
- * the target, so some run never reaches it;
2077
+ * every target, so some run never reaches one;
1734
2078
  * - a node with no outgoing step at all stops the machine where it stands, which is
1735
2079
  * the same violation for a different reason;
1736
2080
  * - a node whose walk completed without a violation is BLACK, and reaching it again
@@ -1741,8 +2085,8 @@ function S8_monotonicVariables(model) {
1741
2085
  * A run is identified by its state plus its variable values, so the same state id
1742
2086
  * with different values is a different node.
1743
2087
  */
1744
- function findLeadsToBadPath(model, start, target) {
1745
- if (start.state === target)
2088
+ function findLeadsToBadPath(model, start, targets, label) {
2089
+ if (targets.has(start.state))
1746
2090
  return undefined;
1747
2091
  const GRAY = 1;
1748
2092
  const BLACK = 2;
@@ -1759,7 +2103,7 @@ function findLeadsToBadPath(model, start, target) {
1759
2103
  }
1760
2104
  frame.nexts = nexts;
1761
2105
  if (nexts.length === 0)
1762
- return { path: frame.path, reason: 'Dead end before target ' + target };
2106
+ return { path: frame.path, reason: 'Dead end before target ' + label };
1763
2107
  }
1764
2108
  if (frame.index >= frame.nexts.length) {
1765
2109
  color.set(runtimeKey(frame.runtime), BLACK);
@@ -1769,18 +2113,18 @@ function findLeadsToBadPath(model, start, target) {
1769
2113
  const { next, event } = frame.nexts[frame.index];
1770
2114
  frame.index += 1;
1771
2115
  const step = { from: frame.runtime.state, event, to: next.state };
1772
- if (next.state === target)
2116
+ if (targets.has(next.state))
1773
2117
  continue;
1774
2118
  const key = runtimeKey(next);
1775
2119
  const seen = color.get(key);
1776
2120
  if (seen === GRAY)
1777
- return { path: [...frame.path, step], reason: 'Cycle avoids target ' + target };
2121
+ return { path: [...frame.path, step], reason: 'Cycle avoids target ' + label };
1778
2122
  if (seen === BLACK)
1779
2123
  continue;
1780
2124
  color.set(key, GRAY);
1781
2125
  stack.push({ runtime: next, path: [...frame.path, step], nexts: undefined, index: 0 });
1782
2126
  }
1783
- // Every branch either reached the target or rejoined a branch that did.
2127
+ // Every branch either reached a target or rejoined a branch that did.
1784
2128
  return undefined;
1785
2129
  }
1786
2130
  function A9_leadsTo(model, exploration) {
@@ -1788,10 +2132,12 @@ function A9_leadsTo(model, exploration) {
1788
2132
  for (const invariant of model.invariants ?? []) {
1789
2133
  if (invariant.kind !== 'leads-to')
1790
2134
  continue;
2135
+ const targets = new Set(leadsToTargets(invariant));
2136
+ const label = leadsToTargetLabel(invariant);
1791
2137
  for (const runtime of exploration.reachable) {
1792
2138
  if (runtime.state !== invariant.from)
1793
2139
  continue;
1794
- const bad = findLeadsToBadPath(model, runtime, invariant.to);
2140
+ const bad = findLeadsToBadPath(model, runtime, targets, label);
1795
2141
  if (bad !== undefined) {
1796
2142
  findings.push({
1797
2143
  code: 'A9_LEADS_TO_VIOLATION',
@@ -2199,21 +2545,11 @@ function computeHitProbability(model, options, targetState) {
2199
2545
  }
2200
2546
  function A13_probability(model, options) {
2201
2547
  const findings = [];
2202
- const eps = 1e-9;
2203
2548
  for (const invariant of model.invariants ?? []) {
2204
2549
  if (invariant.kind !== 'probability')
2205
2550
  continue;
2206
2551
  const { probability, converged } = computeHitProbability(model, options, invariant.target);
2207
- let violated = false;
2208
- if (invariant.op === '>=')
2209
- violated = probability < invariant.p - eps;
2210
- else if (invariant.op === '>')
2211
- violated = probability <= invariant.p + eps;
2212
- else if (invariant.op === '<=')
2213
- violated = probability > invariant.p + eps;
2214
- else
2215
- violated = probability >= invariant.p - eps;
2216
- if (violated) {
2552
+ if (probabilityViolated(invariant, probability)) {
2217
2553
  findings.push({
2218
2554
  code: 'A13_PROBABILITY_VIOLATION',
2219
2555
  severity: 'error',
@@ -2355,20 +2691,32 @@ export function runVerification(input, options = {}) {
2355
2691
  maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
2356
2692
  maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
2357
2693
  };
2694
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
2695
+ const metadataKeys = metadataKeysOf(input);
2696
+ const metadata = metadataKeys.length === 0 ? {} : { metadataKeys };
2358
2697
  const validation = validateModel(input);
2359
2698
  if (!validation.ok) {
2699
+ const findings = validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message }));
2700
+ const modelCheck = {
2701
+ id: 'MODEL',
2702
+ name: 'Model Validation',
2703
+ status: 'fail',
2704
+ detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
2705
+ findings,
2706
+ };
2360
2707
  return {
2361
2708
  ok: false,
2709
+ ran: true,
2710
+ ...verdictOfFindings(findings),
2711
+ schema: REPORT_SCHEMAS.verify,
2362
2712
  schemaVersion: 1,
2713
+ hashSpec,
2363
2714
  modelHash: '',
2715
+ hashes: { hashSpec, modelHash: '' },
2716
+ ...metadata,
2364
2717
  summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
2365
- checks: [{
2366
- id: 'MODEL',
2367
- name: 'Model Validation',
2368
- status: 'fail',
2369
- detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
2370
- findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
2371
- }],
2718
+ checks: [modelCheck],
2719
+ nextSteps: verificationNextSteps([modelCheck], undefined),
2372
2720
  };
2373
2721
  }
2374
2722
  const model = validation.model;
@@ -2401,10 +2749,18 @@ export function runVerification(input, options = {}) {
2401
2749
  if (options.beforeModel !== undefined) {
2402
2750
  const beforeValidation = validateModel(options.beforeModel);
2403
2751
  if (!beforeValidation.ok) {
2752
+ const beforeFindings = beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message }));
2753
+ const allFindings = [...checks.flatMap((check) => check.findings), ...beforeFindings];
2404
2754
  return {
2405
2755
  ok: false,
2756
+ ran: true,
2757
+ ...verdictOfFindings(allFindings),
2758
+ schema: REPORT_SCHEMAS.verify,
2406
2759
  schemaVersion: 1,
2407
- modelHash: modelHash(model),
2760
+ hashSpec,
2761
+ modelHash: modelHash(model, hashSpec),
2762
+ hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
2763
+ ...metadata,
2408
2764
  summary: {
2409
2765
  states: model.states.length,
2410
2766
  transitions: model.transitions.length,
@@ -2420,9 +2776,10 @@ export function runVerification(input, options = {}) {
2420
2776
  name: 'Before Model Validation',
2421
2777
  status: 'fail',
2422
2778
  detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
2423
- findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
2779
+ findings: beforeFindings,
2424
2780
  },
2425
2781
  ],
2782
+ nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
2426
2783
  };
2427
2784
  }
2428
2785
  const before = beforeValidation.model;
@@ -2433,10 +2790,22 @@ export function runVerification(input, options = {}) {
2433
2790
  const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
2434
2791
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
2435
2792
  const coverageNotes = computeCoverageNotes(model);
2793
+ const narrativeCoverage = narrativeCoverageOf(model);
2436
2794
  return {
2437
2795
  ok: true,
2796
+ ran: true,
2797
+ ...verdictOfFindings(checks.flatMap((check) => check.findings)),
2798
+ schema: REPORT_SCHEMAS.verify,
2438
2799
  schemaVersion: 1,
2439
- modelHash: modelHash(model),
2800
+ hashSpec,
2801
+ modelHash: modelHash(model, hashSpec),
2802
+ hashes: {
2803
+ hashSpec,
2804
+ modelHash: modelHash(model, hashSpec),
2805
+ ...(comparison === undefined ? {} : { beforeModelHash: comparison.beforeModelHash, afterModelHash: comparison.afterModelHash }),
2806
+ },
2807
+ ...metadata,
2808
+ ...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
2440
2809
  summary: {
2441
2810
  states: model.states.length,
2442
2811
  transitions: model.transitions.length,
@@ -2446,6 +2815,7 @@ export function runVerification(input, options = {}) {
2446
2815
  truncated: exploration.truncated,
2447
2816
  },
2448
2817
  checks,
2818
+ nextSteps: verificationNextSteps(checks, narrativeCoverage),
2449
2819
  ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
2450
2820
  ...(comparison === undefined ? {} : { comparison }),
2451
2821
  ...(coverageNotes.length === 0 ? {} : { coverageNotes }),
@@ -2463,6 +2833,7 @@ export function runVerification(input, options = {}) {
2463
2833
  * least one is not terminal) and C2 rendezvous that can never fire.
2464
2834
  */
2465
2835
  export function runCompositionVerification(machinesInput, options = {}) {
2836
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
2466
2837
  const models = [];
2467
2838
  const hashes = [];
2468
2839
  const modelFindings = [];
@@ -2473,7 +2844,7 @@ export function runCompositionVerification(machinesInput, options = {}) {
2473
2844
  }
2474
2845
  else {
2475
2846
  models.push(validation.model);
2476
- hashes.push(modelHash(validation.model));
2847
+ hashes.push(modelHash(validation.model, hashSpec));
2477
2848
  }
2478
2849
  });
2479
2850
  const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }));
@@ -2483,8 +2854,14 @@ export function runCompositionVerification(machinesInput, options = {}) {
2483
2854
  }
2484
2855
  return {
2485
2856
  ok: false,
2857
+ ran: true,
2858
+ ...verdictOfFindings(modelFindings),
2859
+ schema: REPORT_SCHEMAS.compose,
2860
+ hashSpec,
2861
+ hashes: { hashSpec, machines: hashes },
2486
2862
  summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
2487
2863
  checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
2864
+ nextSteps: ['Fix the machine that failed validation, then re-run the composition.'],
2488
2865
  };
2489
2866
  }
2490
2867
  const rendezvous = new Set(options.rendezvous ?? []);
@@ -2548,6 +2925,17 @@ export function runCompositionVerification(machinesInput, options = {}) {
2548
2925
  const firedCount = new Map();
2549
2926
  for (const event of rendezvous)
2550
2927
  firedCount.set(event, 0);
2928
+ // Per-machine enablement, collected while the state space is explored: a rendezvous
2929
+ // that never fires needs to say *which* machine never had it enabled, not just that
2930
+ // the handshake did not happen.
2931
+ const everEnabled = new Map();
2932
+ const enabledStates = new Map();
2933
+ const coEnabledNodes = new Map();
2934
+ for (const event of rendezvous) {
2935
+ everEnabled.set(event, new Set());
2936
+ enabledStates.set(event, models.map(() => []));
2937
+ coEnabledNodes.set(event, 0);
2938
+ }
2551
2939
  const c1Findings = [];
2552
2940
  while (queue.length > 0) {
2553
2941
  const node = queue.shift();
@@ -2562,12 +2950,66 @@ export function runCompositionVerification(machinesInput, options = {}) {
2562
2950
  }
2563
2951
  const moves = compositionMoves(node);
2564
2952
  const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state));
2953
+ // Enablement census for C2: which machines could fire each rendezvous here.
2954
+ for (const event of rendezvous) {
2955
+ const declaring = [];
2956
+ for (let i = 0; i < models.length; i++)
2957
+ if (machineEventSets[i].has(event))
2958
+ declaring.push(i);
2959
+ if (declaring.length === 0)
2960
+ continue;
2961
+ let allEnabled = declaring.length >= 2;
2962
+ for (const i of declaring) {
2963
+ const enabled = !isTerminal(models[i], node.runtimes[i].state) && stepRuntime(models[i], node.runtimes[i], event).length > 0;
2964
+ if (!enabled) {
2965
+ allEnabled = false;
2966
+ continue;
2967
+ }
2968
+ everEnabled.get(event)?.add(i);
2969
+ const states = enabledStates.get(event)?.[i];
2970
+ if (states !== undefined && !states.includes(node.runtimes[i].state) && states.length < 8)
2971
+ states.push(node.runtimes[i].state);
2972
+ }
2973
+ if (allEnabled)
2974
+ coEnabledNodes.set(event, (coEnabledNodes.get(event) ?? 0) + 1);
2975
+ }
2565
2976
  if (moves.length === 0 && !allTerminal) {
2977
+ // Why nobody can advance, per machine: its state, its alphabet, and for every event
2978
+ // either the rendezvous partner that is not ready or the transition that does not fire.
2979
+ const perMachine = node.runtimes.map((runtime, i) => {
2980
+ const terminal = isTerminal(models[i], runtime.state);
2981
+ const blocked = terminal ? [] : allEvents(models[i]).map((event) => {
2982
+ const enabled = stepRuntime(models[i], runtime, event).length > 0;
2983
+ // A rendezvous event is not fireable just because this machine has a transition:
2984
+ // it needs every declaring machine enabled at the same time. Check that first, or
2985
+ // the deadlock census would report "enabled" for a handshake nobody can complete.
2986
+ if (!rendezvous.has(event))
2987
+ return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' };
2988
+ const declaring = [];
2989
+ for (let j = 0; j < models.length; j++)
2990
+ if (machineEventSets[j].has(event))
2991
+ declaring.push(j);
2992
+ if (declaring.length < 2)
2993
+ return { event, reason: 'rendezvous-needs-partner' };
2994
+ const partnersReady = declaring.every((j) => !isTerminal(models[j], node.runtimes[j].state)
2995
+ && stepRuntime(models[j], node.runtimes[j], event).length > 0);
2996
+ if (!partnersReady)
2997
+ return { event, reason: 'rendezvous-partner-not-ready' };
2998
+ return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' };
2999
+ });
3000
+ return { index: i, state: runtime.state, terminal, blocked };
3001
+ });
3002
+ const reasons = perMachine
3003
+ .filter((machine) => !machine.terminal)
3004
+ .map((machine) => 'machine ' + String(machine.index) + ' at ' + machine.state + ': ' + (machine.blocked.length === 0
3005
+ ? 'empty event alphabet'
3006
+ : machine.blocked.map((entry) => entry.event + ' (' + entry.reason + ')').join(', ')));
2566
3007
  c1Findings.push({
2567
3008
  code: 'C1_COMPOSITION_DEADLOCK',
2568
3009
  severity: 'error',
2569
3010
  message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
2570
- evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state) },
3011
+ detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | '),
3012
+ evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state), depth: node.path.length, perMachine, reasons },
2571
3013
  });
2572
3014
  continue;
2573
3015
  }
@@ -2589,22 +3031,71 @@ export function runCompositionVerification(machinesInput, options = {}) {
2589
3031
  if (!allEventsSet.has(event))
2590
3032
  continue;
2591
3033
  if ((firedCount.get(event) ?? 0) === 0) {
3034
+ // Why the handshake never happens: who declares it, who ever had it enabled, and
3035
+ // whether the enabling states could ever coincide.
3036
+ const machines = models.map((model, index) => ({
3037
+ index,
3038
+ declares: machineEventSets[index].has(event),
3039
+ everEnabled: everEnabled.get(event)?.has(index) === true,
3040
+ enabledAt: enabledStates.get(event)?.[index] ?? [],
3041
+ }));
3042
+ const declaring = machines.filter((machine) => machine.declares);
3043
+ const neverEnabled = declaring.filter((machine) => !machine.everEnabled);
3044
+ const coEnabled = coEnabledNodes.get(event) ?? 0;
3045
+ let reason;
3046
+ if (declaring.length < 2) {
3047
+ reason = 'only ' + String(declaring.length) + ' machine(s) declare it (machine ' + declaring.map((machine) => machine.index).join(', ') + '); a handshake needs at least two';
3048
+ }
3049
+ else if (neverEnabled.length > 0) {
3050
+ reason = 'machine ' + neverEnabled.map((machine) => machine.index).join(', ') + ' declares it but never has it enabled (no transition, or the guard never holds)';
3051
+ }
3052
+ else {
3053
+ reason = 'every declaring machine enables it somewhere, but never at the same time (' + String(coEnabled) + ' composite state(s) had all of them ready)';
3054
+ }
3055
+ // A truncated exploration cannot prove that a handshake never happens: it may simply
3056
+ // not have reached the composite state where both machines are ready.
3057
+ 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.' : '';
2592
3058
  c2Findings.push({
2593
3059
  code: 'C2_RENDEZVOUS_NEVER_FIRES',
2594
3060
  severity: 'warning',
2595
- message: 'Rendezvous event ' + event + ' can never fire: fewer than two machines ever jointly enable it.',
3061
+ message: 'Rendezvous event ' + event + ' can never fire: ' + reason + '.' + caveat,
3062
+ detail: machines.map((machine) => 'machine ' + String(machine.index) + (machine.declares ? ' declares' : ' does not declare')
3063
+ + (machine.everEnabled ? ', enabled at ' + (machine.enabledAt.length === 0 ? 'no visited state' : machine.enabledAt.join('/')) : ', never enabled')).join(' | '),
3064
+ evidence: { event, machines, declaring: declaring.map((machine) => machine.index), coEnabledNodes: coEnabled, reason, truncated },
2596
3065
  });
2597
3066
  }
2598
3067
  }
2599
3068
  const errors = c1Findings.length;
2600
3069
  const warnings = c2Findings.length;
3070
+ const c1Detail = c1Findings.length > 0
3071
+ ? 'Composition deadlocks: ' + String(c1Findings.length)
3072
+ : truncated
3073
+ ? 'No composition deadlock found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
3074
+ : 'No composition deadlock reachable';
3075
+ const c2Detail = c2Findings.length > 0
3076
+ ? 'Rendezvous warnings: ' + String(c2Findings.length)
3077
+ : truncated
3078
+ ? 'All rendezvous events fired, but the search was truncated at ' + String(maxStates) + ' composite states'
3079
+ : 'All rendezvous events can fire';
2601
3080
  const checks = [
2602
- checkResult('C1', 'Composition Deadlock', c1Findings, c1Findings.length === 0 ? 'No composition deadlock reachable' : 'Composition deadlocks: ' + c1Findings.length),
2603
- checkResult('C2', 'Rendezvous Sync', c2Findings, c2Findings.length === 0 ? 'All rendezvous events can fire' : 'Rendezvous warnings: ' + c2Findings.length),
3081
+ checkResult('C1', 'Composition Deadlock', c1Findings, c1Detail),
3082
+ checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
2604
3083
  ];
3084
+ const nextSteps = [];
3085
+ if (errors > 0)
3086
+ nextSteps.push('Resolve the error findings first: the composition deadlocks at a reachable state, so at least one machine is missing an exit or a handshake partner.');
3087
+ if (warnings > 0)
3088
+ nextSteps.push('Review the warning findings: a rendezvous event that can never fire means the handshake is declared but unreachable.');
3089
+ nextSteps.push('Re-run the composition after the change; every machine must be able to advance, or explain why it is terminal.');
2605
3090
  return {
2606
3091
  ok: errors === 0,
3092
+ ran: true,
3093
+ ...verdictOf(errors, warnings, c1Findings[0]?.code ?? c2Findings[0]?.code),
3094
+ schema: REPORT_SCHEMAS.compose,
3095
+ hashSpec,
3096
+ hashes: { hashSpec, machines: hashes },
2607
3097
  summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
2608
3098
  checks,
3099
+ nextSteps,
2609
3100
  };
2610
3101
  }