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/src/engine.ts CHANGED
@@ -154,7 +154,7 @@ export type InvariantSpec =
154
154
  when?: InvariantWhen
155
155
  }
156
156
  | { id: string; description: string; kind: 'event-before-state'; event: string; state: string }
157
- | { id: string; description: string; kind: 'leads-to'; from: string; to: string }
157
+ | { id: string; description: string; kind: 'leads-to'; from: string; to: string | string[] }
158
158
  | { id: string; description: string; kind: 'sequence'; events: string[] }
159
159
  | { id: string; description: string; kind: 'atomicity'; events: string[]; commit: string; rollback?: string }
160
160
  | { id: string; description: string; kind: 'budget'; budget: number }
@@ -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')
@@ -469,8 +689,27 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
469
689
  } else if (invariant.kind === 'leads-to') {
470
690
  if (typeof invariant.from !== 'string' || invariant.from.length === 0) bad(path + '.from', 'must be a non-empty string')
471
691
  else if (!invariantStateIds.has(invariant.from)) bad(path + '.from', 'references unknown state ' + invariant.from)
472
- if (typeof invariant.to !== 'string' || invariant.to.length === 0) bad(path + '.to', 'must be a non-empty string')
473
- else if (!invariantStateIds.has(invariant.to)) bad(path + '.to', 'references unknown state ' + invariant.to)
692
+ // `to` is one state id or a target set. A set with a duplicate member is a
693
+ // model-authoring slip that would otherwise give one meaning two hashes, so it
694
+ // is rejected rather than silently deduplicated.
695
+ if (typeof invariant.to === 'string') {
696
+ if (invariant.to.length === 0) bad(path + '.to', 'must be a non-empty string')
697
+ else if (!invariantStateIds.has(invariant.to)) bad(path + '.to', 'references unknown state ' + invariant.to)
698
+ } else if (Array.isArray(invariant.to)) {
699
+ if (invariant.to.length === 0) bad(path + '.to', 'must be a non-empty array of state ids')
700
+ else {
701
+ const targets = new Set<string>()
702
+ invariant.to.forEach((target, targetIndex) => {
703
+ const at = path + '.to[' + targetIndex + ']'
704
+ if (typeof target !== 'string' || target.length === 0) bad(at, 'must be a non-empty string')
705
+ else if (!invariantStateIds.has(target)) bad(at, 'references unknown state ' + target)
706
+ else if (targets.has(target)) bad(at, 'duplicates state ' + target + ' in the target set')
707
+ else targets.add(target)
708
+ })
709
+ }
710
+ } else {
711
+ bad(path + '.to', 'must be a state id or a non-empty array of state ids')
712
+ }
474
713
  } else if (invariant.kind === 'sequence') {
475
714
  if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
476
715
  else invariant.events.forEach((event, eventIndex) => {
@@ -555,57 +794,59 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
555
794
  rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath)
556
795
  const stateIds = new Set<string>(Array.isArray(root.states) ? (root.states as StateSpec[]).map((state) => state.id) : [])
557
796
  const eventIds = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.event) : [])
558
- const fromEventGroups = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.from + '|' + transition.event) : [])
559
- if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
560
- bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description')
561
- } else {
562
- for (const [id, description] of Object.entries(narrative.states as Record<string, unknown>)) {
563
- if (!stateIds.has(id)) bad(narrativePath + '.states', 'references unknown state ' + id)
564
- if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.states.' + id, 'must be a non-empty string')
565
- }
566
- for (const id of stateIds) {
567
- if (typeof (narrative.states as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.states', 'missing description for state ' + id)
568
- }
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)')
569
805
  }
570
- if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
571
- bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description')
572
- } else {
573
- for (const [id, description] of Object.entries(narrative.events as Record<string, unknown>)) {
574
- if (!eventIds.has(id)) bad(narrativePath + '.events', 'references unknown event ' + id)
575
- if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.events.' + id, 'must be a non-empty string')
576
- }
577
- for (const id of eventIds) {
578
- 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
+ }
579
814
  }
580
815
  }
581
- if (!Array.isArray(narrative.scenarios)) {
582
- bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }')
583
- } else {
584
- const seen = new Set<string>()
585
- narrative.scenarios.forEach((entry, index) => {
586
- const scenarioPath = narrativePath + '.scenarios[' + index + ']'
587
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
588
- bad(scenarioPath, 'must be an object')
589
- return
590
- }
591
- const scenario = entry as Record<string, unknown>
592
- rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath)
593
- if (typeof scenario.from !== 'string' || scenario.from.length === 0) bad(scenarioPath + '.from', 'must be a non-empty string')
594
- else if (!stateIds.has(scenario.from)) bad(scenarioPath + '.from', 'unknown state ' + scenario.from)
595
- if (typeof scenario.event !== 'string' || scenario.event.length === 0) bad(scenarioPath + '.event', 'must be a non-empty string')
596
- else if (!eventIds.has(scenario.event)) bad(scenarioPath + '.event', 'unknown event ' + scenario.event)
597
- if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0) bad(scenarioPath + '.scenario', 'must be a non-empty string')
598
- const key = String(scenario.from) + '|' + String(scenario.event)
599
- if (seen.has(key)) bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')')
600
- seen.add(key)
601
- })
602
- for (const key of fromEventGroups) {
603
- if (!seen.has(key)) {
604
- const sep = key.indexOf('|')
605
- 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')
606
823
  }
607
824
  }
608
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
+ }
609
850
  }
610
851
  }
611
852
  // Guard/update variable references were validated structurally; re-walk for references.
@@ -826,7 +1067,7 @@ function checkResult(id: string, name: string, findings: Finding[], detail: stri
826
1067
  }
827
1068
 
828
1069
  // ---------------------------------------------------------------------------
829
- // S1-S7
1070
+ // S1-S8
830
1071
  // ---------------------------------------------------------------------------
831
1072
 
832
1073
  function S1_reachability(model: LogicModelV1, exploration: Exploration): CheckResult {
@@ -1108,6 +1349,96 @@ function shortestViolationForInvariant(model: LogicModelV1, options: NormalizedO
1108
1349
  return undefined
1109
1350
  }
1110
1351
 
1352
+ /**
1353
+ * Whether an invariant asserts a property of whole runs rather than of a single
1354
+ * runtime state. These kinds cannot be decided by a state predicate, so they are not
1355
+ * part of `shortestViolationForInvariant` (and therefore not of S7), which owns the
1356
+ * states; A9-A13 own the verdicts, and `pathPropertyViolation` below makes the same
1357
+ * verdicts available to A7 and D2.
1358
+ */
1359
+ function isPathPropertyInvariant(invariant: InvariantSpec): boolean {
1360
+ return invariant.kind === 'leads-to'
1361
+ || invariant.kind === 'sequence'
1362
+ || invariant.kind === 'atomicity'
1363
+ || invariant.kind === 'budget'
1364
+ || invariant.kind === 'probability'
1365
+ }
1366
+
1367
+ /** Whether a probability invariant's bound fails for the computed hit probability. */
1368
+ function probabilityViolated(invariant: Extract<InvariantSpec, { kind: 'probability' }>, probability: number): boolean {
1369
+ const eps = 1e-9
1370
+ if (invariant.op === '>=') return probability < invariant.p - eps
1371
+ if (invariant.op === '>') return probability <= invariant.p + eps
1372
+ if (invariant.op === '<=') return probability > invariant.p + eps
1373
+ return probability >= invariant.p - eps
1374
+ }
1375
+
1376
+ /** Every runtime state reachable from init, capped like the other searches. */
1377
+ function reachableRuntimeStates(model: LogicModelV1, options: NormalizedOptions): RuntimeState[] {
1378
+ const init = initialState(model)
1379
+ const visited = new Set<string>([runtimeKey(init)])
1380
+ const reached: RuntimeState[] = [init]
1381
+ const queue: RuntimeState[] = [init]
1382
+ let steps = 0
1383
+ while (queue.length > 0) {
1384
+ if (++steps > options.maxStates) break
1385
+ const current = queue.shift()!
1386
+ for (const event of allEvents(model)) {
1387
+ for (const next of stepRuntime(model, current, event)) {
1388
+ const key = runtimeKey(next)
1389
+ if (visited.has(key)) continue
1390
+ visited.add(key)
1391
+ reached.push(next)
1392
+ queue.push(next)
1393
+ }
1394
+ }
1395
+ }
1396
+ return reached
1397
+ }
1398
+
1399
+ /**
1400
+ * Violation of a path-property invariant, or undefined when it holds. Each kind
1401
+ * delegates to the search its dedicated probe uses (A9 leads-to, A10 sequence, A11
1402
+ * atomicity, A12 budget, A13 probability), so a verdict reported here can never
1403
+ * disagree with the probe that owns the kind.
1404
+ */
1405
+ function pathPropertyViolation(model: LogicModelV1, options: NormalizedOptions, invariant: InvariantSpec): InvariantViolation | undefined {
1406
+ if (invariant.kind === 'leads-to') {
1407
+ const targets = new Set(leadsToTargets(invariant))
1408
+ const label = leadsToTargetLabel(invariant)
1409
+ for (const runtime of reachableRuntimeStates(model, options)) {
1410
+ if (runtime.state !== invariant.from) continue
1411
+ const bad = findLeadsToBadPath(model, runtime, targets, label)
1412
+ if (bad !== undefined) return { invariant, path: bad.path, reason: bad.reason }
1413
+ }
1414
+ return undefined
1415
+ }
1416
+ if (invariant.kind === 'sequence') return findSequenceViolation(model, options, invariant)
1417
+ if (invariant.kind === 'atomicity') return findAtomicityViolation(model, options, invariant)
1418
+ if (invariant.kind === 'budget') {
1419
+ const violation = findBudgetViolation(model, options, invariant)
1420
+ if (violation === undefined) return undefined
1421
+ return {
1422
+ invariant,
1423
+ path: violation.path,
1424
+ reason: violation.unbounded
1425
+ ? 'a reachable cycle keeps accumulating cost, so no finite budget holds'
1426
+ : 'a run accumulates cost ' + String(violation.cost) + ', above the declared budget ' + String(invariant.budget),
1427
+ }
1428
+ }
1429
+ if (invariant.kind === 'probability') {
1430
+ const { probability, converged } = computeHitProbability(model, options, invariant.target)
1431
+ if (!probabilityViolated(invariant, probability)) return undefined
1432
+ return {
1433
+ invariant,
1434
+ path: [],
1435
+ reason: 'P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + String(invariant.p)
1436
+ + (converged ? '' : ' (value iteration did not converge)'),
1437
+ }
1438
+ }
1439
+ return undefined
1440
+ }
1441
+
1111
1442
  function shortestEventBeforeStateViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'event-before-state' }>): InvariantViolation | undefined {
1112
1443
  const init = initialState(model)
1113
1444
  const seenInitially = false
@@ -1138,10 +1469,12 @@ function shortestEventBeforeStateViolation(model: LogicModelV1, options: Normali
1138
1469
  return undefined
1139
1470
  }
1140
1471
 
1141
- function runInvariants(model: LogicModelV1, options: NormalizedOptions): InvariantViolation[] {
1472
+ function runInvariants(model: LogicModelV1, options: NormalizedOptions, includePathProperties = false): InvariantViolation[] {
1142
1473
  const violations: InvariantViolation[] = []
1143
1474
  for (const invariant of model.invariants ?? []) {
1144
- const violation = shortestViolationForInvariant(model, options, invariant)
1475
+ const violation = includePathProperties && isPathPropertyInvariant(invariant)
1476
+ ? pathPropertyViolation(model, options, invariant)
1477
+ : shortestViolationForInvariant(model, options, invariant)
1145
1478
  if (violation !== undefined) violations.push(violation)
1146
1479
  }
1147
1480
  return violations
@@ -1160,7 +1493,7 @@ function S7_invariants(model: LogicModelV1, options: NormalizedOptions): CheckRe
1160
1493
  }
1161
1494
 
1162
1495
  // ---------------------------------------------------------------------------
1163
- // A1-A7
1496
+ // A1-A14
1164
1497
  // ---------------------------------------------------------------------------
1165
1498
 
1166
1499
  function A1_unexpectedEvents(model: LogicModelV1): CheckResult {
@@ -1507,14 +1840,27 @@ function A6_resourceInjection(model: LogicModelV1): CheckResult {
1507
1840
  }
1508
1841
 
1509
1842
  function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1510
- const violations = runInvariants(model, options)
1511
- const findings: Finding[] = violations.map((violation) => ({
1512
- code: 'A7_SHORTEST_COUNTEREXAMPLE',
1513
- severity: 'warning',
1514
- message: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + violation.path.length + (violation.path.length === 0 ? ' (initial state)' : ''),
1515
- path: violation.path,
1516
- evidence: { invariant: violation.invariant.id },
1517
- }))
1843
+ // Path-property invariants are included: "for any invariant that fails" is this
1844
+ // check's contract, and a check that silently skips five of the eight kinds cannot
1845
+ // honour it. The searches for the state kinds, for sequence, atomicity and budget
1846
+ // are breadth-first, so their first witness is the shortest one; the leads-to walk
1847
+ // and the probability bound have no such guarantee, and their message says what it
1848
+ // found instead of claiming minimality.
1849
+ const violations = runInvariants(model, options, true)
1850
+ const findings: Finding[] = violations.map((violation) => {
1851
+ const pathLength = violation.path.length
1852
+ const message = isPathPropertyInvariant(violation.invariant)
1853
+ ? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
1854
+ + (pathLength === 0 ? '' : ' (witness path length: ' + pathLength + ')')
1855
+ : 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + pathLength + (pathLength === 0 ? ' (initial state)' : '')
1856
+ return {
1857
+ code: 'A7_SHORTEST_COUNTEREXAMPLE',
1858
+ severity: 'warning' as const,
1859
+ message,
1860
+ path: violation.path,
1861
+ evidence: { invariant: violation.invariant.id },
1862
+ }
1863
+ })
1518
1864
  return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length)
1519
1865
  }
1520
1866
 
@@ -1595,6 +1941,25 @@ function mapInvariantForComparison(invariant: InvariantSpec, mapping: Record<str
1595
1941
  }
1596
1942
  return mapped
1597
1943
  }
1944
+ if (invariant.kind === 'leads-to') {
1945
+ // A target set maps member by member; a rename that drops a state is reported by
1946
+ // D2's own mapped-state check rather than silently changing the property.
1947
+ return {
1948
+ ...invariant,
1949
+ id: invariant.id + ':before',
1950
+ description: invariant.description + ' (from BEFORE)',
1951
+ from: mapStateId(mapping, invariant.from),
1952
+ to: typeof invariant.to === 'string' ? mapStateId(mapping, invariant.to) : invariant.to.map((state) => mapStateId(mapping, state)),
1953
+ }
1954
+ }
1955
+ if (invariant.kind === 'probability') {
1956
+ return {
1957
+ ...invariant,
1958
+ id: invariant.id + ':before',
1959
+ description: invariant.description + ' (from BEFORE)',
1960
+ target: mapStateId(mapping, invariant.target),
1961
+ }
1962
+ }
1598
1963
  return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' }
1599
1964
  }
1600
1965
 
@@ -1634,8 +1999,36 @@ function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, optio
1634
1999
  })
1635
2000
  continue
1636
2001
  }
2002
+ } else if (mapped.kind === 'leads-to') {
2003
+ // The state references of a path-property invariant must follow stateMapping too,
2004
+ // otherwise a pure rename searches for a state AFTER does not declare.
2005
+ for (const state of [mapped.from, ...leadsToTargets(mapped)]) {
2006
+ if (!afterStates.has(state)) {
2007
+ findings.push({
2008
+ code: 'D2_MAPPED_STATE_MISSING',
2009
+ severity: 'warning',
2010
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
2011
+ evidence: { invariant: invariant.id, state },
2012
+ })
2013
+ }
2014
+ }
2015
+ } else if (mapped.kind === 'probability') {
2016
+ if (!afterStates.has(mapped.target)) {
2017
+ findings.push({
2018
+ code: 'D2_MAPPED_STATE_MISSING',
2019
+ severity: 'warning',
2020
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.target + ', which is not declared in AFTER.',
2021
+ evidence: { invariant: invariant.id, state: mapped.target },
2022
+ })
2023
+ }
1637
2024
  }
1638
- const violation = shortestViolationForInvariant(after, options, mapped)
2025
+ // Every invariant the engine can decide is re-decided against AFTER. Path-property
2026
+ // kinds go through the same searches their dedicated probes use, so D2 can no
2027
+ // longer report "all BEFORE invariants continue to hold" while A9-A13 fail on the
2028
+ // very same model.
2029
+ const violation = isPathPropertyInvariant(mapped)
2030
+ ? pathPropertyViolation(after, options, mapped)
2031
+ : shortestViolationForInvariant(after, options, mapped)
1639
2032
  if (violation !== undefined) {
1640
2033
  findings.push({
1641
2034
  code: 'D2_INVARIANT_REGRESSION',
@@ -1825,15 +2218,29 @@ function S8_monotonicVariables(model: LogicModelV1): CheckResult {
1825
2218
  }
1826
2219
 
1827
2220
  /**
1828
- * Find a run from `start` that never reaches `target`, or return undefined when
1829
- * every run does. The property is universal (see the A9 row in `SKILL.md`): one
1830
- * branch that loops forever, or that stops before the target, refutes it.
2221
+ * Target states of a `leads-to` invariant. The schema accepts one state id or a
2222
+ * non-empty array of them, so "reach any of these" needs no separate invariant kind.
2223
+ */
2224
+ function leadsToTargets(invariant: Extract<InvariantSpec, { kind: 'leads-to' }>): string[] {
2225
+ return typeof invariant.to === 'string' ? [invariant.to] : invariant.to
2226
+ }
2227
+
2228
+ /** Target list as findings print it. A single target renders as its bare id, so existing messages stay byte-identical. */
2229
+ function leadsToTargetLabel(invariant: Extract<InvariantSpec, { kind: 'leads-to' }>): string {
2230
+ return leadsToTargets(invariant).join(', ')
2231
+ }
2232
+
2233
+ /**
2234
+ * Find a run from `start` that reaches none of `targets`, or return undefined when
2235
+ * every run reaches at least one of them. The property is universal (see the A9 row
2236
+ * in `SKILL.md`): one branch that loops forever, or that stops before any target,
2237
+ * refutes it.
1831
2238
  *
1832
- * The search is a depth-first walk of the run graph with three colours, and target
1833
- * runs are success leaves that are never expanded:
2239
+ * The search is a depth-first walk of the run graph with three colours, and runs at a
2240
+ * target are success leaves that are never expanded:
1834
2241
  *
1835
2242
  * - a node reached while it is on the current walk (GRAY) closes a cycle that avoids
1836
- * the target, so some run never reaches it;
2243
+ * every target, so some run never reaches one;
1837
2244
  * - a node with no outgoing step at all stops the machine where it stands, which is
1838
2245
  * the same violation for a different reason;
1839
2246
  * - a node whose walk completed without a violation is BLACK, and reaching it again
@@ -1844,8 +2251,8 @@ function S8_monotonicVariables(model: LogicModelV1): CheckResult {
1844
2251
  * A run is identified by its state plus its variable values, so the same state id
1845
2252
  * with different values is a different node.
1846
2253
  */
1847
- function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: string): { path: PathStep[]; reason: string } | undefined {
1848
- if (start.state === target) return undefined
2254
+ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: Set<string>, label: string): { path: PathStep[]; reason: string } | undefined {
2255
+ if (targets.has(start.state)) return undefined
1849
2256
  const GRAY = 1
1850
2257
  const BLACK = 2
1851
2258
  interface LeadFrame {
@@ -1866,7 +2273,7 @@ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: st
1866
2273
  for (const next of stepRuntime(model, frame.runtime, event)) nexts.push({ next, event })
1867
2274
  }
1868
2275
  frame.nexts = nexts
1869
- if (nexts.length === 0) return { path: frame.path, reason: 'Dead end before target ' + target }
2276
+ if (nexts.length === 0) return { path: frame.path, reason: 'Dead end before target ' + label }
1870
2277
  }
1871
2278
  if (frame.index >= frame.nexts.length) {
1872
2279
  color.set(runtimeKey(frame.runtime), BLACK)
@@ -1876,15 +2283,15 @@ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: st
1876
2283
  const { next, event } = frame.nexts[frame.index]
1877
2284
  frame.index += 1
1878
2285
  const step: PathStep = { from: frame.runtime.state, event, to: next.state }
1879
- if (next.state === target) continue
2286
+ if (targets.has(next.state)) continue
1880
2287
  const key = runtimeKey(next)
1881
2288
  const seen = color.get(key)
1882
- if (seen === GRAY) return { path: [...frame.path, step], reason: 'Cycle avoids target ' + target }
2289
+ if (seen === GRAY) return { path: [...frame.path, step], reason: 'Cycle avoids target ' + label }
1883
2290
  if (seen === BLACK) continue
1884
2291
  color.set(key, GRAY)
1885
2292
  stack.push({ runtime: next, path: [...frame.path, step], nexts: undefined, index: 0 })
1886
2293
  }
1887
- // Every branch either reached the target or rejoined a branch that did.
2294
+ // Every branch either reached a target or rejoined a branch that did.
1888
2295
  return undefined
1889
2296
  }
1890
2297
 
@@ -1892,9 +2299,11 @@ function A9_leadsTo(model: LogicModelV1, exploration: Exploration): CheckResult
1892
2299
  const findings: Finding[] = []
1893
2300
  for (const invariant of model.invariants ?? []) {
1894
2301
  if (invariant.kind !== 'leads-to') continue
2302
+ const targets = new Set(leadsToTargets(invariant))
2303
+ const label = leadsToTargetLabel(invariant)
1895
2304
  for (const runtime of exploration.reachable) {
1896
2305
  if (runtime.state !== invariant.from) continue
1897
- const bad = findLeadsToBadPath(model, runtime, invariant.to)
2306
+ const bad = findLeadsToBadPath(model, runtime, targets, label)
1898
2307
  if (bad !== undefined) {
1899
2308
  findings.push({
1900
2309
  code: 'A9_LEADS_TO_VIOLATION',
@@ -2318,16 +2727,10 @@ function computeHitProbability(model: LogicModelV1, options: NormalizedOptions,
2318
2727
 
2319
2728
  function A13_probability(model: LogicModelV1, options: NormalizedOptions): CheckResult {
2320
2729
  const findings: Finding[] = []
2321
- const eps = 1e-9
2322
2730
  for (const invariant of model.invariants ?? []) {
2323
2731
  if (invariant.kind !== 'probability') continue
2324
2732
  const { probability, converged } = computeHitProbability(model, options, invariant.target)
2325
- let violated = false
2326
- if (invariant.op === '>=') violated = probability < invariant.p - eps
2327
- else if (invariant.op === '>') violated = probability <= invariant.p + eps
2328
- else if (invariant.op === '<=') violated = probability > invariant.p + eps
2329
- else violated = probability >= invariant.p - eps
2330
- if (violated) {
2733
+ if (probabilityViolated(invariant, probability)) {
2331
2734
  findings.push({
2332
2735
  code: 'A13_PROBABILITY_VIOLATION',
2333
2736
  severity: 'error',
@@ -2465,20 +2868,32 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2465
2868
  maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
2466
2869
  maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
2467
2870
  }
2871
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC
2872
+ const metadataKeys = metadataKeysOf(input)
2873
+ const metadata = metadataKeys.length === 0 ? {} : { metadataKeys }
2468
2874
  const validation = validateModel(input)
2469
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
+ }
2470
2884
  return {
2471
2885
  ok: false,
2886
+ ran: true,
2887
+ ...verdictOfFindings(findings),
2888
+ schema: REPORT_SCHEMAS.verify,
2472
2889
  schemaVersion: 1,
2890
+ hashSpec,
2473
2891
  modelHash: '',
2892
+ hashes: { hashSpec, modelHash: '' },
2893
+ ...metadata,
2474
2894
  summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
2475
- checks: [{
2476
- id: 'MODEL',
2477
- name: 'Model Validation',
2478
- status: 'fail',
2479
- detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
2480
- findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
2481
- }],
2895
+ checks: [modelCheck],
2896
+ nextSteps: verificationNextSteps([modelCheck], undefined),
2482
2897
  }
2483
2898
  }
2484
2899
  const model = validation.model
@@ -2511,10 +2926,18 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2511
2926
  if (options.beforeModel !== undefined) {
2512
2927
  const beforeValidation = validateModel(options.beforeModel)
2513
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]
2514
2931
  return {
2515
2932
  ok: false,
2933
+ ran: true,
2934
+ ...verdictOfFindings(allFindings),
2935
+ schema: REPORT_SCHEMAS.verify,
2516
2936
  schemaVersion: 1,
2517
- modelHash: modelHash(model),
2937
+ hashSpec,
2938
+ modelHash: modelHash(model, hashSpec),
2939
+ hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
2940
+ ...metadata,
2518
2941
  summary: {
2519
2942
  states: model.states.length,
2520
2943
  transitions: model.transitions.length,
@@ -2530,9 +2953,10 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2530
2953
  name: 'Before Model Validation',
2531
2954
  status: 'fail',
2532
2955
  detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
2533
- findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
2956
+ findings: beforeFindings,
2534
2957
  },
2535
2958
  ],
2959
+ nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
2536
2960
  }
2537
2961
  }
2538
2962
  const before = beforeValidation.model
@@ -2548,10 +2972,22 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2548
2972
  const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
2549
2973
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
2550
2974
  const coverageNotes = computeCoverageNotes(model)
2975
+ const narrativeCoverage = narrativeCoverageOf(model)
2551
2976
  return {
2552
2977
  ok: true,
2978
+ ran: true,
2979
+ ...verdictOfFindings(checks.flatMap((check) => check.findings)),
2980
+ schema: REPORT_SCHEMAS.verify,
2553
2981
  schemaVersion: 1,
2554
- 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 }),
2555
2991
  summary: {
2556
2992
  states: model.states.length,
2557
2993
  transitions: model.transitions.length,
@@ -2561,6 +2997,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2561
2997
  truncated: exploration.truncated,
2562
2998
  },
2563
2999
  checks,
3000
+ nextSteps: verificationNextSteps(checks, narrativeCoverage),
2564
3001
  ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
2565
3002
  ...(comparison === undefined ? {} : { comparison }),
2566
3003
  ...(coverageNotes.length === 0 ? {} : { coverageNotes }),
@@ -2581,6 +3018,8 @@ export interface CompositionOptions {
2581
3018
  /** Events that require a synchronized multi-machine step (handshake). */
2582
3019
  rendezvous?: string[]
2583
3020
  maxStates?: number
3021
+ /** Hash specification used for the per-machine modelHash. Defaults to the current published spec (`v1`). */
3022
+ hashSpec?: HashSpec
2584
3023
  }
2585
3024
 
2586
3025
  export interface CompositionSummary {
@@ -2593,9 +3032,21 @@ export interface CompositionSummary {
2593
3032
  }
2594
3033
 
2595
3034
  export interface CompositionReport {
3035
+ /** Historical meaning: the composition has no error-severity finding. Read `verdict` for the review outcome. */
2596
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[] }
2597
3046
  summary: CompositionSummary
2598
3047
  checks: CheckResult[]
3048
+ /** What to do next, derived from the findings — never empty. */
3049
+ nextSteps: string[]
2599
3050
  }
2600
3051
 
2601
3052
  interface CompositionNode {
@@ -2621,6 +3072,7 @@ interface CompositionMove {
2621
3072
  * least one is not terminal) and C2 rendezvous that can never fire.
2622
3073
  */
2623
3074
  export function runCompositionVerification(machinesInput: unknown[], options: CompositionOptions = {}): CompositionReport {
3075
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC
2624
3076
  const models: LogicModelV1[] = []
2625
3077
  const hashes: string[] = []
2626
3078
  const modelFindings: Finding[] = []
@@ -2630,7 +3082,7 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2630
3082
  modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'machine ' + index + ' invalid: ' + validation.errors.join('; ') })
2631
3083
  } else {
2632
3084
  models.push(validation.model)
2633
- hashes.push(modelHash(validation.model))
3085
+ hashes.push(modelHash(validation.model, hashSpec))
2634
3086
  }
2635
3087
  })
2636
3088
  const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }))
@@ -2640,8 +3092,14 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2640
3092
  }
2641
3093
  return {
2642
3094
  ok: false,
3095
+ ran: true,
3096
+ ...verdictOfFindings(modelFindings),
3097
+ schema: REPORT_SCHEMAS.compose,
3098
+ hashSpec,
3099
+ hashes: { hashSpec, machines: hashes },
2643
3100
  summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
2644
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.'],
2645
3103
  }
2646
3104
  }
2647
3105
  const rendezvous = new Set(options.rendezvous ?? [])
@@ -2700,6 +3158,17 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2700
3158
  let truncated = false
2701
3159
  const firedCount = new Map<string, number>()
2702
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
+ }
2703
3172
  const c1Findings: Finding[] = []
2704
3173
  while (queue.length > 0) {
2705
3174
  const node = queue.shift()!
@@ -2713,12 +3182,53 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2713
3182
  }
2714
3183
  const moves = compositionMoves(node)
2715
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
+ }
2716
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(', ')))
2717
3226
  c1Findings.push({
2718
3227
  code: 'C1_COMPOSITION_DEADLOCK',
2719
3228
  severity: 'error',
2720
3229
  message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
2721
- 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 },
2722
3232
  })
2723
3233
  continue
2724
3234
  }
@@ -2735,22 +3245,67 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2735
3245
  for (const event of rendezvous) {
2736
3246
  if (!allEventsSet.has(event)) continue
2737
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.' : ''
2738
3270
  c2Findings.push({
2739
3271
  code: 'C2_RENDEZVOUS_NEVER_FIRES',
2740
3272
  severity: 'warning',
2741
- 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 },
2742
3277
  })
2743
3278
  }
2744
3279
  }
2745
3280
  const errors = c1Findings.length
2746
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'
2747
3292
  const checks: CheckResult[] = [
2748
- checkResult('C1', 'Composition Deadlock', c1Findings, c1Findings.length === 0 ? 'No composition deadlock reachable' : 'Composition deadlocks: ' + c1Findings.length),
2749
- 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),
2750
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.')
2751
3300
  return {
2752
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 },
2753
3307
  summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
2754
3308
  checks,
3309
+ nextSteps,
2755
3310
  }
2756
3311
  }