Prove Safe Recovery¶
This guide walks through verifying that an SSFC is guaranteed to reach a designated safe state, and how quickly. The Pasteurizer is used as the running example throughout, with two queries:
Production → SafeStopped(withASSUME abort_signal)SafeStopped → Idle(no ASSUME forreset_request)
For the conceptual explanation of how GUARANTEED and BOUNDED are computed, see concepts/recovery-and-progress.md. For the normative claim level definitions, see reference/assurance.md.
Step 1: Choose source and home states¶
Decide which (source, home) pair to analyse. Common patterns:
- Fault recovery: source = fault or production state, home = safe or stopped state
- Reset sequence: source = safe state, home = idle or ready state
- Any reachable state to safe: source = initial state, home = safe terminal state
For the Pasteurizer:
- Source:
Production— the normal running state - Home:
SafeStopped— the safe mode the system must reach on abort
A second query covers the subsequent reset path:
- Source:
SafeStopped - Home:
Idle
Step 2: Declare ASSUME clauses only where justified¶
ASSUME clauses assert that the environment will keep a condition True. They are engineering commitments, not free assumptions. Add an ASSUME only when there is an external contract — a hardware interlock, a system-level safety requirement, or an operator procedure — that guarantees the condition.
SSFC PasteurizerSequence
VAR_INPUT
abort_signal : BOOL;
reset_request : BOOL;
END_VAR
ASSUME abort_signal; (* external: safety interlock guarantees abort stays asserted *)
...
reset_request does not get an ASSUME here. It depends on operator action and no external contract guarantees it will ever become True.
Do not add ASSUME clauses to make GUARANTEED pass. A PROVED-CONDITIONAL result is only meaningful if the ASSUME reflects a real guarantee. A spurious ASSUME produces a proof that is technically valid but unsafe in practice.
Step 3: Run the recovery analysis¶
The analysis is available via analyze_recovery():
from lola.ssfc_recovery import analyze_recovery, render_claim_md
from lola.ssfc_marking import build_marking_graph
mg = build_marking_graph(net)
claim = analyze_recovery("Production", "SafeStopped", ssfc, net, mg)
print(render_claim_md(claim))
For the complete Pasteurizer report covering both queries:
from lola.ssfc_recovery import RecoveryReport, render_report_md
report = RecoveryReport(
component="PasteurizerSequence",
claims=[
analyze_recovery("Production", "SafeStopped", ssfc, net, mg),
analyze_recovery("SafeStopped", "Idle", ssfc, net, mg),
],
)
print(render_report_md(report))
Step 4: Read the four claim levels separately¶
Each claim level is an independent result. Do not collapse them into a single verdict.
Target report for Production → SafeStopped:
STRUCTURAL ✓ PROVED
path: Production → SafeStopped (1 transition)
POSSIBLE ✓ PROVED
per-edge SAT path: abort_signal is satisfiable
(over-approximation: guard satisfiability checked per transition)
GUARANTEED ≈ PROVED-CONDITIONAL
all admissible executions reach SafeStopped
requires: ASSUME abort_signal
BOUNDED ≈ PROVED-CONDITIONAL
within 1 scan (worst-case attractor rank)
requires: ASSUME abort_signal
Target report for SafeStopped → Idle:
STRUCTURAL ✓ PROVED
path: SafeStopped → Idle (1 transition)
POSSIBLE ✓ PROVED
per-edge SAT path: reset_request is satisfiable
GUARANTEED ✗ NOT PROVED
marking {safestopped} may idle without progressing to 'idle'.
Add ASSUME to guarantee the relevant guard eventually becomes TRUE.
BOUNDED ✗ NOT PROVED
(requires GUARANTEED to hold)
What to do with each level:
| Level | NOT PROVED means | Action |
|---|---|---|
| STRUCTURAL | No path in the state machine topology | Fix the state machine structure |
| POSSIBLE | No guard-feasible path | Check guards; they may be contradictory or the state may be unreachable |
| GUARANTEED | System may idle or diverge to a non-home state | Add ASSUME (if justified), or accept that GUARANTEED does not hold |
| BOUNDED | GUARANTEED does not hold | Fix GUARANTEED first |
Step 5: Inspect assumptions_used¶
When GUARANTEED is PROVED-CONDITIONAL, assumptions_used lists only the ASSUME clauses that are actually needed for the proof to hold — the load-bearing assumes. Non-load-bearing clauses are excluded.
claim = analyze_recovery("Production", "SafeStopped", ssfc, net, mg)
print(claim.guaranteed.assumptions_used)
# ['abort_signal']
If an ASSUME clause you expected to be load-bearing does not appear in assumptions_used, it is not contributing to the GUARANTEED proof. It may still be needed for other reasons — POSSIBLE feasibility, other claims — but you can consider whether it is architecturally justified.
Step 6: Use BOUNDED for WCRT planning¶
bound_scans gives the worst-case number of scans to reach home, under GUARANTEED conditions:
print(claim.bounded.bound_scans) # 1
This is the maximum number of scans in which the system is guaranteed to reach the home state, counting from the moment the abort signal becomes and remains asserted. It is a worst-case bound: the actual number may be less.
In safety planning: if the scan cycle is 10 ms and BOUNDED = 3, the guaranteed recovery time is at most 30 ms. This bound is only valid under the ASSUME conditions.
Step 7: Treat NOT PROVED as a design finding¶
GUARANTEED = NOT PROVED is not a solver failure. It means the analysis found a reachable marking from which the home state is not guaranteed to be reached. The reason field says what was found:
- "marking {X} may idle without progressing": the system can stall in marking X indefinitely if the relevant guard is never True. To fix: add an ASSUME for the guard (if there is an external guarantee), or restructure the SSFC so the guard becomes always True in this marking.
- "marking {X} reachable via admissible transitions and does not lead to home on all executions": a branch from X leads to a non-home sink. To fix: eliminate the non-home branch, or make the diverging transition guard impossible under ASSUMEs.
Do not add ASSUMEs blindly to pass GUARANTEED. If the system genuinely cannot be guaranteed to reach the home state — for example, SafeStopped → Idle depends on operator action — document this explicitly and rely on POSSIBLE instead.
Quick reference: POSSIBLE vs GUARANTEED¶
| Scenario | POSSIBLE | GUARANTEED |
|---|---|---|
| Path exists and guard satisfiable | PROVED | NOT PROVED (no ASSUME) |
| Path exists, ASSUME covers all guards | PROVED | PROVED-CONDITIONAL |
| Path exists, guard is always True (tautological) | PROVED | PROVED |
| No structural path | NOT PROVED | NOT PROVED |
The most important case: POSSIBLE = PROVED but GUARANTEED = NOT PROVED. This means the system can reach the safe state but is not required to on every execution. This is the normal situation for operator-triggered resets and similar environment-dependent transitions. It is not a bug; it is a correct characterisation of the system.