Skip to content

fix(tla): unmask EnvironmentController panic — nested-disjunction split + UNCHANGED-tuple staging - #187

Merged
zoratu merged 1 commit into
mainfrom
fix/exists-conjunction-nested-disjunction-mis-split
Jul 19, 2026
Merged

fix(tla): unmask EnvironmentController panic — nested-disjunction split + UNCHANGED-tuple staging#187
zoratu merged 1 commit into
mainfrom
fix/exists-conjunction-nested-disjunction-mis-split

Conversation

@zoratu

@zoratu zoratu commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Two independent evaluator bugs cause next_states to panic (function application unsupported for value ModelValue("failed'")) on the Chandra-Toueg EnvironmentController spec (specifications/detector_chan96). Both stem from a prime that should be staged being left unstaged, so a later read resolves it to an unbound ModelValue. Each was isolated to a standalone minimal repro and given a differential-gate regression spec.

1. \E-quantified conjunction with a trailing nested disjunction

split_action_body_disjuncts mis-split \E i : /\ g /\ (\/ A \/ B) — an \E body whose top-level connective is /\ — into three top-level disjuncts [\E i : guards, \E i : A, \E i : B], distributing the \E and treating the inner disjunction's \/ as top-level. The orphaned A/B branches dropped the surrounding guards (moved[i] = "NO") and the shared primed conjuncts (procPause', UNCHANGED failed), so they fired from every state — even where the guard is false — producing spurious partial branches. A trailing constraint then read an unstaged failed' and panicked.

The existing conjunction guard in this function inspects the whole \E … text (which split_action_body_clauses reports as a single clause), so it never fired for the quantified shape; the fix runs the same conjunction check on the exists body.

This is EnvironmentController's ProcTick spurious-partial-branch bug and the same mechanism behind ACP's parProgNB over-generation.

2. UNCHANGED of a defined variable-tuple

UNCHANGED envVars where envVars == << procPause, moved, failed, F >> staged one phantom name envVars, not the component primes. The successor state is still correct (unchanged vars survive the state clone), so the gap is invisible until an action reads its own unchanged prime — as EnvironmentController's DELTAConstraint/PHIConstraint read failed' after the Crash/ProcTick crashed branch's UNCHANGED envVars.

New expand_unchanged_var resolves a defined nullary tuple operator to its component variables (recursively) at eval time, in both the interpreted and compiled Unchanged handlers.

Result

With both fixes EnvironmentController no longer panics and runs to completion. It still under-explores relative to TLC because of a separate, pre-existing disjunction-under-trailing-constraint issue in this spec (the constraint accepts each branch it sees, but the disjunction under a trailing conjunct yields too few branches) — tracked separately, not addressed here.

Validation

  • scripts/diff_tlc.sh: 18/18 — adds two regression specs, each matching TLC exactly:
    • ExistsGuardedNestedDisjunction — 170 distinct (== TLC), no violation; panics/over-generates without fix 1.
    • UnchangedDefinedTuple — 4 distinct (== TLC), no violation; panics without fix 2.
  • cargo test --release: 1485 passed / 0 failed.
  • New unit test split_action_body_disjuncts_keeps_exists_conjunction_with_trailing_nested_disjunction.

…it + UNCHANGED-tuple staging

Two independent evaluator bugs cause `next_states` to panic
("function application unsupported for value ModelValue(\"failed'\")")
on the Chandra-Toueg EnvironmentController spec. Both stem from a prime
that should be staged being left unstaged, so a later read resolves it to
an unbound ModelValue.

1. Exists-quantified conjunction with a trailing nested disjunction.
   `split_action_body_disjuncts` mis-split `\E i : /\ g /\ (\/ A \/ B)` —
   an `\E` body whose top-level connective is `/\` — into three top-level
   disjuncts `[\E i : guards, \E i : A, \E i : B]`, distributing the `\E`
   and treating the inner disjunction's `\/` as top-level. The orphaned
   A/B branches dropped the surrounding guards (`moved[i] = "NO"`) and the
   shared primed conjuncts (`procPause'`, `UNCHANGED failed`), so they
   fired from every state — even where the guard is false — and produced
   spurious partial branches. The existing conjunction guard inspects the
   whole `\E …` text (which `split_action_body_clauses` reports as one
   clause), so it never fired; the fix runs the same check on the exists
   *body*. This is the ProcTick spurious-partial-branch bug and the ACP
   parProgNB over-generation.

2. UNCHANGED of a defined variable-tuple. `UNCHANGED envVars` where
   `envVars == << procPause, moved, failed, F >>` staged one phantom name
   `envVars`, not the component primes. The successor state is still
   correct (unchanged vars survive the state clone), so the gap is
   invisible until an action READS its own unchanged prime — as
   EnvironmentController's DELTAConstraint/PHIConstraint read `failed'`
   after the Crash/ProcTick crashed branch's `UNCHANGED envVars`. New
   `expand_unchanged_var` resolves a defined nullary tuple operator to its
   component variables (recursively) at eval time, in both the interpreted
   and compiled Unchanged handlers.

With both fixes EnvironmentController no longer panics and runs to
completion. (It still under-explores relative to TLC due to a separate,
pre-existing disjunction-under-trailing-constraint issue — tracked
separately; not addressed here.)

Gate: diff_tlc 18/18 (adds ExistsGuardedNestedDisjunction 170==TLC and
UnchangedDefinedTuple 4==TLC), cargo test --release 1485 passed / 0 failed.
@zoratu
zoratu merged commit 76c7105 into main Jul 19, 2026
6 checks passed
@zoratu
zoratu deleted the fix/exists-conjunction-nested-disjunction-mis-split branch July 19, 2026 01:13
zoratu added a commit that referenced this pull request Jul 19, 2026
…ction split + instance primed-arg staging (#188)

EnvironmentController (Chandra-Toueg detector) explored only 36 distinct
states vs TLC's ~129,630 after the panic was fixed in #187. Root cause was
`localClock` never advancing: the ProcTick RECEIVE branch's
`Detector!Receive(i, inDelivery')` produced no successor, so its nested
`LocallyTick(i)` never ran, so every guard requiring `localClock > 0`
(message send/predict) stayed disabled and the whole message-passing state
space (the bulk of TLC's 130k) went unexplored. Two independent bugs, both
needed:

1. Leading guard-disjunction with shared conjuncts.
   `split_action_body_disjuncts` split `/\ (\/ a \/ b) /\ LocallyTick(i)
   /\ UNCHANGED ...` into `[/\ a, /\ b /\ LocallyTick /\ UNCHANGED]` — it
   stripped the leading `/\` and split at the guard's `\/`, absorbing the
   shared trailing conjuncts into only the LAST `\/` branch. The first
   guard-disjunct then fired WITHOUT the shared `LocallyTick`/primed
   conjuncts, so the clock never advanced. The `/\`+`\/` strip now only
   fires when the disjunction is the body's sole top-level conjunct
   (`/\ \/ b1 \/ b2 \/ b3`, e.g. 2PC `RS`), detected by layout via the new
   `leading_conjunct_is_sole` (count `/\` lines shallower than the `\/`
   bullets — robust to `expr.trim()` dedenting the first line, unlike a
   `split_action_body_clauses` count, which over-counts a lone
   disjunction-conjunct).

2. Instance-action arguments that read a next-state variable.
   `expand_action_call_multi` value-bound call arguments against the bare
   `ctx`, so `Detector!Receive(i, inDelivery')` (with `inDelivery'` staged by
   the preceding `CommChan!Deliver(i)`) read `inDelivery'` as an unbound
   ModelValue; Receive's `\E m \in incomingMessages` then errored and it
   produced no successor. Evaluate arguments in the caller's staged context
   (a superset — unprimed arguments are unaffected).

With both fixes EnvironmentController explores the full ~130k-state space
(36 -> 116k+ and matching TLC's scale; it still hits the pre-existing
pathological slow-tail near the end, tracked separately).

Gate: diff_tlc 19/19 (adds ClockDetectorMC, a self-contained instance-action
spec needing both fixes, 9 == TLC), cargo test --release 1486 passed / 0
failed. New unit test pins the RS-vs-Receive split distinction.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant