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.
- package/README.en-US.md +79 -14
- package/README.md +79 -14
- package/lib/baseline.js +142 -0
- package/lib/client.js +56 -1
- package/lib/compose-tool.js +1 -1
- package/lib/concurrency-tool.js +1 -1
- package/lib/concurrency.js +14 -0
- package/lib/data-engine.js +42 -8
- package/lib/engine.js +609 -118
- package/lib/export-tool.js +27 -2
- package/lib/granularity.js +237 -0
- package/lib/index.js +63 -32
- package/lib/report-diff-tool.js +39 -0
- package/lib/structure-tool.js +64 -0
- package/lib/structure.js +648 -0
- package/lib/tool.js +3 -3
- package/lib/types/baseline.d.ts +69 -0
- package/lib/types/concurrency.d.ts +10 -0
- package/lib/types/data-engine.d.ts +13 -1
- package/lib/types/engine.d.ts +139 -2
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +9 -8
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +156 -0
- package/lib/types/tool.d.ts +2 -2
- package/lib/types/uml.d.ts +89 -0
- package/lib/uml-tool.js +36 -7
- package/lib/uml.js +217 -23
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +16 -14
- package/skills/logicprobe/SKILL.md +97 -58
- package/skills/logicprobe/references/dsh-model-schema.md +87 -15
- package/skills/logicprobe/references/gap-routing-guide.md +37 -36
- package/skills/logicprobe/references/hash-spec.md +128 -0
- package/skills/logicprobe/references/logic-verification-guide.md +29 -248
- package/skills/logicprobe-concurrency/SKILL.md +43 -0
- package/skills/{logicprobe → logicprobe-concurrency}/references/concurrency-risk-guide.md +1 -1
- package/skills/logicprobe-datamodel/SKILL.md +5 -4
- package/skills/logicprobe-datamodel/references/data-model-guide.md +1 -1
- package/skills/logicprobe-datamodel/references/data-model-schema.md +1 -1
- package/skills/logicprobe-structure/SKILL.md +95 -0
- package/skills/logicprobe-structure/references/structure-review-guide.md +177 -0
- package/skills/logicprobe-uml/SKILL.md +55 -0
- package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +120 -8
- package/src/baseline.ts +180 -0
- package/src/client.js +56 -1
- package/src/compose-tool.ts +47 -47
- package/src/concurrency-tool.ts +2 -2
- package/src/concurrency.ts +23 -0
- package/src/data-engine.ts +51 -9
- package/src/engine.ts +656 -101
- package/src/export-tool.ts +73 -48
- package/src/granularity.ts +312 -0
- package/src/index.ts +63 -32
- package/src/report-diff-tool.ts +43 -0
- package/src/structure-tool.ts +68 -0
- package/src/structure.ts +724 -0
- package/src/tool.ts +4 -4
- package/src/uml-tool.ts +38 -8
- package/src/uml.ts +275 -24
- package/skills/logicprobe/references/logicprobe-engine.py +0 -4780
- package/skills/logicprobe/references/verification-harness.py +0 -959
- 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
|
-
|
|
473
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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 (
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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 (
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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-
|
|
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 =
|
|
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-
|
|
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
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1829
|
-
*
|
|
1830
|
-
|
|
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
|
|
1833
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
1848
|
-
if (start.state
|
|
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 ' +
|
|
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
|
|
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 ' +
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
2749
|
-
checkResult('C2', 'Rendezvous Sync', c2Findings,
|
|
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
|
}
|