dsh-logicprobe 0.9.0 → 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 (63) 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.js +14 -0
  7. package/lib/data-engine.js +42 -8
  8. package/lib/engine.js +379 -79
  9. package/lib/export-tool.js +27 -2
  10. package/lib/granularity.js +237 -0
  11. package/lib/index.js +63 -41
  12. package/lib/report-diff-tool.js +39 -0
  13. package/lib/structure-tool.js +64 -0
  14. package/lib/structure.js +648 -0
  15. package/lib/tool.js +3 -3
  16. package/lib/types/baseline.d.ts +69 -0
  17. package/lib/types/concurrency.d.ts +10 -0
  18. package/lib/types/data-engine.d.ts +13 -1
  19. package/lib/types/engine.d.ts +138 -1
  20. package/lib/types/granularity.d.ts +106 -0
  21. package/lib/types/index.d.ts +9 -8
  22. package/lib/types/report-diff-tool.d.ts +8 -0
  23. package/lib/types/structure-tool.d.ts +10 -0
  24. package/lib/types/structure.d.ts +156 -0
  25. package/lib/types/tool.d.ts +2 -2
  26. package/lib/types/uml.d.ts +89 -0
  27. package/lib/uml-tool.js +36 -7
  28. package/lib/uml.js +217 -23
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +16 -14
  32. package/skills/logicprobe/SKILL.md +84 -58
  33. package/skills/logicprobe/references/dsh-model-schema.md +81 -10
  34. package/skills/logicprobe/references/gap-routing-guide.md +37 -36
  35. package/skills/logicprobe/references/hash-spec.md +128 -0
  36. package/skills/logicprobe/references/logic-verification-guide.md +27 -250
  37. package/skills/logicprobe-concurrency/SKILL.md +43 -0
  38. package/skills/{logicprobe → logicprobe-concurrency}/references/concurrency-risk-guide.md +1 -1
  39. package/skills/logicprobe-datamodel/SKILL.md +5 -4
  40. package/skills/logicprobe-datamodel/references/data-model-guide.md +1 -1
  41. package/skills/logicprobe-datamodel/references/data-model-schema.md +1 -1
  42. package/skills/logicprobe-structure/SKILL.md +95 -0
  43. package/skills/logicprobe-structure/references/structure-review-guide.md +177 -0
  44. package/skills/logicprobe-uml/SKILL.md +55 -0
  45. package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +120 -8
  46. package/src/baseline.ts +180 -0
  47. package/src/client.js +56 -1
  48. package/src/compose-tool.ts +47 -47
  49. package/src/concurrency.ts +23 -0
  50. package/src/data-engine.ts +51 -9
  51. package/src/engine.ts +441 -67
  52. package/src/export-tool.ts +73 -48
  53. package/src/granularity.ts +312 -0
  54. package/src/index.ts +63 -41
  55. package/src/report-diff-tool.ts +43 -0
  56. package/src/structure-tool.ts +68 -0
  57. package/src/structure.ts +724 -0
  58. package/src/tool.ts +3 -3
  59. package/src/uml-tool.ts +38 -8
  60. package/src/uml.ts +275 -24
  61. package/skills/logicprobe/references/logicprobe-engine.py +0 -4943
  62. package/skills/logicprobe/references/verification-harness.py +0 -959
  63. 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;
@@ -458,70 +603,70 @@ export function validateModel(input) {
458
603
  rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath);
459
604
  const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
460
605
  const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
461
- const fromEventGroups = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.from + '|' + transition.event) : []);
462
- if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
463
- 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)');
464
614
  }
465
- else {
466
- for (const [id, description] of Object.entries(narrative.states)) {
467
- if (!stateIds.has(id))
468
- bad(narrativePath + '.states', 'references unknown state ' + id);
469
- if (typeof description !== 'string' || description.length === 0)
470
- 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');
471
618
  }
472
- for (const id of stateIds) {
473
- if (typeof narrative.states[id] !== 'string')
474
- 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
+ }
475
626
  }
476
627
  }
477
- if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
478
- bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description');
479
- }
480
- else {
481
- for (const [id, description] of Object.entries(narrative.events)) {
482
- if (!eventIds.has(id))
483
- bad(narrativePath + '.events', 'references unknown event ' + id);
484
- if (typeof description !== 'string' || description.length === 0)
485
- 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');
486
631
  }
487
- for (const id of eventIds) {
488
- if (typeof narrative.events[id] !== 'string')
489
- 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
+ }
490
639
  }
491
640
  }
492
- if (!Array.isArray(narrative.scenarios)) {
493
- bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }');
494
- }
495
- else {
496
- const seen = new Set();
497
- narrative.scenarios.forEach((entry, index) => {
498
- const scenarioPath = narrativePath + '.scenarios[' + index + ']';
499
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
500
- bad(scenarioPath, 'must be an object');
501
- return;
502
- }
503
- const scenario = entry;
504
- rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath);
505
- if (typeof scenario.from !== 'string' || scenario.from.length === 0)
506
- bad(scenarioPath + '.from', 'must be a non-empty string');
507
- else if (!stateIds.has(scenario.from))
508
- bad(scenarioPath + '.from', 'unknown state ' + scenario.from);
509
- if (typeof scenario.event !== 'string' || scenario.event.length === 0)
510
- bad(scenarioPath + '.event', 'must be a non-empty string');
511
- else if (!eventIds.has(scenario.event))
512
- bad(scenarioPath + '.event', 'unknown event ' + scenario.event);
513
- if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0)
514
- bad(scenarioPath + '.scenario', 'must be a non-empty string');
515
- const key = String(scenario.from) + '|' + String(scenario.event);
516
- if (seen.has(key))
517
- bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')');
518
- seen.add(key);
519
- });
520
- for (const key of fromEventGroups) {
521
- if (!seen.has(key)) {
522
- const sep = key.indexOf('|');
523
- bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')');
524
- }
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
+ });
525
670
  }
526
671
  }
527
672
  }
@@ -750,7 +895,7 @@ function checkResult(id, name, findings, detail) {
750
895
  };
751
896
  }
752
897
  // ---------------------------------------------------------------------------
753
- // S1-S7
898
+ // S1-S8
754
899
  // ---------------------------------------------------------------------------
755
900
  function S1_reachability(model, exploration) {
756
901
  const reached = new Set(exploration.reachable.map((runtime) => runtime.state));
@@ -1184,7 +1329,7 @@ function S7_invariants(model, options) {
1184
1329
  return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length);
1185
1330
  }
1186
1331
  // ---------------------------------------------------------------------------
1187
- // A1-A7
1332
+ // A1-A14
1188
1333
  // ---------------------------------------------------------------------------
1189
1334
  function A1_unexpectedEvents(model) {
1190
1335
  const events = allEvents(model);
@@ -2546,20 +2691,32 @@ export function runVerification(input, options = {}) {
2546
2691
  maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
2547
2692
  maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
2548
2693
  };
2694
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
2695
+ const metadataKeys = metadataKeysOf(input);
2696
+ const metadata = metadataKeys.length === 0 ? {} : { metadataKeys };
2549
2697
  const validation = validateModel(input);
2550
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
+ };
2551
2707
  return {
2552
2708
  ok: false,
2709
+ ran: true,
2710
+ ...verdictOfFindings(findings),
2711
+ schema: REPORT_SCHEMAS.verify,
2553
2712
  schemaVersion: 1,
2713
+ hashSpec,
2554
2714
  modelHash: '',
2715
+ hashes: { hashSpec, modelHash: '' },
2716
+ ...metadata,
2555
2717
  summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
2556
- checks: [{
2557
- id: 'MODEL',
2558
- name: 'Model Validation',
2559
- status: 'fail',
2560
- detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
2561
- findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
2562
- }],
2718
+ checks: [modelCheck],
2719
+ nextSteps: verificationNextSteps([modelCheck], undefined),
2563
2720
  };
2564
2721
  }
2565
2722
  const model = validation.model;
@@ -2592,10 +2749,18 @@ export function runVerification(input, options = {}) {
2592
2749
  if (options.beforeModel !== undefined) {
2593
2750
  const beforeValidation = validateModel(options.beforeModel);
2594
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];
2595
2754
  return {
2596
2755
  ok: false,
2756
+ ran: true,
2757
+ ...verdictOfFindings(allFindings),
2758
+ schema: REPORT_SCHEMAS.verify,
2597
2759
  schemaVersion: 1,
2598
- modelHash: modelHash(model),
2760
+ hashSpec,
2761
+ modelHash: modelHash(model, hashSpec),
2762
+ hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
2763
+ ...metadata,
2599
2764
  summary: {
2600
2765
  states: model.states.length,
2601
2766
  transitions: model.transitions.length,
@@ -2611,9 +2776,10 @@ export function runVerification(input, options = {}) {
2611
2776
  name: 'Before Model Validation',
2612
2777
  status: 'fail',
2613
2778
  detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
2614
- findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
2779
+ findings: beforeFindings,
2615
2780
  },
2616
2781
  ],
2782
+ nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
2617
2783
  };
2618
2784
  }
2619
2785
  const before = beforeValidation.model;
@@ -2624,10 +2790,22 @@ export function runVerification(input, options = {}) {
2624
2790
  const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
2625
2791
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
2626
2792
  const coverageNotes = computeCoverageNotes(model);
2793
+ const narrativeCoverage = narrativeCoverageOf(model);
2627
2794
  return {
2628
2795
  ok: true,
2796
+ ran: true,
2797
+ ...verdictOfFindings(checks.flatMap((check) => check.findings)),
2798
+ schema: REPORT_SCHEMAS.verify,
2629
2799
  schemaVersion: 1,
2630
- 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 }),
2631
2809
  summary: {
2632
2810
  states: model.states.length,
2633
2811
  transitions: model.transitions.length,
@@ -2637,6 +2815,7 @@ export function runVerification(input, options = {}) {
2637
2815
  truncated: exploration.truncated,
2638
2816
  },
2639
2817
  checks,
2818
+ nextSteps: verificationNextSteps(checks, narrativeCoverage),
2640
2819
  ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
2641
2820
  ...(comparison === undefined ? {} : { comparison }),
2642
2821
  ...(coverageNotes.length === 0 ? {} : { coverageNotes }),
@@ -2654,6 +2833,7 @@ export function runVerification(input, options = {}) {
2654
2833
  * least one is not terminal) and C2 rendezvous that can never fire.
2655
2834
  */
2656
2835
  export function runCompositionVerification(machinesInput, options = {}) {
2836
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
2657
2837
  const models = [];
2658
2838
  const hashes = [];
2659
2839
  const modelFindings = [];
@@ -2664,7 +2844,7 @@ export function runCompositionVerification(machinesInput, options = {}) {
2664
2844
  }
2665
2845
  else {
2666
2846
  models.push(validation.model);
2667
- hashes.push(modelHash(validation.model));
2847
+ hashes.push(modelHash(validation.model, hashSpec));
2668
2848
  }
2669
2849
  });
2670
2850
  const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }));
@@ -2674,8 +2854,14 @@ export function runCompositionVerification(machinesInput, options = {}) {
2674
2854
  }
2675
2855
  return {
2676
2856
  ok: false,
2857
+ ran: true,
2858
+ ...verdictOfFindings(modelFindings),
2859
+ schema: REPORT_SCHEMAS.compose,
2860
+ hashSpec,
2861
+ hashes: { hashSpec, machines: hashes },
2677
2862
  summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
2678
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.'],
2679
2865
  };
2680
2866
  }
2681
2867
  const rendezvous = new Set(options.rendezvous ?? []);
@@ -2739,6 +2925,17 @@ export function runCompositionVerification(machinesInput, options = {}) {
2739
2925
  const firedCount = new Map();
2740
2926
  for (const event of rendezvous)
2741
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
+ }
2742
2939
  const c1Findings = [];
2743
2940
  while (queue.length > 0) {
2744
2941
  const node = queue.shift();
@@ -2753,12 +2950,66 @@ export function runCompositionVerification(machinesInput, options = {}) {
2753
2950
  }
2754
2951
  const moves = compositionMoves(node);
2755
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
+ }
2756
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(', ')));
2757
3007
  c1Findings.push({
2758
3008
  code: 'C1_COMPOSITION_DEADLOCK',
2759
3009
  severity: 'error',
2760
3010
  message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
2761
- 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 },
2762
3013
  });
2763
3014
  continue;
2764
3015
  }
@@ -2780,22 +3031,71 @@ export function runCompositionVerification(machinesInput, options = {}) {
2780
3031
  if (!allEventsSet.has(event))
2781
3032
  continue;
2782
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.' : '';
2783
3058
  c2Findings.push({
2784
3059
  code: 'C2_RENDEZVOUS_NEVER_FIRES',
2785
3060
  severity: 'warning',
2786
- 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 },
2787
3065
  });
2788
3066
  }
2789
3067
  }
2790
3068
  const errors = c1Findings.length;
2791
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';
2792
3080
  const checks = [
2793
- checkResult('C1', 'Composition Deadlock', c1Findings, c1Findings.length === 0 ? 'No composition deadlock reachable' : 'Composition deadlocks: ' + c1Findings.length),
2794
- 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),
2795
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.');
2796
3090
  return {
2797
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 },
2798
3097
  summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
2799
3098
  checks,
3099
+ nextSteps,
2800
3100
  };
2801
3101
  }