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/src/engine.ts CHANGED
@@ -194,6 +194,8 @@ export interface VerificationOptions {
194
194
  maxPermutationEvents?: number
195
195
  beforeModel?: unknown
196
196
  stateMapping?: Record<string, string>
197
+ /** Hash specification used for modelHash. Defaults to the current published spec (`v1`). */
198
+ hashSpec?: HashSpec
197
199
  }
198
200
 
199
201
  export interface PathStep {
@@ -206,6 +208,12 @@ export interface Finding {
206
208
  code: string
207
209
  severity: 'error' | 'warning'
208
210
  message: string
211
+ /** Longer explanation: the shortest-path note, the per-machine reasons, the quoted source lines. */
212
+ detail?: string
213
+ /** The artefact the finding is about (a diagram name, a file path) — set when one report covers several. */
214
+ file?: string
215
+ /** 1-based line in that artefact, when the check knows it. */
216
+ line?: number
209
217
  path?: PathStep[]
210
218
  evidence?: Record<string, unknown>
211
219
  }
@@ -219,9 +227,29 @@ export interface CheckResult {
219
227
  }
220
228
 
221
229
  export interface VerificationReport {
230
+ /**
231
+ * The engine ran and the input was well-formed enough to produce this report.
232
+ * It does NOT mean the review passed — read `verdict` for that.
233
+ */
222
234
  ok: boolean
235
+ /** The tool executed and produced a report. False only when a tool refused the request before the engine ran. */
236
+ ran: boolean
237
+ /** The review outcome. `ok: true` with `verdict: "fail"` is a failing review, not a passing one. */
238
+ verdict: Verdict
239
+ /** Why the verdict came out that way, e.g. `2 error finding(s) (first: S2_NO_TRANSITIONS)`. */
240
+ verdictReason: string
241
+ /** The versioned report contract this result follows. */
242
+ schema: ReportSchema
223
243
  schemaVersion: 1
244
+ /** Which published specification `modelHash` follows (see references/hash-spec.md). */
245
+ hashSpec: HashSpec
224
246
  modelHash: string
247
+ /** Every hash this report carries, in one place, for a baseline diff or an archive record. */
248
+ hashes: { hashSpec: HashSpec; modelHash: string; beforeModelHash?: string; afterModelHash?: string }
249
+ /** Paths of the `_`-prefixed metadata keys found in the input and ignored by the schema. */
250
+ metadataKeys?: string[]
251
+ /** How much of the model the narrative documents; absent when there is no narrative. */
252
+ narrativeCoverage?: NarrativeCoverage
225
253
  /** Echo of the model's natural-language narrative, when present. */
226
254
  narrative?: ModelNarrative
227
255
  summary: {
@@ -234,11 +262,30 @@ export interface VerificationReport {
234
262
  }
235
263
  checks: CheckResult[]
236
264
  comparison?: ComparisonSummary
265
+ /** What to do next, derived from the findings — never empty. */
266
+ nextSteps: string[]
237
267
  /** Informational notes about semantic dimensions this model references (timing, preemption)
238
268
  * that this engine does not verify. Heuristic, vocabulary-based — never a substitute for the checks. */
239
269
  coverageNotes?: string[]
240
270
  }
241
271
 
272
+ /**
273
+ * The next-steps list every verification report carries. Derived only from the checks
274
+ * and the narrative coverage, so two runs over the same model produce the same list.
275
+ */
276
+ function verificationNextSteps(checks: CheckResult[], coverage: NarrativeCoverage | undefined): string[] {
277
+ const steps: string[] = []
278
+ const failed = checks.filter((check) => check.status === 'fail').map((check) => check.id + ' ' + check.name)
279
+ const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort()
280
+ if (failed.length > 0) steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.')
281
+ if (warningCodes.length > 0) steps.push('Review the warning findings (' + warningCodes.join(', ') + '): they are not failures, but they are not silence either.')
282
+ if (coverage !== undefined && !narrativeComplete(coverage)) {
283
+ 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.')
284
+ }
285
+ steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress (D1-D4).')
286
+ return steps
287
+ }
288
+
242
289
  export interface ComparisonSummary {
243
290
  beforeModelHash: string
244
291
  afterModelHash: string
@@ -272,8 +319,177 @@ function stableStringify(value: unknown): string {
272
319
  return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}'
273
320
  }
274
321
 
275
- export function modelHash(model: LogicModelV1): string {
276
- return createHash('sha256').update(stableStringify(model)).digest('hex')
322
+ export function modelHash(model: LogicModelV1, spec: HashSpec = DEFAULT_HASH_SPEC): string {
323
+ return createHash('sha256').update(stableStringify(hashPayload(model, spec))).digest('hex')
324
+ }
325
+
326
+ /**
327
+ * Deterministic JSON (sorted keys, no insignificant whitespace) — the serialization the
328
+ * hash specification is defined over. Exported so every report hashes inputs the same way.
329
+ */
330
+ export function canonicalJson(value: unknown): string {
331
+ return stableStringify(value)
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Report contract: ran / verdict (P0-1) and metadata keys + hash specs (P0-3/P0-4)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ /**
339
+ * Published model-hash specifications. The full normalization rules, the list of
340
+ * excluded keys and a one-line recompute command are in `references/hash-spec.md`.
341
+ *
342
+ * - `v1` (1.0.0) drops every `_`-prefixed metadata key at every level before hashing,
343
+ * so archiving `_source` / `_verified` next to a model cannot change its hash.
344
+ * - `v0` is the pre-1.0.0 behaviour (no metadata filtering), kept so an archived hash
345
+ * can still be checked against the old engine.
346
+ *
347
+ * For a model that carries no `_` keys the two specs are byte-identical.
348
+ */
349
+ export type HashSpec = 'v0' | 'v1'
350
+
351
+ export const PUBLISHED_HASH_SPECS: readonly HashSpec[] = ['v0', 'v1']
352
+ export const DEFAULT_HASH_SPEC: HashSpec = 'v1'
353
+
354
+ /**
355
+ * Versioned report contracts. Every tool result carries `schema`, so a consumer can
356
+ * branch on the contract instead of sniffing fields, and a change to a report's shape
357
+ * is a version bump here rather than a silent break. `findings[]` keeps the same
358
+ * stable core in every family: `{code, severity, message, detail?, evidence?, path?}`.
359
+ */
360
+ export const REPORT_SCHEMAS = {
361
+ verify: 'logicprobe/verify/v1',
362
+ compose: 'logicprobe/compose/v1',
363
+ datamodel: 'logicprobe/datamodel/v1',
364
+ concurrency: 'logicprobe/concurrency/v1',
365
+ export: 'logicprobe/export/v1',
366
+ umlRender: 'logicprobe/uml/render/v1',
367
+ umlParse: 'logicprobe/uml/parse/v1',
368
+ umlReview: 'logicprobe/uml/review/v1',
369
+ structure: 'logicprobe/structure/v1',
370
+ granularity: 'logicprobe/granularity/v1',
371
+ baseline: 'logicprobe/baseline/v1',
372
+ } as const
373
+
374
+ export type ReportSchema = typeof REPORT_SCHEMAS[keyof typeof REPORT_SCHEMAS]
375
+
376
+ /**
377
+ * How much of the model the `narrative` block actually documents, as `covered/total`
378
+ * per dimension. A partial narrative is valid — writing the states first and the
379
+ * events later is the natural order — so this is a coverage report, not a gate.
380
+ * `scenarios` counts distinct modelled (from, event) groups: one scenario per group.
381
+ */
382
+ export interface NarrativeCoverage {
383
+ states: string
384
+ events: string
385
+ scenarios: string
386
+ }
387
+
388
+ export function narrativeCoverageOf(model: LogicModelV1): NarrativeCoverage | undefined {
389
+ const narrative = model.narrative
390
+ if (narrative === undefined) return undefined
391
+ const describedStates = narrative.states === undefined ? 0 : Object.keys(narrative.states).length
392
+ const describedEvents = narrative.events === undefined ? 0 : Object.keys(narrative.events).length
393
+ const groups = new Set(model.transitions.map((transition) => transition.from + '|' + transition.event))
394
+ const describedGroups = narrative.scenarios === undefined
395
+ ? 0
396
+ : new Set(narrative.scenarios.map((scenario) => scenario.from + '|' + scenario.event)).size
397
+ const events = new Set(model.transitions.map((transition) => transition.event))
398
+ return {
399
+ states: String(describedStates) + '/' + String(model.states.length),
400
+ events: String(describedEvents) + '/' + String(events.size),
401
+ scenarios: String(describedGroups) + '/' + String(groups.size),
402
+ }
403
+ }
404
+
405
+ /** True when every dimension of the coverage is fully described. */
406
+ export function narrativeComplete(coverage: NarrativeCoverage | undefined): boolean {
407
+ if (coverage === undefined) return false
408
+ return coverage.states.split('/')[0] === coverage.states.split('/')[1]
409
+ && coverage.events.split('/')[0] === coverage.events.split('/')[1]
410
+ && coverage.scenarios.split('/')[0] === coverage.scenarios.split('/')[1]
411
+ }
412
+
413
+ /**
414
+ * The review outcome, kept separate from `ok` (= "the tool ran"). Collapsing the two
415
+ * is how a failed review gets read as a passing one.
416
+ */
417
+ export type Verdict = 'pass' | 'pass_with_findings' | 'fail'
418
+
419
+ export interface VerdictSummary {
420
+ verdict: Verdict
421
+ verdictReason: string
422
+ }
423
+
424
+ /** A tool that refused the request before running anything: it neither passed nor failed a review. */
425
+ export function refusalVerdict(reason: string): VerdictSummary {
426
+ return { verdict: 'fail', verdictReason: 'the tool did not run: ' + reason }
427
+ }
428
+
429
+ /**
430
+ * Decide the verdict from finding counts. Any error-severity finding fails the review
431
+ * even though the tool ran successfully — that gap is exactly what `verdict` closes.
432
+ */
433
+ export function verdictOf(errors: number, warnings: number, firstCode?: string): VerdictSummary {
434
+ if (errors > 0) {
435
+ return {
436
+ verdict: 'fail',
437
+ verdictReason: String(errors) + ' error finding(s)' + (firstCode === undefined ? '' : ' (first: ' + firstCode + ')'),
438
+ }
439
+ }
440
+ if (warnings > 0) {
441
+ return { verdict: 'pass_with_findings', verdictReason: 'no error findings; ' + String(warnings) + ' warning finding(s)' }
442
+ }
443
+ return { verdict: 'pass', verdictReason: 'no error or warning findings' }
444
+ }
445
+
446
+ export function verdictOfFindings(findings: Array<{ code: string; severity: string }>): VerdictSummary {
447
+ const errors = findings.filter((finding) => finding.severity === 'error')
448
+ const warnings = findings.filter((finding) => finding.severity === 'warning')
449
+ return verdictOf(errors.length, warnings.length, errors[0]?.code)
450
+ }
451
+
452
+ /**
453
+ * Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
454
+ * `states[0]._note`; a metadata key's own children are not listed, since the subtree is
455
+ * metadata as a whole). The schema ignores these keys as annotation metadata; echoing
456
+ * them in the report makes the ignored metadata auditable instead of invisible.
457
+ */
458
+ export function metadataKeysOf(input: unknown): string[] {
459
+ const keys: string[] = []
460
+ const walk = (value: unknown, path: string): void => {
461
+ if (value === null || typeof value !== 'object') return
462
+ if (Array.isArray(value)) {
463
+ value.forEach((entry, index) => walk(entry, path + '[' + String(index) + ']'))
464
+ return
465
+ }
466
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
467
+ const child = path === '' ? key : path + '.' + key
468
+ if (key.startsWith('_')) keys.push(child)
469
+ walk(entry, child)
470
+ }
471
+ }
472
+ walk(input, '')
473
+ return keys.sort()
474
+ }
475
+
476
+ function withoutMetadata(value: unknown): unknown {
477
+ if (value === null || typeof value !== 'object') return value
478
+ if (Array.isArray(value)) return value.map((entry) => withoutMetadata(entry))
479
+ const out: Record<string, unknown> = {}
480
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
481
+ if (key.startsWith('_')) continue
482
+ out[key] = withoutMetadata(entry)
483
+ }
484
+ return out
485
+ }
486
+
487
+ /**
488
+ * The exact payload a hash spec hashes. Exported so the specification has one
489
+ * implementation and the documentation can be checked against it.
490
+ */
491
+ export function hashPayload(model: unknown, spec: HashSpec = DEFAULT_HASH_SPEC): unknown {
492
+ return spec === 'v1' ? withoutMetadata(model) : model
277
493
  }
278
494
 
279
495
  export function validateModel(input: unknown): { ok: true; model: LogicModelV1 } | { ok: false; errors: string[] } {
@@ -285,8 +501,12 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
285
501
  return { ok: false, errors: ['model: must be an object'] }
286
502
  }
287
503
  const root = input as Record<string, unknown>
504
+ // `_`-prefixed keys are annotation metadata (provenance, verification snapshots,
505
+ // extraction caveats). They are ignored here and excluded from the hash, so a model
506
+ // can carry its own archive record without a sidecar that drifts away from it.
507
+ // Everything else stays closed: a mistyped `sttes` must still be an error.
288
508
  const rejectUnknownKeys = (value: Record<string, unknown>, allowed: Set<string>, path: string): void => {
289
- const unexpected = Object.keys(value).filter((key) => !allowed.has(key))
509
+ const unexpected = Object.keys(value).filter((key) => !allowed.has(key) && !key.startsWith('_'))
290
510
  if (unexpected.length > 0) {
291
511
  bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + [...allowed].join(', ') + ')')
292
512
  }
@@ -422,7 +642,7 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
422
642
  // older reader may not know are skipped so they still fail on `.kind` alone.
423
643
  if (KNOWN_INVARIANT_KINDS.has(invariant.kind as string)) {
424
644
  const allowed = INVARIANT_KEYS[invariant.kind as string]
425
- const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key))
645
+ const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key) && !key.startsWith('_'))
426
646
  if (unexpected.length > 0) {
427
647
  bad(path + '.' + unexpected[0], 'unknown field for kind ' + String(invariant.kind) + ' (allowed: ' + [...allowed].join(', ') + ')')
428
648
  }
@@ -445,7 +665,7 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
445
665
  } else if ('state' in when) {
446
666
  const scope = when as Record<string, unknown>
447
667
  for (const key of Object.keys(scope)) {
448
- if (key !== 'state') bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)')
668
+ if (key !== 'state' && !key.startsWith('_')) bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)')
449
669
  }
450
670
  const state = scope.state
451
671
  if (typeof state !== 'string' || state.length === 0) bad(path + '.when.state', 'must be a non-empty string')
@@ -574,57 +794,59 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
574
794
  rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath)
575
795
  const stateIds = new Set<string>(Array.isArray(root.states) ? (root.states as StateSpec[]).map((state) => state.id) : [])
576
796
  const eventIds = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.event) : [])
577
- const fromEventGroups = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.from + '|' + transition.event) : [])
578
- if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
579
- bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description')
580
- } else {
581
- for (const [id, description] of Object.entries(narrative.states as Record<string, unknown>)) {
582
- if (!stateIds.has(id)) bad(narrativePath + '.states', 'references unknown state ' + id)
583
- if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.states.' + id, 'must be a non-empty string')
584
- }
585
- for (const id of stateIds) {
586
- if (typeof (narrative.states as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.states', 'missing description for state ' + id)
587
- }
797
+ // A narrative is allowed to cover part of the model: states first, events and
798
+ // scenarios later is the natural authoring order, and `narrativeCoverage` reports
799
+ // what is still missing instead of rejecting the file. What stays an error is a
800
+ // narrative that is *wrong* (unknown id, empty description, duplicate scenario) or
801
+ // empty — an empty block claims documentation that does not exist.
802
+ const declaredDimensions = (['states', 'events', 'scenarios'] as const).filter((key) => narrative[key] !== undefined)
803
+ if (declaredDimensions.length === 0) {
804
+ bad(narrativePath, 'declares no dimension: give at least one of states, events or scenarios (a partial narrative is fine)')
588
805
  }
589
- if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
590
- bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description')
591
- } else {
592
- for (const [id, description] of Object.entries(narrative.events as Record<string, unknown>)) {
593
- if (!eventIds.has(id)) bad(narrativePath + '.events', 'references unknown event ' + id)
594
- if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.events.' + id, 'must be a non-empty string')
595
- }
596
- for (const id of eventIds) {
597
- if (typeof (narrative.events as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.events', 'missing description for event ' + id)
806
+ if (narrative.states !== undefined) {
807
+ if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
808
+ bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description')
809
+ } else {
810
+ for (const [id, description] of Object.entries(narrative.states as Record<string, unknown>)) {
811
+ if (!stateIds.has(id)) bad(narrativePath + '.states', 'references unknown state ' + id)
812
+ if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.states.' + id, 'must be a non-empty string')
813
+ }
598
814
  }
599
815
  }
600
- if (!Array.isArray(narrative.scenarios)) {
601
- bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }')
602
- } else {
603
- const seen = new Set<string>()
604
- narrative.scenarios.forEach((entry, index) => {
605
- const scenarioPath = narrativePath + '.scenarios[' + index + ']'
606
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
607
- bad(scenarioPath, 'must be an object')
608
- return
609
- }
610
- const scenario = entry as Record<string, unknown>
611
- rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath)
612
- if (typeof scenario.from !== 'string' || scenario.from.length === 0) bad(scenarioPath + '.from', 'must be a non-empty string')
613
- else if (!stateIds.has(scenario.from)) bad(scenarioPath + '.from', 'unknown state ' + scenario.from)
614
- if (typeof scenario.event !== 'string' || scenario.event.length === 0) bad(scenarioPath + '.event', 'must be a non-empty string')
615
- else if (!eventIds.has(scenario.event)) bad(scenarioPath + '.event', 'unknown event ' + scenario.event)
616
- if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0) bad(scenarioPath + '.scenario', 'must be a non-empty string')
617
- const key = String(scenario.from) + '|' + String(scenario.event)
618
- if (seen.has(key)) bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')')
619
- seen.add(key)
620
- })
621
- for (const key of fromEventGroups) {
622
- if (!seen.has(key)) {
623
- const sep = key.indexOf('|')
624
- bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')')
816
+ if (narrative.events !== undefined) {
817
+ if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
818
+ bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description')
819
+ } else {
820
+ for (const [id, description] of Object.entries(narrative.events as Record<string, unknown>)) {
821
+ if (!eventIds.has(id)) bad(narrativePath + '.events', 'references unknown event ' + id)
822
+ if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.events.' + id, 'must be a non-empty string')
625
823
  }
626
824
  }
627
825
  }
826
+ if (narrative.scenarios !== undefined) {
827
+ if (!Array.isArray(narrative.scenarios)) {
828
+ bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }')
829
+ } else {
830
+ const seen = new Set<string>()
831
+ narrative.scenarios.forEach((entry, index) => {
832
+ const scenarioPath = narrativePath + '.scenarios[' + index + ']'
833
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
834
+ bad(scenarioPath, 'must be an object')
835
+ return
836
+ }
837
+ const scenario = entry as Record<string, unknown>
838
+ rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath)
839
+ if (typeof scenario.from !== 'string' || scenario.from.length === 0) bad(scenarioPath + '.from', 'must be a non-empty string')
840
+ else if (!stateIds.has(scenario.from)) bad(scenarioPath + '.from', 'unknown state ' + scenario.from)
841
+ if (typeof scenario.event !== 'string' || scenario.event.length === 0) bad(scenarioPath + '.event', 'must be a non-empty string')
842
+ else if (!eventIds.has(scenario.event)) bad(scenarioPath + '.event', 'unknown event ' + scenario.event)
843
+ if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0) bad(scenarioPath + '.scenario', 'must be a non-empty string')
844
+ const key = String(scenario.from) + '|' + String(scenario.event)
845
+ if (seen.has(key)) bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')')
846
+ seen.add(key)
847
+ })
848
+ }
849
+ }
628
850
  }
629
851
  }
630
852
  // Guard/update variable references were validated structurally; re-walk for references.
@@ -845,7 +1067,7 @@ function checkResult(id: string, name: string, findings: Finding[], detail: stri
845
1067
  }
846
1068
 
847
1069
  // ---------------------------------------------------------------------------
848
- // S1-S7
1070
+ // S1-S8
849
1071
  // ---------------------------------------------------------------------------
850
1072
 
851
1073
  function S1_reachability(model: LogicModelV1, exploration: Exploration): CheckResult {
@@ -1271,7 +1493,7 @@ function S7_invariants(model: LogicModelV1, options: NormalizedOptions): CheckRe
1271
1493
  }
1272
1494
 
1273
1495
  // ---------------------------------------------------------------------------
1274
- // A1-A7
1496
+ // A1-A14
1275
1497
  // ---------------------------------------------------------------------------
1276
1498
 
1277
1499
  function A1_unexpectedEvents(model: LogicModelV1): CheckResult {
@@ -2646,20 +2868,32 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2646
2868
  maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
2647
2869
  maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
2648
2870
  }
2871
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC
2872
+ const metadataKeys = metadataKeysOf(input)
2873
+ const metadata = metadataKeys.length === 0 ? {} : { metadataKeys }
2649
2874
  const validation = validateModel(input)
2650
2875
  if (!validation.ok) {
2876
+ const findings: Finding[] = validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message }))
2877
+ const modelCheck: CheckResult = {
2878
+ id: 'MODEL',
2879
+ name: 'Model Validation',
2880
+ status: 'fail',
2881
+ detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
2882
+ findings,
2883
+ }
2651
2884
  return {
2652
2885
  ok: false,
2886
+ ran: true,
2887
+ ...verdictOfFindings(findings),
2888
+ schema: REPORT_SCHEMAS.verify,
2653
2889
  schemaVersion: 1,
2890
+ hashSpec,
2654
2891
  modelHash: '',
2892
+ hashes: { hashSpec, modelHash: '' },
2893
+ ...metadata,
2655
2894
  summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
2656
- checks: [{
2657
- id: 'MODEL',
2658
- name: 'Model Validation',
2659
- status: 'fail',
2660
- detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
2661
- findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
2662
- }],
2895
+ checks: [modelCheck],
2896
+ nextSteps: verificationNextSteps([modelCheck], undefined),
2663
2897
  }
2664
2898
  }
2665
2899
  const model = validation.model
@@ -2692,10 +2926,18 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2692
2926
  if (options.beforeModel !== undefined) {
2693
2927
  const beforeValidation = validateModel(options.beforeModel)
2694
2928
  if (!beforeValidation.ok) {
2929
+ const beforeFindings: Finding[] = beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message }))
2930
+ const allFindings: Finding[] = [...checks.flatMap((check) => check.findings), ...beforeFindings]
2695
2931
  return {
2696
2932
  ok: false,
2933
+ ran: true,
2934
+ ...verdictOfFindings(allFindings),
2935
+ schema: REPORT_SCHEMAS.verify,
2697
2936
  schemaVersion: 1,
2698
- modelHash: modelHash(model),
2937
+ hashSpec,
2938
+ modelHash: modelHash(model, hashSpec),
2939
+ hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
2940
+ ...metadata,
2699
2941
  summary: {
2700
2942
  states: model.states.length,
2701
2943
  transitions: model.transitions.length,
@@ -2711,9 +2953,10 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2711
2953
  name: 'Before Model Validation',
2712
2954
  status: 'fail',
2713
2955
  detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
2714
- findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
2956
+ findings: beforeFindings,
2715
2957
  },
2716
2958
  ],
2959
+ nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
2717
2960
  }
2718
2961
  }
2719
2962
  const before = beforeValidation.model
@@ -2729,10 +2972,22 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2729
2972
  const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
2730
2973
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
2731
2974
  const coverageNotes = computeCoverageNotes(model)
2975
+ const narrativeCoverage = narrativeCoverageOf(model)
2732
2976
  return {
2733
2977
  ok: true,
2978
+ ran: true,
2979
+ ...verdictOfFindings(checks.flatMap((check) => check.findings)),
2980
+ schema: REPORT_SCHEMAS.verify,
2734
2981
  schemaVersion: 1,
2735
- modelHash: modelHash(model),
2982
+ hashSpec,
2983
+ modelHash: modelHash(model, hashSpec),
2984
+ hashes: {
2985
+ hashSpec,
2986
+ modelHash: modelHash(model, hashSpec),
2987
+ ...(comparison === undefined ? {} : { beforeModelHash: comparison.beforeModelHash, afterModelHash: comparison.afterModelHash }),
2988
+ },
2989
+ ...metadata,
2990
+ ...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
2736
2991
  summary: {
2737
2992
  states: model.states.length,
2738
2993
  transitions: model.transitions.length,
@@ -2742,6 +2997,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2742
2997
  truncated: exploration.truncated,
2743
2998
  },
2744
2999
  checks,
3000
+ nextSteps: verificationNextSteps(checks, narrativeCoverage),
2745
3001
  ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
2746
3002
  ...(comparison === undefined ? {} : { comparison }),
2747
3003
  ...(coverageNotes.length === 0 ? {} : { coverageNotes }),
@@ -2762,6 +3018,8 @@ export interface CompositionOptions {
2762
3018
  /** Events that require a synchronized multi-machine step (handshake). */
2763
3019
  rendezvous?: string[]
2764
3020
  maxStates?: number
3021
+ /** Hash specification used for the per-machine modelHash. Defaults to the current published spec (`v1`). */
3022
+ hashSpec?: HashSpec
2765
3023
  }
2766
3024
 
2767
3025
  export interface CompositionSummary {
@@ -2774,9 +3032,21 @@ export interface CompositionSummary {
2774
3032
  }
2775
3033
 
2776
3034
  export interface CompositionReport {
3035
+ /** Historical meaning: the composition has no error-severity finding. Read `verdict` for the review outcome. */
2777
3036
  ok: boolean
3037
+ ran: boolean
3038
+ verdict: Verdict
3039
+ verdictReason: string
3040
+ /** The versioned report contract this result follows. */
3041
+ schema: ReportSchema
3042
+ /** Which published specification the per-machine `modelHash` values follow. */
3043
+ hashSpec: HashSpec
3044
+ /** Every hash this report carries, in one place. */
3045
+ hashes: { hashSpec: HashSpec; machines: string[] }
2778
3046
  summary: CompositionSummary
2779
3047
  checks: CheckResult[]
3048
+ /** What to do next, derived from the findings — never empty. */
3049
+ nextSteps: string[]
2780
3050
  }
2781
3051
 
2782
3052
  interface CompositionNode {
@@ -2802,6 +3072,7 @@ interface CompositionMove {
2802
3072
  * least one is not terminal) and C2 rendezvous that can never fire.
2803
3073
  */
2804
3074
  export function runCompositionVerification(machinesInput: unknown[], options: CompositionOptions = {}): CompositionReport {
3075
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC
2805
3076
  const models: LogicModelV1[] = []
2806
3077
  const hashes: string[] = []
2807
3078
  const modelFindings: Finding[] = []
@@ -2811,7 +3082,7 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2811
3082
  modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'machine ' + index + ' invalid: ' + validation.errors.join('; ') })
2812
3083
  } else {
2813
3084
  models.push(validation.model)
2814
- hashes.push(modelHash(validation.model))
3085
+ hashes.push(modelHash(validation.model, hashSpec))
2815
3086
  }
2816
3087
  })
2817
3088
  const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }))
@@ -2821,8 +3092,14 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2821
3092
  }
2822
3093
  return {
2823
3094
  ok: false,
3095
+ ran: true,
3096
+ ...verdictOfFindings(modelFindings),
3097
+ schema: REPORT_SCHEMAS.compose,
3098
+ hashSpec,
3099
+ hashes: { hashSpec, machines: hashes },
2824
3100
  summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
2825
3101
  checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
3102
+ nextSteps: ['Fix the machine that failed validation, then re-run the composition.'],
2826
3103
  }
2827
3104
  }
2828
3105
  const rendezvous = new Set(options.rendezvous ?? [])
@@ -2881,6 +3158,17 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2881
3158
  let truncated = false
2882
3159
  const firedCount = new Map<string, number>()
2883
3160
  for (const event of rendezvous) firedCount.set(event, 0)
3161
+ // Per-machine enablement, collected while the state space is explored: a rendezvous
3162
+ // that never fires needs to say *which* machine never had it enabled, not just that
3163
+ // the handshake did not happen.
3164
+ const everEnabled = new Map<string, Set<number>>()
3165
+ const enabledStates = new Map<string, string[][]>()
3166
+ const coEnabledNodes = new Map<string, number>()
3167
+ for (const event of rendezvous) {
3168
+ everEnabled.set(event, new Set<number>())
3169
+ enabledStates.set(event, models.map(() => []))
3170
+ coEnabledNodes.set(event, 0)
3171
+ }
2884
3172
  const c1Findings: Finding[] = []
2885
3173
  while (queue.length > 0) {
2886
3174
  const node = queue.shift()!
@@ -2894,12 +3182,53 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2894
3182
  }
2895
3183
  const moves = compositionMoves(node)
2896
3184
  const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state))
3185
+ // Enablement census for C2: which machines could fire each rendezvous here.
3186
+ for (const event of rendezvous) {
3187
+ const declaring: number[] = []
3188
+ for (let i = 0; i < models.length; i++) if (machineEventSets[i].has(event)) declaring.push(i)
3189
+ if (declaring.length === 0) continue
3190
+ let allEnabled = declaring.length >= 2
3191
+ for (const i of declaring) {
3192
+ const enabled = !isTerminal(models[i], node.runtimes[i].state) && stepRuntime(models[i], node.runtimes[i], event).length > 0
3193
+ if (!enabled) { allEnabled = false; continue }
3194
+ everEnabled.get(event)?.add(i)
3195
+ const states = enabledStates.get(event)?.[i]
3196
+ if (states !== undefined && !states.includes(node.runtimes[i].state) && states.length < 8) states.push(node.runtimes[i].state)
3197
+ }
3198
+ if (allEnabled) coEnabledNodes.set(event, (coEnabledNodes.get(event) ?? 0) + 1)
3199
+ }
2897
3200
  if (moves.length === 0 && !allTerminal) {
3201
+ // Why nobody can advance, per machine: its state, its alphabet, and for every event
3202
+ // either the rendezvous partner that is not ready or the transition that does not fire.
3203
+ const perMachine = node.runtimes.map((runtime, i) => {
3204
+ const terminal = isTerminal(models[i], runtime.state)
3205
+ const blocked = terminal ? [] : allEvents(models[i]).map((event) => {
3206
+ const enabled = stepRuntime(models[i], runtime, event).length > 0
3207
+ // A rendezvous event is not fireable just because this machine has a transition:
3208
+ // it needs every declaring machine enabled at the same time. Check that first, or
3209
+ // the deadlock census would report "enabled" for a handshake nobody can complete.
3210
+ if (!rendezvous.has(event)) return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' }
3211
+ const declaring: number[] = []
3212
+ for (let j = 0; j < models.length; j++) if (machineEventSets[j].has(event)) declaring.push(j)
3213
+ if (declaring.length < 2) return { event, reason: 'rendezvous-needs-partner' }
3214
+ const partnersReady = declaring.every((j) => !isTerminal(models[j], node.runtimes[j].state)
3215
+ && stepRuntime(models[j], node.runtimes[j], event).length > 0)
3216
+ if (!partnersReady) return { event, reason: 'rendezvous-partner-not-ready' }
3217
+ return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' }
3218
+ })
3219
+ return { index: i, state: runtime.state, terminal, blocked }
3220
+ })
3221
+ const reasons = perMachine
3222
+ .filter((machine) => !machine.terminal)
3223
+ .map((machine) => 'machine ' + String(machine.index) + ' at ' + machine.state + ': ' + (machine.blocked.length === 0
3224
+ ? 'empty event alphabet'
3225
+ : machine.blocked.map((entry) => entry.event + ' (' + entry.reason + ')').join(', ')))
2898
3226
  c1Findings.push({
2899
3227
  code: 'C1_COMPOSITION_DEADLOCK',
2900
3228
  severity: 'error',
2901
3229
  message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
2902
- evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state) },
3230
+ detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | '),
3231
+ evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state), depth: node.path.length, perMachine, reasons },
2903
3232
  })
2904
3233
  continue
2905
3234
  }
@@ -2916,22 +3245,67 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2916
3245
  for (const event of rendezvous) {
2917
3246
  if (!allEventsSet.has(event)) continue
2918
3247
  if ((firedCount.get(event) ?? 0) === 0) {
3248
+ // Why the handshake never happens: who declares it, who ever had it enabled, and
3249
+ // whether the enabling states could ever coincide.
3250
+ const machines = models.map((model, index) => ({
3251
+ index,
3252
+ declares: machineEventSets[index].has(event),
3253
+ everEnabled: everEnabled.get(event)?.has(index) === true,
3254
+ enabledAt: enabledStates.get(event)?.[index] ?? [],
3255
+ }))
3256
+ const declaring = machines.filter((machine) => machine.declares)
3257
+ const neverEnabled = declaring.filter((machine) => !machine.everEnabled)
3258
+ const coEnabled = coEnabledNodes.get(event) ?? 0
3259
+ let reason: string
3260
+ if (declaring.length < 2) {
3261
+ reason = 'only ' + String(declaring.length) + ' machine(s) declare it (machine ' + declaring.map((machine) => machine.index).join(', ') + '); a handshake needs at least two'
3262
+ } else if (neverEnabled.length > 0) {
3263
+ reason = 'machine ' + neverEnabled.map((machine) => machine.index).join(', ') + ' declares it but never has it enabled (no transition, or the guard never holds)'
3264
+ } else {
3265
+ reason = 'every declaring machine enables it somewhere, but never at the same time (' + String(coEnabled) + ' composite state(s) had all of them ready)'
3266
+ }
3267
+ // A truncated exploration cannot prove that a handshake never happens: it may simply
3268
+ // not have reached the composite state where both machines are ready.
3269
+ 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.' : ''
2919
3270
  c2Findings.push({
2920
3271
  code: 'C2_RENDEZVOUS_NEVER_FIRES',
2921
3272
  severity: 'warning',
2922
- message: 'Rendezvous event ' + event + ' can never fire: fewer than two machines ever jointly enable it.',
3273
+ message: 'Rendezvous event ' + event + ' can never fire: ' + reason + '.' + caveat,
3274
+ detail: machines.map((machine) => 'machine ' + String(machine.index) + (machine.declares ? ' declares' : ' does not declare')
3275
+ + (machine.everEnabled ? ', enabled at ' + (machine.enabledAt.length === 0 ? 'no visited state' : machine.enabledAt.join('/')) : ', never enabled')).join(' | '),
3276
+ evidence: { event, machines, declaring: declaring.map((machine) => machine.index), coEnabledNodes: coEnabled, reason, truncated },
2923
3277
  })
2924
3278
  }
2925
3279
  }
2926
3280
  const errors = c1Findings.length
2927
3281
  const warnings = c2Findings.length
3282
+ const c1Detail = c1Findings.length > 0
3283
+ ? 'Composition deadlocks: ' + String(c1Findings.length)
3284
+ : truncated
3285
+ ? 'No composition deadlock found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
3286
+ : 'No composition deadlock reachable'
3287
+ const c2Detail = c2Findings.length > 0
3288
+ ? 'Rendezvous warnings: ' + String(c2Findings.length)
3289
+ : truncated
3290
+ ? 'All rendezvous events fired, but the search was truncated at ' + String(maxStates) + ' composite states'
3291
+ : 'All rendezvous events can fire'
2928
3292
  const checks: CheckResult[] = [
2929
- checkResult('C1', 'Composition Deadlock', c1Findings, c1Findings.length === 0 ? 'No composition deadlock reachable' : 'Composition deadlocks: ' + c1Findings.length),
2930
- checkResult('C2', 'Rendezvous Sync', c2Findings, c2Findings.length === 0 ? 'All rendezvous events can fire' : 'Rendezvous warnings: ' + c2Findings.length),
3293
+ checkResult('C1', 'Composition Deadlock', c1Findings, c1Detail),
3294
+ checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
2931
3295
  ]
3296
+ const nextSteps: string[] = []
3297
+ if (errors > 0) 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.')
3298
+ if (warnings > 0) nextSteps.push('Review the warning findings: a rendezvous event that can never fire means the handshake is declared but unreachable.')
3299
+ nextSteps.push('Re-run the composition after the change; every machine must be able to advance, or explain why it is terminal.')
2932
3300
  return {
2933
3301
  ok: errors === 0,
3302
+ ran: true,
3303
+ ...verdictOf(errors, warnings, c1Findings[0]?.code ?? c2Findings[0]?.code),
3304
+ schema: REPORT_SCHEMAS.compose,
3305
+ hashSpec,
3306
+ hashes: { hashSpec, machines: hashes },
2934
3307
  summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
2935
3308
  checks,
3309
+ nextSteps,
2936
3310
  }
2937
3311
  }