fix(tla): surface a reached Assert(FALSE) in an action as a safety violation - #194
Merged
Conversation
…olation A next-state action conjunct that errors during evaluation is treated as a disabled branch (no successor). That is correct for a benign guard-eval failure (e.g. a record access on a `ModelValue` when a sibling guard is false), but it also silently swallowed a reached `Assert(FALSE)` — so a spec whose Next action can reach a failing assertion was reported as PASSING instead of violated (TLC halts on such an assertion). `Model::next_states` returns `()` and has no error/violation channel, so the failure is surfaced via a per-thread side channel instead of a trait change: - The `Assert` handlers (compiled `eval_var`/interpreted `operator`) record the failed assertion ONLY when its argument evaluated to `Bool(false)` AND a committed next-state generation is in progress. A non-boolean argument (a benign/ambiguous eval error) is not recorded. - `Model::next_states` / `next_states_labeled` mark committed generation for the duration of the evaluation via an RAII `CommittedNextStateGuard`, so speculative evaluations (`ENABLED`, invariant/state-constraint checks, action probing) that hit an assertion do NOT record a spurious violation. - The worker clears the slot before generating successors and, right after, drains any recorded assertion and reports it exactly like an invariant violation (same trace reconstruction, channel, and stop handling). - `Model::next_states` returns cleanly (no panic) when an assertion is pending, regardless of `allow_deadlock`, so the worker can report it. Soundness rests on conjunction short-circuit (`And` stops at the first false conjunct; a false guard clause prunes the branch before later clauses), so a *reached* assertion means all preceding guards passed — exactly where TLC would evaluate and halt on it. Gate: cargo test --release 1488 passed, 0 failed — adds `tests/reached_assertion_violation.rs` (a reached Assert(FALSE) is reported as a Safety violation; an Assert behind a never-satisfiable guard is NOT). diff_tlc 23/23 with unchanged verdicts — adds `assert_disabled` (Assert behind an unsatisfiable guard: no violation, count == TLC). No existing spec flips to a false violation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A next-state action conjunct that errors during evaluation is treated as a disabled branch (no successor). That's correct for a benign guard-eval failure (e.g. a record access on a
ModelValuewhen a sibling guard is false), but it also silently swallowed a reachedAssert(FALSE)— so a spec whoseNextaction can reach a failing assertion was reported as passing instead of violated. TLC halts on such an assertion.Why it's tricky
Model::next_statesreturns()— it has no error/violation channel — and the only violation channel (check_invariants) is state-level, not action-level. So the failure is surfaced via a per-thread side channel rather than a trait-signature change.Change
Asserthandlers (compiled + interpreted) record the failure only when the argument evaluated toBool(false)and a committed next-state generation is in progress. A non-boolean argument (benign/ambiguous eval error) is not recorded.next_states/next_states_labeledmark committed generation for the duration of the eval via an RAIICommittedNextStateGuard, so speculative evaluations (ENABLED, invariant/state-constraint checks, action probing) that hit an assertion do not record a spurious violation.next_statesreturns cleanly (no panic) when an assertion is pending, regardless ofallow_deadlock.Soundness rests on conjunction short-circuit (
Andstops at the first false conjunct; a false guard clause prunes the branch before later clauses evaluate), so a reached assertion means all preceding guards passed — exactly where TLC would evaluate and halt on it.Approach chosen
Presented three options (side-channel / trait-signature change / defer); the side-channel was selected as best risk/reward — it reuses the existing violation machinery without a trait ripple across the runtime, POR, swarm, labeled variants, and every model.
Gate
cargo test --release1488 passed, 0 failed — addstests/reached_assertion_violation.rs: a reachedAssert(FALSE)is reported as a Safety violation; anAssertbehind a never-satisfiable guard is not.scripts/diff_tlc.sh23/23 with unchanged verdicts — addsassert_disabled(Assert behind an unsatisfiable guard: no violation, distinct count == TLC). No existing spec flips to a false violation.run-tla:AssertReached→violation=true("assertion failed");AssertDisabled→violation=false, 4 states.