dsh-logicprobe 1.3.0 → 1.4.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/lib/engine.js +3446 -3151
- package/package.json +1 -1
- package/skills/logicprobe/SKILL.md +27 -1
- package/skills/logicprobe/references/dsh-model-schema.md +20 -3
- package/src/engine.ts +325 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-logicprobe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Verifies claims about designs and code. Facts go against the source. Behaviour goes through an executable model: state machines, protocols, composition, data models, migrations. Structure diagrams are read as dependency graphs, at one level or several. Two reports can be compared for regression. Exports UPPAAL, TLA+, PRISM and SPIN input. Ships a native DeepSeek Harness bundle that injects the verification gate into the first model step.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -161,6 +161,24 @@ UML modelling variant (the task is to draw a flow, not to check a claim):
|
|
|
161
161
|
→ logicprobe_verify → the behavioural checks (S1-S8 / A1-A14)
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
### Budgeting the Model Before You Run It
|
|
165
|
+
|
|
166
|
+
Search cost is the declared domain times the number of states — and a declared range is
|
|
167
|
+
not a bound on the search.
|
|
168
|
+
|
|
169
|
+
- **`inc`/`dec` are never clamped to `min`/`max`.** The declaration is a property the
|
|
170
|
+
checks verify, not a limit the engine enforces. A counter whose guard does not pin it
|
|
171
|
+
ranges past its own declaration, so the reachable set is unbounded however small the
|
|
172
|
+
declared range looks; S8 reports that as `S8_DOMAIN_NOT_ENFORCED`.
|
|
173
|
+
- **Bound a counter with a guard, not a narrower declaration.** `max: 4` with a guard
|
|
174
|
+
`retry < 4` closes the loop. `max: 4` alone does not.
|
|
175
|
+
- **Say the budget before running**: values per variable x states. A product in the
|
|
176
|
+
10^5-and-up range will hit the cap and come back undecided.
|
|
177
|
+
- **Model the check where the code performs it.** A cap check that lives inside the tick
|
|
178
|
+
handler is a guarded branch of the tick event, not a separate event: as a separate event
|
|
179
|
+
the environment can starve it, and the leads-to failure you then read is an artefact of
|
|
180
|
+
the model rather than a property of the code.
|
|
181
|
+
|
|
164
182
|
### Native dsh Tools
|
|
165
183
|
|
|
166
184
|
| Tool | Covers |
|
|
@@ -203,6 +221,11 @@ Turn the verdict into an answer, never into a summary of `ok`:
|
|
|
203
221
|
|
|
204
222
|
Exit codes: `0` for `pass` / `pass_with_findings`, `2` for `fail`, a refusal, or an unreadable input. In DSH the native tools return the report as data — read `verdict`, and treat `errorCode`/`error` as the refusal path.
|
|
205
223
|
|
|
224
|
+
`summary.truncated: true`, or a finding whose code ends in `_UNDECIDED`
|
|
225
|
+
(`S7_UNDECIDED`, `A7_UNDECIDED`, `A9_LEADS_TO_UNDECIDED`, `D2_UNDECIDED`), means the search
|
|
226
|
+
stopped before deciding. That is **not** a pass: name the invariant that went undecided,
|
|
227
|
+
and say the property is undecided rather than reading the silence as success.
|
|
228
|
+
|
|
206
229
|
### Writing the Answer: Name the Finding, Not Its Code
|
|
207
230
|
|
|
208
231
|
`S2_NO_TRANSITIONS`, `UML019`, `DA2` are values of `findings[].code`, not names. A reader
|
|
@@ -437,6 +460,8 @@ If you catch yourself thinking any of these, stop:
|
|
|
437
460
|
| "I'll check while implementing" | Check before the decision, not after. |
|
|
438
461
|
| "Reasoning is enough" | Reasoning is not a run. One counter-example kills a universal claim. |
|
|
439
462
|
| "The report passed, so the code is fine" | The report covers the model you gave it. Reconcile it, and say what you did not check. |
|
|
463
|
+
| "The declared range limits the search" | The declaration is a property to verify. `inc`/`dec` are never clamped to it; only a guard bounds the value. |
|
|
464
|
+
| "The check found nothing" | Read the verdict: `truncated` and `_UNDECIDED` mean the search stopped, not that the property holds. |
|
|
440
465
|
|
|
441
466
|
## Rules
|
|
442
467
|
|
|
@@ -449,4 +474,5 @@ If you catch yourself thinking any of these, stop:
|
|
|
449
474
|
7. **Don't verify what the code already checks.** If the existing codebase has compile-time assertions, static analysis, or runtime checks for a property, cite those — don't re-verify in a model.
|
|
450
475
|
8. **Hand a neighbouring domain to its own skill.** A diagram is a model, not evidence: modelling or auditing one belongs to `logicprobe-uml`. A concurrency claim is mined and routed, never proven here: that is `logicprobe-concurrency`. Schema, data invariants and migrations are `logicprobe-datamodel`. This skill keeps the doctrine and the behavioural checks.
|
|
451
476
|
9. **Say what this does not prove.** For dimensions logicprobe does not verify — hard real time (deadlines, periods), preemptive concurrency, hybrid-control stability, probabilistic reliability, execution-cost budgets — route to the dedicated tool named in `references/gap-routing-guide.md`. `logicprobe_verify` reports carry the same routing as informational `coverageNotes` on matching models.
|
|
452
|
-
10. **
|
|
477
|
+
10. **A declared range is a property, not a bound, and a capped search is not a pass.** `inc`/`dec` are never clamped to `min`/`max`; only a guard that pins the value bounds it. When a report is `truncated` or a check is `_UNDECIDED`, say the property is undecided and narrow the model — never report it as verified.
|
|
478
|
+
11. **Write-site analysis is out of scope, and stays there.** "Only the ISR writes this flag", "one owner per register" — logicprobe verifies models and diagrams, not *who writes what in the source*. That claim needs a source-side AST/rule check or a static analyzer; this plugin's contribution is to flag the claim as unverified and route it (`logicprobe_concurrency_scan` does exactly that). Do not imply the diagram or the model settled it.
|
|
@@ -225,6 +225,17 @@ A guard is exactly one of:
|
|
|
225
225
|
|
|
226
226
|
`inc`/`dec` default to 1 when `value` is omitted. `set` defaults to 0.
|
|
227
227
|
|
|
228
|
+
`inc` and `dec` are **never clamped** to the variable's declared `min`/`max`. The declared
|
|
229
|
+
range is a property the checks verify (S7 `var-in-range`), not a bound on the search: a
|
|
230
|
+
counter whose guard does not pin it ranges past its own declaration, so the reachable set
|
|
231
|
+
is unbounded however narrow the declaration looks. S8 reports that as
|
|
232
|
+
`S8_DOMAIN_NOT_ENFORCED` and names the transition, rather than clamping the value and
|
|
233
|
+
deleting the states a range violation is witnessed by. A `set` constant outside the
|
|
234
|
+
declared range contradicts the declaration and is `S8_DOMAIN_SET_OUTSIDE` (error).
|
|
235
|
+
|
|
236
|
+
To bound a counter, add a guard that pins it — `"max": 4` with a guard
|
|
237
|
+
`{ "variable": "retry", "op": "<", "value": 4 }` — not a narrower `max`.
|
|
238
|
+
|
|
228
239
|
## Invariants
|
|
229
240
|
|
|
230
241
|
Every invariant carries `id` and **`description`** — both required strings — plus a
|
|
@@ -396,9 +407,14 @@ The report's `comparison` object includes both model hashes, state/transition co
|
|
|
396
407
|
|
|
397
408
|
List events that must be idempotent in `idempotentEvents`. For every reachable state, applying the event twice must produce the same state as applying it once. This is useful for retries, webhook redelivery, and migration replay.
|
|
398
409
|
|
|
399
|
-
## Advanced constraints (S8, A9-A14)
|
|
410
|
+
## Advanced constraints (S6, S8, A9-A14)
|
|
400
411
|
|
|
401
|
-
- **
|
|
412
|
+
- **S6 Guard Completeness** also reports `S6_DEAD_GUARD`: a guarded branch no reachable
|
|
413
|
+
variable value can satisfy is a branch that can never fire. The test runs over the
|
|
414
|
+
values the model can produce — each variable's `init` plus every `set` constant — not
|
|
415
|
+
over the declared domain, so a guard on a value the model never writes is satisfiable on
|
|
416
|
+
paper and dead in practice.
|
|
417
|
+
- **S8 Variable Declarations**: declare `monotonic: "inc"|"dec"` on a variable; updates must not move in the opposite direction. The same check reads the declared range: `S8_DOMAIN_NOT_ENFORCED` reports an `inc`/`dec` no guard keeps inside `min`/`max`, and `S8_DOMAIN_SET_OUTSIDE` (error) reports a `set` constant outside it.
|
|
402
418
|
- **A9 Leads-To**: `{ kind: "leads-to", from, to }` — every path from `from` must eventually reach `to`. `to` is one state id or a non-empty array of state ids for "reach any one of these"; a duplicated member is a validation error rather than a shorthand. Either way the property stays universal: one branch that loops forever, or that stops before every target, refutes it.
|
|
403
419
|
- **A10 Sequence**: `{ kind: "sequence", events }` — events must appear in order.
|
|
404
420
|
- **A11 Atomicity**: `{ kind: "atomicity", events, commit, rollback? }` — once an atomic event starts, the machine must reach commit/rollback before leaving the atomic scope or terminating.
|
|
@@ -408,7 +424,8 @@ List events that must be idempotent in `idempotentEvents`. For every reachable s
|
|
|
408
424
|
|
|
409
425
|
## Limits
|
|
410
426
|
|
|
411
|
-
-
|
|
427
|
+
- Each breadth-first exploration caps at `maxStates` (default 10000). The leads-to walk caps at twenty times that, **shared across every start state of one check**: it is depth-first over run states, and an unbounded counter makes every one of them distinct, so it has no shorter bound available.
|
|
428
|
+
- A cap that bites is **reported, never silently passed**. The report sets `summary.truncated`, and the check that stopped carries a finding whose code ends in `_UNDECIDED` — `S7_UNDECIDED`, `A7_UNDECIDED`, `A9_LEADS_TO_UNDECIDED`, `D2_UNDECIDED`. An undecided check has proven neither the violation nor its absence; read it as "not decided", never as a pass.
|
|
412
429
|
- A3 samples the first `maxPermutationEvents` events (default 5).
|
|
413
430
|
- The engine is a finite-state model checker. It cannot prove properties of the real implementation; follow with code-level review.
|
|
414
431
|
- `cost` values are modeler-provided static labels — A12 verifies against them; real execution time/WCET needs binary-level timing analysis.
|
package/src/engine.ts
CHANGED
|
@@ -329,6 +329,31 @@ type NormalizedOptions = Required<Pick<VerificationOptions, 'maxStates' | 'maxPe
|
|
|
329
329
|
|
|
330
330
|
const DEFAULT_MAX_STATES = 10_000
|
|
331
331
|
const DEFAULT_MAX_PERMUTATION_EVENTS = 5
|
|
332
|
+
/**
|
|
333
|
+
* Budget for the leads-to walk, as a multiple of `maxStates`. That walk is depth-first
|
|
334
|
+
* over run states, so `maxStates` does not bound it the way it bounds the breadth-first
|
|
335
|
+
* explorations: an `inc`/`dec` counter whose updates are not pinned by a guard has an
|
|
336
|
+
* unbounded reachable set, every run state is distinct, and the walk never revisits one.
|
|
337
|
+
* The budget is shared across every start state of a single check, so a wide search
|
|
338
|
+
* space cannot multiply it by the number of starts.
|
|
339
|
+
*/
|
|
340
|
+
const LEADS_TO_WALK_FACTOR = 20
|
|
341
|
+
|
|
342
|
+
function leadsToWalkBudget(options: NormalizedOptions): number {
|
|
343
|
+
return Math.max(1_000, options.maxStates * LEADS_TO_WALK_FACTOR)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface LeadsToWalk {
|
|
347
|
+
/** Nodes still allowed, shared across every start state of one check. */
|
|
348
|
+
remaining: number
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
interface LeadsToOutcome {
|
|
352
|
+
/** A run from the start state that reaches none of the targets. */
|
|
353
|
+
violation?: { path: PathStep[]; reason: string }
|
|
354
|
+
/** The budget ran out: the property is neither refuted nor proven. */
|
|
355
|
+
undecided: boolean
|
|
356
|
+
}
|
|
332
357
|
|
|
333
358
|
function stableStringify(value: unknown): string {
|
|
334
359
|
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
@@ -468,6 +493,17 @@ export function verdictOfFindings(findings: Array<{ code: string; severity: stri
|
|
|
468
493
|
return verdictOf(errors.length, warnings.length, errors[0]?.code)
|
|
469
494
|
}
|
|
470
495
|
|
|
496
|
+
/**
|
|
497
|
+
* Finding codes that mean "the search stopped before deciding", as opposed to "the
|
|
498
|
+
* model is wrong". They must reach `summary.truncated`, or a caller reading only that
|
|
499
|
+
* flag would take an unfinished search for a clean one.
|
|
500
|
+
*/
|
|
501
|
+
const UNDECIDED_CODES = new Set(['S7_UNDECIDED', 'A7_UNDECIDED', 'A9_LEADS_TO_UNDECIDED', 'D2_UNDECIDED'])
|
|
502
|
+
|
|
503
|
+
function hasUndecidedFinding(checks: CheckResult[]): boolean {
|
|
504
|
+
return checks.some((check) => check.findings.some((finding) => UNDECIDED_CODES.has(finding.code)))
|
|
505
|
+
}
|
|
506
|
+
|
|
471
507
|
/**
|
|
472
508
|
* Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
|
|
473
509
|
* `states[0]._note`; a metadata key's own children are not listed, since the subtree is
|
|
@@ -1226,6 +1262,99 @@ function collectGuardConstants(model: LogicModelV1): number[] {
|
|
|
1226
1262
|
return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value as number)
|
|
1227
1263
|
}
|
|
1228
1264
|
|
|
1265
|
+
/** How a variable value prints in a message. Shared by every finding that quotes one. */
|
|
1266
|
+
function varValueLabel(value: VarValue): string {
|
|
1267
|
+
return typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/** Deterministic value order: booleans first, then numbers ascending. */
|
|
1271
|
+
function compareVarValues(a: VarValue, b: VarValue): number {
|
|
1272
|
+
const rank = (value: VarValue): [number, number] => typeof value === 'number' ? [1, value] : [0, value ? 1 : 0]
|
|
1273
|
+
const [rankA, numberA] = rank(a)
|
|
1274
|
+
const [rankB, numberB] = rank(b)
|
|
1275
|
+
return rankA === rankB ? numberA - numberB : rankA - rankB
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Values each variable can actually hold, as an over-approximation: `init` plus every
|
|
1280
|
+
* `set` constant the model writes. `null` means "unknown", which is what an `inc` or
|
|
1281
|
+
* `dec` update produces — the reachable set is then unbounded and nothing can be
|
|
1282
|
+
* concluded from it.
|
|
1283
|
+
*
|
|
1284
|
+
* The union is taken across the whole model rather than per state, so the answer can
|
|
1285
|
+
* only be too large, never too small. A guard this function calls dead is therefore
|
|
1286
|
+
* dead in every state, while a live guard may still be unreachable in a particular one.
|
|
1287
|
+
*/
|
|
1288
|
+
function attainableValues(model: LogicModelV1): Map<string, Set<VarValue> | null> {
|
|
1289
|
+
const attainable = new Map<string, Set<VarValue> | null>()
|
|
1290
|
+
for (const variable of model.variables ?? []) attainable.set(variable.name, new Set<VarValue>([variable.init]))
|
|
1291
|
+
for (const transition of model.transitions) {
|
|
1292
|
+
for (const update of transition.updates ?? []) {
|
|
1293
|
+
if (!attainable.has(update.variable)) continue
|
|
1294
|
+
if (update.op === 'inc' || update.op === 'dec') {
|
|
1295
|
+
attainable.set(update.variable, null)
|
|
1296
|
+
continue
|
|
1297
|
+
}
|
|
1298
|
+
const values = attainable.get(update.variable)
|
|
1299
|
+
if (values === null || values === undefined) continue
|
|
1300
|
+
values.add(update.value ?? 0)
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return attainable
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Guarded transitions whose guard no reachable variable value can satisfy: the branch
|
|
1308
|
+
* is dead code, and a model that relies on it is not the model the author wrote. This
|
|
1309
|
+
* is the dual of S6_INCOMPLETE_GUARD — a group that never reaches a branch, rather
|
|
1310
|
+
* than one that falls through every branch.
|
|
1311
|
+
*
|
|
1312
|
+
* The test runs over {@link attainableValues}, not over the declared domain: a guard
|
|
1313
|
+
* on a variable the model only ever sets to one of its declared values is satisfiable
|
|
1314
|
+
* on paper and dead in practice, which is exactly the defect worth reporting.
|
|
1315
|
+
*/
|
|
1316
|
+
function deadGuardFindings(model: LogicModelV1): Finding[] {
|
|
1317
|
+
const attainable = attainableValues(model)
|
|
1318
|
+
const findings: Finding[] = []
|
|
1319
|
+
for (const [key, group] of groupTransitions(model)) {
|
|
1320
|
+
const [from, event] = key.split('|')
|
|
1321
|
+
for (const transition of group) {
|
|
1322
|
+
if (transition.guard === undefined) continue
|
|
1323
|
+
const names = [...new Set(guardVariables(transition.guard))].filter((name) => attainable.has(name)).sort()
|
|
1324
|
+
if (names.length === 0) continue
|
|
1325
|
+
const domains: Array<Set<VarValue>> = []
|
|
1326
|
+
let known = true
|
|
1327
|
+
for (const name of names) {
|
|
1328
|
+
const values = attainable.get(name)
|
|
1329
|
+
if (values === undefined || values === null || values.size === 0) { known = false; break }
|
|
1330
|
+
domains.push(values)
|
|
1331
|
+
}
|
|
1332
|
+
if (!known) continue
|
|
1333
|
+
let size = 1
|
|
1334
|
+
for (const values of domains) size *= values.size
|
|
1335
|
+
if (size > 10_000) continue
|
|
1336
|
+
const assignment: Record<string, VarValue> = {}
|
|
1337
|
+
const satisfiable = (index: number): boolean => {
|
|
1338
|
+
if (index === names.length) return evalGuard(transition.guard!, assignment)
|
|
1339
|
+
for (const value of [...domains[index]].sort(compareVarValues)) {
|
|
1340
|
+
assignment[names[index]] = value
|
|
1341
|
+
if (satisfiable(index + 1)) return true
|
|
1342
|
+
}
|
|
1343
|
+
return false
|
|
1344
|
+
}
|
|
1345
|
+
if (satisfiable(0)) continue
|
|
1346
|
+
findings.push({
|
|
1347
|
+
code: 'S6_DEAD_GUARD',
|
|
1348
|
+
severity: 'warning',
|
|
1349
|
+
message: from + ' + ' + event + ' has a guard that can never hold: ' + names.map((name) =>
|
|
1350
|
+
name + ' is only ever ' + [...(attainable.get(name) as Set<VarValue>)].sort(compareVarValues).map(varValueLabel).join('/')).join(', ') + '. The branch is dead.',
|
|
1351
|
+
evidence: { guard: transition.guard, attainable: names.map((name) => ({ variable: name, values: [...(attainable.get(name) as Set<VarValue>)].sort(compareVarValues).map(varValueLabel) })) },
|
|
1352
|
+
})
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return findings
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1229
1358
|
interface GuardAnalysis {
|
|
1230
1359
|
determinism: Finding[]
|
|
1231
1360
|
completeness: Finding[]
|
|
@@ -1282,6 +1411,7 @@ function analyzeGuards(model: LogicModelV1): GuardAnalysis {
|
|
|
1282
1411
|
}
|
|
1283
1412
|
if (truncated) break
|
|
1284
1413
|
}
|
|
1414
|
+
completeness.push(...deadGuardFindings(model))
|
|
1285
1415
|
return { determinism, completeness, truncated }
|
|
1286
1416
|
}
|
|
1287
1417
|
|
|
@@ -1292,7 +1422,11 @@ function S4_determinism(model: LogicModelV1): CheckResult {
|
|
|
1292
1422
|
|
|
1293
1423
|
function S6_guardCompleteness(model: LogicModelV1): CheckResult {
|
|
1294
1424
|
const analysis = analyzeGuards(model)
|
|
1295
|
-
|
|
1425
|
+
const dead = analysis.completeness.filter((finding) => finding.code === 'S6_DEAD_GUARD').length
|
|
1426
|
+
const detail = analysis.completeness.length === 0
|
|
1427
|
+
? 'All guard branches defined'
|
|
1428
|
+
: dead === analysis.completeness.length ? 'Dead guard branches: ' + dead : 'Guard findings: ' + analysis.completeness.length
|
|
1429
|
+
return checkResult('S6', 'Guard Completeness', analysis.completeness, detail)
|
|
1296
1430
|
}
|
|
1297
1431
|
|
|
1298
1432
|
function S5_eventCompleteness(model: LogicModelV1): CheckResult {
|
|
@@ -1318,6 +1452,13 @@ interface InvariantViolation {
|
|
|
1318
1452
|
invariant: InvariantSpec
|
|
1319
1453
|
path: PathStep[]
|
|
1320
1454
|
reason: string
|
|
1455
|
+
/**
|
|
1456
|
+
* The search hit its budget before deciding the property, so neither "holds" nor
|
|
1457
|
+
* "violated" is proven. Every caller must report this as undecided rather than fold
|
|
1458
|
+
* it into a pass: a cap that reads as a pass is the false green the report contract
|
|
1459
|
+
* exists to prevent.
|
|
1460
|
+
*/
|
|
1461
|
+
undecided?: boolean
|
|
1321
1462
|
}
|
|
1322
1463
|
|
|
1323
1464
|
/** Whether a `var-in-range` `when` scope selects this runtime state. */
|
|
@@ -1427,10 +1568,15 @@ function pathPropertyViolation(model: LogicModelV1, options: NormalizedOptions,
|
|
|
1427
1568
|
if (invariant.kind === 'leads-to') {
|
|
1428
1569
|
const targets = new Set(leadsToTargets(invariant))
|
|
1429
1570
|
const label = leadsToTargetLabel(invariant)
|
|
1571
|
+
const budget = leadsToWalkBudget(options)
|
|
1572
|
+
const walk: LeadsToWalk = { remaining: budget }
|
|
1430
1573
|
for (const runtime of reachableRuntimeStates(model, options)) {
|
|
1431
1574
|
if (runtime.state !== invariant.from) continue
|
|
1432
|
-
const
|
|
1433
|
-
if (
|
|
1575
|
+
const outcome = findLeadsToBadPath(model, runtime, targets, label, walk)
|
|
1576
|
+
if (outcome.undecided) {
|
|
1577
|
+
return { invariant, path: [], reason: 'the walk spent its budget of ' + budget + ' steps without finding a run that avoids ' + label + ', or proving that none exists', undecided: true }
|
|
1578
|
+
}
|
|
1579
|
+
if (outcome.violation !== undefined) return { invariant, path: outcome.violation.path, reason: outcome.violation.reason }
|
|
1434
1580
|
}
|
|
1435
1581
|
return undefined
|
|
1436
1582
|
}
|
|
@@ -1503,14 +1649,22 @@ function runInvariants(model: LogicModelV1, options: NormalizedOptions, includeP
|
|
|
1503
1649
|
|
|
1504
1650
|
function S7_invariants(model: LogicModelV1, options: NormalizedOptions): CheckResult {
|
|
1505
1651
|
const violations = runInvariants(model, options)
|
|
1506
|
-
const findings: Finding[] = violations.map((violation) =>
|
|
1652
|
+
const findings: Finding[] = violations.map((violation) => violation.undecided ? {
|
|
1653
|
+
code: 'S7_UNDECIDED',
|
|
1654
|
+
severity: 'warning' as const,
|
|
1655
|
+
message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') could not be decided: ' + violation.reason + '. Absence is not proven.',
|
|
1656
|
+
evidence: { invariant: violation.invariant },
|
|
1657
|
+
} : {
|
|
1507
1658
|
code: 'S7_INVARIANT_VIOLATION',
|
|
1508
|
-
severity: 'error',
|
|
1659
|
+
severity: 'error' as const,
|
|
1509
1660
|
message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
|
|
1510
1661
|
path: violation.path,
|
|
1511
1662
|
evidence: { invariant: violation.invariant },
|
|
1512
|
-
})
|
|
1513
|
-
|
|
1663
|
+
})
|
|
1664
|
+
const undecided = findings.some((finding) => finding.code === 'S7_UNDECIDED')
|
|
1665
|
+
return checkResult('S7', 'Invariant Validity', findings, violations.length === 0
|
|
1666
|
+
? 'All invariants hold'
|
|
1667
|
+
: undecided && findings.every((finding) => finding.code === 'S7_UNDECIDED') ? 'Invariants undecided: the search budget ran out' : 'Invariant violations: ' + violations.length)
|
|
1514
1668
|
}
|
|
1515
1669
|
|
|
1516
1670
|
// ---------------------------------------------------------------------------
|
|
@@ -1869,6 +2023,14 @@ function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions):
|
|
|
1869
2023
|
// found instead of claiming minimality.
|
|
1870
2024
|
const violations = runInvariants(model, options, true)
|
|
1871
2025
|
const findings: Finding[] = violations.map((violation) => {
|
|
2026
|
+
if (violation.undecided) {
|
|
2027
|
+
return {
|
|
2028
|
+
code: 'A7_UNDECIDED',
|
|
2029
|
+
severity: 'warning' as const,
|
|
2030
|
+
message: 'Invariant "' + violation.invariant.id + '" could not be decided: ' + violation.reason + '. Absence is not proven, so there is no shortest counterexample to report.',
|
|
2031
|
+
evidence: { invariant: violation.invariant.id },
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
1872
2034
|
const pathLength = violation.path.length
|
|
1873
2035
|
const message = isPathPropertyInvariant(violation.invariant)
|
|
1874
2036
|
? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
|
|
@@ -2051,9 +2213,14 @@ function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, optio
|
|
|
2051
2213
|
? pathPropertyViolation(after, options, mapped)
|
|
2052
2214
|
: shortestViolationForInvariant(after, options, mapped)
|
|
2053
2215
|
if (violation !== undefined) {
|
|
2054
|
-
findings.push({
|
|
2216
|
+
findings.push(violation.undecided ? {
|
|
2217
|
+
code: 'D2_UNDECIDED',
|
|
2218
|
+
severity: 'warning' as const,
|
|
2219
|
+
message: 'BEFORE invariant "' + invariant.id + '" could not be re-decided against AFTER: ' + violation.reason + '. Absence is not proven.',
|
|
2220
|
+
evidence: { beforeInvariant: invariant, afterInvariant: mapped },
|
|
2221
|
+
} : {
|
|
2055
2222
|
code: 'D2_INVARIANT_REGRESSION',
|
|
2056
|
-
severity: 'error',
|
|
2223
|
+
severity: 'error' as const,
|
|
2057
2224
|
message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
|
|
2058
2225
|
path: violation.path,
|
|
2059
2226
|
evidence: { beforeInvariant: invariant, afterInvariant: mapped },
|
|
@@ -2218,6 +2385,99 @@ function A8_idempotentReplay(model: LogicModelV1, exploration: Exploration): Che
|
|
|
2218
2385
|
}
|
|
2219
2386
|
return checkResult('A8', 'Idempotent Replay', findings, findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length)
|
|
2220
2387
|
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Whether the guard proves `variable` stays at or below `bound` wherever it holds.
|
|
2390
|
+
*
|
|
2391
|
+
* Deliberately conservative in one direction only: a guard this function cannot read
|
|
2392
|
+
* answers `false`, so an unreadable guard is reported as "not proven" rather than as
|
|
2393
|
+
* "enforced". A conjunction bounds the value if any conjunct does; a disjunction has to
|
|
2394
|
+
* bound it on every branch; a negation is never reasoned about.
|
|
2395
|
+
*/
|
|
2396
|
+
function guardImpliesUpperBound(guard: GuardNode | undefined, variable: string, bound: number): boolean {
|
|
2397
|
+
if (guard === undefined) return false
|
|
2398
|
+
if ('variable' in guard) {
|
|
2399
|
+
if (guard.variable !== variable || typeof guard.value !== 'number') return false
|
|
2400
|
+
if (guard.op === '<') return guard.value - 1 <= bound
|
|
2401
|
+
if (guard.op === '<=' || guard.op === '==') return guard.value <= bound
|
|
2402
|
+
return false
|
|
2403
|
+
}
|
|
2404
|
+
if ('all' in guard) return guard.all.some((child) => guardImpliesUpperBound(child, variable, bound))
|
|
2405
|
+
if ('any' in guard) return guard.any.every((child) => guardImpliesUpperBound(child, variable, bound))
|
|
2406
|
+
return false
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
/** Mirror of {@link guardImpliesUpperBound} for the lower bound. */
|
|
2410
|
+
function guardImpliesLowerBound(guard: GuardNode | undefined, variable: string, bound: number): boolean {
|
|
2411
|
+
if (guard === undefined) return false
|
|
2412
|
+
if ('variable' in guard) {
|
|
2413
|
+
if (guard.variable !== variable || typeof guard.value !== 'number') return false
|
|
2414
|
+
if (guard.op === '>') return guard.value + 1 >= bound
|
|
2415
|
+
if (guard.op === '>=' || guard.op === '==') return guard.value >= bound
|
|
2416
|
+
return false
|
|
2417
|
+
}
|
|
2418
|
+
if ('all' in guard) return guard.all.some((child) => guardImpliesLowerBound(child, variable, bound))
|
|
2419
|
+
if ('any' in guard) return guard.any.every((child) => guardImpliesLowerBound(child, variable, bound))
|
|
2420
|
+
return false
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/**
|
|
2424
|
+
* Whether the model's own declarations about a variable hold against what its
|
|
2425
|
+
* transitions do. `inc` and `dec` are never clamped to `min`/`max` — the declared
|
|
2426
|
+
* range is a property to check, not a bound on the search — so a counter whose guard
|
|
2427
|
+
* does not pin it ranges past its own declaration, and the reachable set is unbounded
|
|
2428
|
+
* however narrow the declaration looks.
|
|
2429
|
+
*
|
|
2430
|
+
* Reporting the declaration rather than silently clamping is deliberate. Clamping would
|
|
2431
|
+
* delete the very states a range violation is witnessed by, turning a counterexample
|
|
2432
|
+
* into a false pass.
|
|
2433
|
+
*/
|
|
2434
|
+
function declaredDomainFindings(model: LogicModelV1): Finding[] {
|
|
2435
|
+
const findings: Finding[] = []
|
|
2436
|
+
for (const transition of model.transitions) {
|
|
2437
|
+
for (const update of transition.updates ?? []) {
|
|
2438
|
+
const variable = (model.variables ?? []).find((entry) => entry.name === update.variable)
|
|
2439
|
+
if (variable === undefined) continue
|
|
2440
|
+
const delta = typeof update.value === 'number' ? update.value : 1
|
|
2441
|
+
const at = transition.from + ' + ' + transition.event
|
|
2442
|
+
if (update.op === 'inc' && variable.max !== undefined && !guardImpliesUpperBound(transition.guard, variable.name, variable.max - delta)) {
|
|
2443
|
+
findings.push({
|
|
2444
|
+
code: 'S8_DOMAIN_NOT_ENFORCED',
|
|
2445
|
+
severity: 'warning',
|
|
2446
|
+
message: at + ' increments ' + variable.name + ', but ' + variable.name + ' declares max ' + variable.max + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
|
|
2447
|
+
evidence: { variable: variable.name, declaredMax: variable.max, transition: at },
|
|
2448
|
+
})
|
|
2449
|
+
}
|
|
2450
|
+
if (update.op === 'dec' && variable.min !== undefined && !guardImpliesLowerBound(transition.guard, variable.name, variable.min + delta)) {
|
|
2451
|
+
findings.push({
|
|
2452
|
+
code: 'S8_DOMAIN_NOT_ENFORCED',
|
|
2453
|
+
severity: 'warning',
|
|
2454
|
+
message: at + ' decrements ' + variable.name + ', but ' + variable.name + ' declares min ' + variable.min + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
|
|
2455
|
+
evidence: { variable: variable.name, declaredMin: variable.min, transition: at },
|
|
2456
|
+
})
|
|
2457
|
+
}
|
|
2458
|
+
if (update.op === 'set' && typeof update.value === 'number') {
|
|
2459
|
+
if (variable.min !== undefined && update.value < variable.min) {
|
|
2460
|
+
findings.push({
|
|
2461
|
+
code: 'S8_DOMAIN_SET_OUTSIDE',
|
|
2462
|
+
severity: 'error',
|
|
2463
|
+
message: at + ' sets ' + variable.name + ' to ' + update.value + ', below its declared min ' + variable.min + '.',
|
|
2464
|
+
evidence: { variable: variable.name, value: update.value, declaredMin: variable.min },
|
|
2465
|
+
})
|
|
2466
|
+
}
|
|
2467
|
+
if (variable.max !== undefined && update.value > variable.max) {
|
|
2468
|
+
findings.push({
|
|
2469
|
+
code: 'S8_DOMAIN_SET_OUTSIDE',
|
|
2470
|
+
severity: 'error',
|
|
2471
|
+
message: at + ' sets ' + variable.name + ' to ' + update.value + ', above its declared max ' + variable.max + '.',
|
|
2472
|
+
evidence: { variable: variable.name, value: update.value, declaredMax: variable.max },
|
|
2473
|
+
})
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
return findings
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2221
2481
|
function S8_monotonicVariables(model: LogicModelV1): CheckResult {
|
|
2222
2482
|
const findings: Finding[] = []
|
|
2223
2483
|
for (const variable of model.variables ?? []) {
|
|
@@ -2235,7 +2495,8 @@ function S8_monotonicVariables(model: LogicModelV1): CheckResult {
|
|
|
2235
2495
|
}
|
|
2236
2496
|
}
|
|
2237
2497
|
}
|
|
2238
|
-
|
|
2498
|
+
findings.push(...declaredDomainFindings(model))
|
|
2499
|
+
return checkResult('S8', 'Variable Declarations', findings, findings.length === 0 ? 'Variable declarations are respected' : 'Variable declaration findings: ' + findings.length)
|
|
2239
2500
|
}
|
|
2240
2501
|
|
|
2241
2502
|
/**
|
|
@@ -2271,22 +2532,45 @@ function leadsToTargetLabel(invariant: Extract<InvariantSpec, { kind: 'leads-to'
|
|
|
2271
2532
|
* (two branches rejoining) is acyclic and must pass, while a genuine cycle must not.
|
|
2272
2533
|
* A run is identified by its state plus its variable values, so the same state id
|
|
2273
2534
|
* with different values is a different node.
|
|
2535
|
+
*
|
|
2536
|
+
* Two properties keep the walk from taking the host down, and both are load-bearing:
|
|
2537
|
+
* the walk is **budgeted**, and it stores a **parent link** per node instead of a copy
|
|
2538
|
+
* of the path. Without the budget an unbounded counter makes the run graph infinite, so
|
|
2539
|
+
* the three colours above never repeat a key and the walk never returns; without the
|
|
2540
|
+
* parent link every frame retains its own copy of the path, which grows the walk's
|
|
2541
|
+
* memory quadratically in its depth. Either one alone turns "the model is wrong" into
|
|
2542
|
+
* an out-of-memory abort, which is the failure this function must not have.
|
|
2543
|
+
*
|
|
2544
|
+
* An exhausted budget is reported as `undecided`, never as `undefined`: a walk that
|
|
2545
|
+
* did not finish has not proven that every run reaches a target.
|
|
2274
2546
|
*/
|
|
2275
|
-
function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: Set<string>, label: string
|
|
2276
|
-
if (targets.has(start.state)) return
|
|
2547
|
+
function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: Set<string>, label: string, walk: LeadsToWalk): LeadsToOutcome {
|
|
2548
|
+
if (targets.has(start.state)) return { undecided: false }
|
|
2277
2549
|
const GRAY = 1
|
|
2278
2550
|
const BLACK = 2
|
|
2279
2551
|
interface LeadFrame {
|
|
2280
2552
|
runtime: RuntimeState
|
|
2281
|
-
|
|
2553
|
+
parent: LeadFrame | undefined
|
|
2554
|
+
step: PathStep | undefined
|
|
2282
2555
|
/** Outgoing steps, filled on first expansion; `undefined` means "not expanded yet". */
|
|
2283
2556
|
nexts: Array<{ next: RuntimeState; event: string }> | undefined
|
|
2284
2557
|
index: number
|
|
2285
2558
|
}
|
|
2559
|
+
const pathOf = (frame: LeadFrame): PathStep[] => {
|
|
2560
|
+
const steps: PathStep[] = []
|
|
2561
|
+
let node: LeadFrame | undefined = frame
|
|
2562
|
+
while (node !== undefined && node.step !== undefined) {
|
|
2563
|
+
steps.push(node.step)
|
|
2564
|
+
node = node.parent
|
|
2565
|
+
}
|
|
2566
|
+
return steps.reverse()
|
|
2567
|
+
}
|
|
2286
2568
|
const color = new Map<string, number>()
|
|
2287
|
-
const stack: LeadFrame[] = [{ runtime: start,
|
|
2569
|
+
const stack: LeadFrame[] = [{ runtime: start, parent: undefined, step: undefined, nexts: undefined, index: 0 }]
|
|
2288
2570
|
color.set(runtimeKey(start), GRAY)
|
|
2289
2571
|
while (stack.length > 0) {
|
|
2572
|
+
if (walk.remaining <= 0) return { undecided: true }
|
|
2573
|
+
walk.remaining -= 1
|
|
2290
2574
|
const frame = stack[stack.length - 1]
|
|
2291
2575
|
if (frame.nexts === undefined) {
|
|
2292
2576
|
const nexts: Array<{ next: RuntimeState; event: string }> = []
|
|
@@ -2294,7 +2578,7 @@ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: S
|
|
|
2294
2578
|
for (const next of stepRuntime(model, frame.runtime, event)) nexts.push({ next, event })
|
|
2295
2579
|
}
|
|
2296
2580
|
frame.nexts = nexts
|
|
2297
|
-
if (nexts.length === 0) return { path: frame
|
|
2581
|
+
if (nexts.length === 0) return { violation: { path: pathOf(frame), reason: 'Dead end before target ' + label }, undecided: false }
|
|
2298
2582
|
}
|
|
2299
2583
|
if (frame.index >= frame.nexts.length) {
|
|
2300
2584
|
color.set(runtimeKey(frame.runtime), BLACK)
|
|
@@ -2307,37 +2591,51 @@ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: S
|
|
|
2307
2591
|
if (targets.has(next.state)) continue
|
|
2308
2592
|
const key = runtimeKey(next)
|
|
2309
2593
|
const seen = color.get(key)
|
|
2310
|
-
if (seen === GRAY) return { path: [...frame
|
|
2594
|
+
if (seen === GRAY) return { violation: { path: [...pathOf(frame), step], reason: 'Cycle avoids target ' + label }, undecided: false }
|
|
2311
2595
|
if (seen === BLACK) continue
|
|
2312
2596
|
color.set(key, GRAY)
|
|
2313
|
-
stack.push({ runtime: next,
|
|
2597
|
+
stack.push({ runtime: next, parent: frame, step, nexts: undefined, index: 0 })
|
|
2314
2598
|
}
|
|
2315
2599
|
// Every branch either reached a target or rejoined a branch that did.
|
|
2316
|
-
return
|
|
2600
|
+
return { undecided: false }
|
|
2317
2601
|
}
|
|
2318
2602
|
|
|
2319
|
-
function A9_leadsTo(model: LogicModelV1, exploration: Exploration): CheckResult {
|
|
2603
|
+
function A9_leadsTo(model: LogicModelV1, exploration: Exploration, options: NormalizedOptions): CheckResult {
|
|
2320
2604
|
const findings: Finding[] = []
|
|
2321
2605
|
for (const invariant of model.invariants ?? []) {
|
|
2322
2606
|
if (invariant.kind !== 'leads-to') continue
|
|
2323
2607
|
const targets = new Set(leadsToTargets(invariant))
|
|
2324
2608
|
const label = leadsToTargetLabel(invariant)
|
|
2609
|
+
const budget = leadsToWalkBudget(options)
|
|
2610
|
+
const walk: LeadsToWalk = { remaining: budget }
|
|
2325
2611
|
for (const runtime of exploration.reachable) {
|
|
2326
2612
|
if (runtime.state !== invariant.from) continue
|
|
2327
|
-
const
|
|
2328
|
-
if (
|
|
2613
|
+
const outcome = findLeadsToBadPath(model, runtime, targets, label, walk)
|
|
2614
|
+
if (outcome.undecided) {
|
|
2615
|
+
findings.push({
|
|
2616
|
+
code: 'A9_LEADS_TO_UNDECIDED',
|
|
2617
|
+
severity: 'warning',
|
|
2618
|
+
message: 'Leads-to invariant "' + invariant.id + '" is undecided: the walk spent its budget of ' + budget + ' steps from ' + invariant.from + ' without finding a run that avoids ' + label + ', or proving that none exists. Absence is not proven.',
|
|
2619
|
+
evidence: { invariant },
|
|
2620
|
+
})
|
|
2621
|
+
break
|
|
2622
|
+
}
|
|
2623
|
+
if (outcome.violation !== undefined) {
|
|
2329
2624
|
findings.push({
|
|
2330
2625
|
code: 'A9_LEADS_TO_VIOLATION',
|
|
2331
2626
|
severity: 'error',
|
|
2332
|
-
message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' +
|
|
2333
|
-
path:
|
|
2627
|
+
message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + outcome.violation.reason,
|
|
2628
|
+
path: outcome.violation.path,
|
|
2334
2629
|
evidence: { invariant },
|
|
2335
2630
|
})
|
|
2336
2631
|
break
|
|
2337
2632
|
}
|
|
2338
2633
|
}
|
|
2339
2634
|
}
|
|
2340
|
-
|
|
2635
|
+
const undecided = findings.some((finding) => finding.code === 'A9_LEADS_TO_UNDECIDED')
|
|
2636
|
+
return checkResult('A9', 'Leads-To', findings, findings.length === 0
|
|
2637
|
+
? 'All leads-to invariants hold'
|
|
2638
|
+
: undecided ? 'Leads-to undecided: the search budget ran out' : 'Leads-to findings: ' + findings.length)
|
|
2341
2639
|
}
|
|
2342
2640
|
|
|
2343
2641
|
function findSequenceViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'sequence' }>): InvariantViolation | undefined {
|
|
@@ -2936,7 +3234,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
2936
3234
|
A6_resourceInjection(model),
|
|
2937
3235
|
A7_shortestViolations(model, normalized),
|
|
2938
3236
|
A8_idempotentReplay(model, exploration),
|
|
2939
|
-
A9_leadsTo(model, exploration),
|
|
3237
|
+
A9_leadsTo(model, exploration, normalized),
|
|
2940
3238
|
A10_sequenceOrder(model, normalized),
|
|
2941
3239
|
A11_atomicity(model, normalized),
|
|
2942
3240
|
A12_budget(model, normalized),
|
|
@@ -2965,7 +3263,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
2965
3263
|
errors: beforeValidation.errors.length,
|
|
2966
3264
|
warnings: 0,
|
|
2967
3265
|
checksRun: checks.length + 1,
|
|
2968
|
-
truncated: exploration.truncated,
|
|
3266
|
+
truncated: exploration.truncated || hasUndecidedFinding(checks),
|
|
2969
3267
|
},
|
|
2970
3268
|
checks: [
|
|
2971
3269
|
...checks,
|
|
@@ -3025,7 +3323,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
3025
3323
|
errors,
|
|
3026
3324
|
warnings,
|
|
3027
3325
|
checksRun: checks.length,
|
|
3028
|
-
truncated: exploration.truncated,
|
|
3326
|
+
truncated: exploration.truncated || hasUndecidedFinding(checks),
|
|
3029
3327
|
},
|
|
3030
3328
|
checks,
|
|
3031
3329
|
nextSteps: verificationNextSteps(checks, narrativeCoverage),
|