Skip to content

Add -V tla-export: export a Verus transition-system model to TLA+ - #49

Merged
kiranandcode merged 23 commits into
mainfrom
kg/tla-export
Sep 26, 2026
Merged

kiranandcode merged 23 commits into
mainfrom
kg/tla-export

Conversation

@kiranandcode

@kiranandcode kiranandcode commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

vir::tla exports any module's (State, init, next, invariants) triple to TLA+ so the tlc_* MCP tools can model-check it, with nothing keyed on one project. Plan: verus-research/plans/tla_export.md.

  • Three shapes recognised. Hand-rolled init(s) / next(pre, post) with a step enum and (State) -> bool invariants; VerusSync's state_machine! output (init_by, next_by, Step); verus-tla's init()/next() closures with Action { precondition, transition } records, reduced symbolically through variables, record fields and calls returning closures.
  • Variables are the state struct's fields. A state-typed parameter in the pre or post role is dropped from its operator and read as the unprimed or primed variables; post == e on a whole state becomes one primed assignment per field, the shape TLC needs to enumerate.
  • Types and definitions. Records for datatypes (a tag for enums), 1-based sequences for Seq (every index shifted once; seq![a, b] is <<a, b>>), sets, functions for Map (DOMAIN, :>/@@), operators for spec fns in dependency order, IF chains for match (a literal pattern an equality with the scrutinee, a range pattern its comparisons), LET for let.
  • Quantifier bounds. From the guard (set membership, a map's domain, integer ranges from inequalities and chained comparisons, through binders bound later: 0 <= a < b < n bounds a by n - 2), intersected with the binder's integer type range (the type also closes a side the guard leaves open); from a finite type of at most 2^10 values (booleans, u8/i8, datatypes whose fields are bounded; a variant whose fields together take more has a Dom_<Type>_<variant>_<field> hole per field, so Step::Upd(u16, u16) is never enumerated), else a CONSTANT Dom_<Type> the cfg supplies, named after the type (Dom_u32, Dom_int), reported as a hole. Everything outside the fragment (choose, old, closures outside a known call, floats) is a refusal with its location, printed as Assert(FALSE, "...") so TLC stops wherever it is evaluated; an invariant that reaches one is left out of the .cfg. Hole constants are left unassigned (commented) in the .cfg, so TLC stops until they are given.
  • Invariants. By default: the #[invariant]s for VerusSync (the conjuncts of the generated State::invariant, so <transition>_enabled predicates are not checked), every () -> spec_fn(State) -> bool for verus-tla, and every (State) -> bool spec fn for a hand-rolled model (one named invariant included) except one init/next read, unprimed or primed (a guard), and one another selected invariant calls (a helper: big in inv(s) = big(s) ==> !marked(s) is checked inside inv, not alone). When none is checked, the .cfg says so and lists the candidates with their reasons. -V tla-export=<module>:inv1,inv2 checks exactly the named ones. The report's candidates lists every candidate, included or not, with the reason.
  • Arithmetic and names. / and % by anything but a positive literal go through EuclidDiv/EuclidMod (Verus's Euclidean semantics; TLC's \div rounds down and its % rejects a negative divisor). Option's spec helpers (spec_unwrap, arrow_0, is_some, ...) are recognised by the receiver's type, so a crate function of the same name is left alone. A state field named like a generated name (vars, Init, ...) or a TLA+ reserved word is held in a renamed variable.
  • Integer types. TypeOK keeps every bounded integer the state holds (nat, uN, iN, through records, enum payloads, tuples and Seq/Set/Map elements) in its type's range, conjoined to Init and primed to Next, so a step that would leave the type is disabled as in Verus; bounds beyond TLC's 32-bit integers are left out. The report's typed_variables lists them.
  • Casts. A widening cast (the operand's type lies in the target's, u8 as u16, as nat) is the identity. A narrowing one ((pre.x - 1) as nat, as u8, ...) is a value check, LET c == e IN IF <c in range> THEN c ELSE Assert(FALSE, "... value out of range of <type> in a cast at <location>"): Verus gives an out-of-range value some unspecified value of the type, so TLC stops only in a reached state whose value leaves the type (never behind a guard like pre.x > 0). It is not a refusal and taints nothing. A literal is decided at export: kept in range, refused out of it.
  • Transitions. The report's transitions lists each transition Next reaches (an operator called in a disjunct, IF/match arm or exists, with the operators it conjoins) and the variables it never assigns (only a conjunct-level v' = e or =~= (VerusSync's tmp_assert => ... lowering of assert keeps its consequent at conjunct level), a conjunct-level call to an operator that assigns it, or an IF/match/disjunction all of whose branches assign it, counts, two conjunct-level implications of complementary guards (g/!g, x < y/x >= y, x == y/x != y) assigning what both consequents do, in Next and Init, a branch that is the literal false (VerusSync's dummy_to_use_type_params => false arm) counting as assigning everything, together with what the transition's callers assign around it, so a frame condition factored out of a disjunction counts for every branch; pre.y == post.y is printed y' = y, since TLC assigns only a primed variable on the left, and of two primed fields the one not yet assigned goes on the left); the .cfg names any that leaves one unassigned and says what the check can miss (conjunct order, v' \in S), since TLC stops there with "successor state not completely specified". Init is checked the same way: the report's init_unassigned and a .cfg line name any variable Init never assigns (TLC: "current state is not a legal state"), and 0 == s.x is printed x = 0; s.x == s.y assigns whichever an earlier conjunct left unassigned (printed on the left); a bare or negated bool field (s.done, !post.done) is printed done = TRUE / done' = FALSE and assigns it, in Init and Next.
  • Flag. -V tla-export=<module>[:inv1,inv2] runs before ast_simplify, under --no-verify too, and writes <State>_tla.tla (named after the module it declares, so TLC loads it directly), a .cfg skeleton and a .tla.json report under the log directory.
  • Trait impls. A trait method call Verus resolved to an impl exports the impl's function.
  • Names. An enum value's record carries its variant in tag, so a field named tag (or tag plus underscores) takes one more underscore (tag_). Every local name is fresh within its operator and distinct from operators, variables and constants (TLA+ forbids redefining a name in its scope); a recursive function (decreases) is printed only as its record variant f_rec, the state passed as a record (a recursive root is a wrapper cnt == cnt_rec([n |-> n, m |-> m])), and only operators in a call cycle are declared RECURSIVE with their arity; a callee given post is a primed variant of its operator rather than a primed application.

examples/tla/ has one fixture per shape (plus toggle_sync.rs, VerusSync with parameterless transitions, and assert_sync.rs, VerusSync with an assert) and the hand-written Counter.tla. The counter export model-checks under TLC with exactly the hand-written spec's numbers (28 generated, 18 distinct, the same six violations); the VerusSync export is clean apart from the step's int parameter, which is a hole; the verus-tla export has no hole or refusal and checks under TLC (10 distinct states, both invariants hold).

Test plan

  • vargo build --release clean.
  • rust_verify_test --test tla_export: exports the four fixtures and probes for each shape the export once got wrong (including a crate fn named unwrap, negative div/mod, a field named vars, a guard predicate, an explicit invariant list, an unknown invariant name, state fields a/b beside Euclidean division, a u8 cast, Option<bool> beside Option<int>, a transition leaving a field unassigned, bounded-integer fields kept in range by TypeOK (TLC-checked), a trait impl call, guard-only and branching assignments, escaped char literals, pre.y == post.y and =~= with the post state on the right (TLC-checked), equalities of two primed fields, a hand-rolled invariant (TLC-checked), the .cfg comment when no invariant is checked, whole-state =~= (TLC-checked), a frame condition factored out of a disjunction (TLC-checked, plus real gaps in a called and an inline branch), widening casts and a narrowing value check that holds (TLC-checked), a narrowing cast TLC stops at when the reached value leaves u8, a guarded as nat/as u8 decrement (TLC-checked), a false branch counted as assigning everything (TLC-checked), what Init leaves unassigned (plus a fixed Init, TLC-checked), a field named tag (TLC-checked), u8/i8 binders bounded by their type and a u32 hole named Dom_u32 (TLC-checked), a chained guard bounding a binder through a later one (TLC-checked), a guard read on the post state left out of the invariants (TLC-checked), updates under a VerusSync assert counted as assigning (TLC-checked), a recursive invariant next reads on post plus a mutual recursion, by default and named (TLC-checked), complementary implications in Next and Init (TLC-checked) and a non-complementary one reported, seq! literals (TLC-checked), the VerusSync fixtures' transitions/init_unassigned, an export under --no-verify of a crate with an unprovable proof (TLC-checked), a step with Put(u16, u16), Pair(u8, u8) and Mixed(Option<int>, u8, u8) left as per-field holes beside a whole Nudge(u8) (TLC-checked), helpers an invariant calls left out by default and checked when named (TLC-checked), an Init equality of two fields assigning the unassigned one (TLC-checked), literal and range match patterns (TLC-checked), bare/negated bool fields assigning in Init and Next (TLC-checked), and swapped states (moved(post, pre)) and one state given twice (frame(post, post), same(pre, pre)) called in the record variant (TLC-checked), an Init helper same(s) = s.x == s.y read as it is printed (x = y, so y is reported unassigned after s.x == 0; TLC-checked), a helper checked on its own when the invariant calling it reaches a refusal (TLC-checked), and a one-variant fieldless enum enumerated as {[tag |-> "unit"]} rather than a hole (TLC-checked), Seq/Map/Set binders and step payloads as holes named after the type (Dom_Seq_u8, Dom_Step_Store_v0) and a Set<bool> as SUBSET BOOLEAN, never records of their representation (TLC-checked), and an or-pattern binding nothing as a disjunction (Step::A | Step::B, Step::C(1) | Step::C(2); TLC-checked)); with TLA2TOOLS_JAR set, SANY parses every export (checked by exit status and its semantic-processing line) and TLC checks counter (against Counter.tla), adder, mutex and probes. Passes with and without the jar.
  • rust_verify_test --test examples -- examples_tla: the fixtures verify.
  • cargo fmt -- --check; vargo clippy -p vir|rust_verify|rust_verify_test -- -D warnings and cargo clippy -p rust_verify_test --test tla_export -- -D warnings.
  • cargo clippy -p rust_verify_test --test tla_export -- -D warnings.

Lands together with verus-tools-mcp#55

This PR writes the export as <State>_tla.tla. verus-tools-mcp's tla_export used to expect crate-<State>.tla and rename it; https://github.com/BasisResearch/verus-tools-mcp/pull/55 (branch kg/tlc-tools) takes <State>_tla.tla as written. Merge the two together: this one alone would leave the tool writing State_tla_tla.tla.

🤖 Generated with Claude Code

kiranandcode and others added 23 commits September 21, 2026 18:47
vir::tla exports any module's (State, init, next, invariants) triple to
TLA+ so TLC can check it, keying on nothing from one project. It
recognises three shapes: hand-rolled init(s) and next(pre, post) with a
step enum and (State) -> bool invariants; VerusSync's state_machine!
output (init_by, next_by, Step); verus-tla's init()/next() closures with
Action { precondition, transition } records, which it reduces
symbolically through variables, record fields and calls returning
closures. The state struct's fields are the variables: a state-typed
parameter in the pre or post role is dropped from its operator and read
as the unprimed or primed variables, and post == e on a whole state
becomes one primed assignment per field, the shape TLC needs. Datatypes
are records (a tag for enums), Seq a 1-based sequence with every index
shifted once, Set a set, Map a function (DOMAIN, :> @@ from TLC), spec
fns operators in dependency order, match an IF chain over tags, let a
LET. A quantifier is bounded from its guard (set membership, a map's
domain, an integer range from inequalities and chained comparisons),
from a finite type (booleans, enums whose fields are bounded), or else
by a CONSTANT Dom_<Type> the cfg must supply, reported as a hole; every
construct outside the fragment (choose, old, closures outside a known
call, floats) is a refusal with its location, printed as TRUE.

rust_verify gains -V tla-export=<module>, run before ast_simplify so
match and constructor updates are still visible, writing
<State>.tla, a .cfg skeleton and a .tla.json report (shape, variables,
init, next, invariants, holes, refusals) under the log directory.

examples/tla holds one fixture per shape. The counter export
model-checks under TLC with the same counts as a hand-written spec of
the same counter; the VerusSync export is clean but for the step's int
parameter; the verus-tla export parses with no refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Drop the Exporter's unused krate field and lifetime, the unreachable
BinaryOpr arm (ExtEq is its only variant) and a needless mut; format
the touched files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- examples/tla: commit counter.rs, adder_sync.rs, mutex_tla.rs and the
  hand-written Counter.tla/.cfg the README compares against.
- rust_verify_test/tests/tla_export.rs exports each fixture and checks the
  report; with TLA2TOOLS_JAR it parses every export with SANY and runs TLC
  (the counter matches Counter.tla: 28 generated, 18 distinct, six
  violations). Probes cover each shape below. examples/tla is verified by
  the examples test.
- Files are written as <State>_tla.tla/.cfg/.tla.json, named after the
  module they declare, so TLC loads them directly.
- Every local name (parameter, let, pattern binding, quantifier binder, and
  the m__/s__/i__/k__ helpers) is fresh within its operator and distinct
  from operators, variables and constants: nested LETs, shadowing lets and
  a parameter named like a state field no longer redefine a name.
- RECURSIVE declarations carry the operator's arity.
- A match guard is evaluated inside the pattern's bindings.
- Records built by a call (verus-tla's acquire(t)) bind the call's
  arguments with LETs and carry the pre/post roles.
- Seq::new's closure argument is no longer printed as a refused closure,
  and closure-valued lets stay symbolic.
- A callee given `post` for its single state parameter is a primed variant
  of the operator, instead of priming the whole application (which also
  primed its other arguments).
- bound_from_type stops at a recursive datatype (a hole, not a stack
  overflow); a nat/unsigned binder is bounded below by 0; guards are found
  under explicit triggers; nested binders nest their quantifiers.
- A refusal prints Assert(FALSE, "...") rather than TRUE; an invariant
  that reaches one is left out of the .cfg (report: skipped_invariants).
  Hole constants are left unassigned (commented) in the .cfg, so TLC stops
  instead of quantifying over {}.
- Report.actions, always empty, is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d, variable names

- VerusSync: the invariants are the conjuncts of the generated
  State::invariant, so the `<transition>_enabled` predicates of
  parameterless transitions are no longer checked as invariants.
- `-V tla-export=<module>:inv1,inv2` checks exactly the named invariants.
  Without a list the selection is unchanged, but the .tla.json report now
  lists every candidate with whether it was included and why, and a
  predicate init/next read unprimed is reported (and noted in the .cfg)
  rather than dropped silently.
- Option's spec helpers are recognised by the receiver's type, not by the
  name's suffix, so a crate fn named `unwrap` is left alone; vstd's
  `spec_unwrap`/`arrow_0`/`spec_expect`/`spec_unwrap_or` are now handled.
- `/` and `%` by anything but a positive literal go through EuclidDiv and
  EuclidMod, matching Verus's Euclidean semantics (TLC's `\div` rounds down
  and its `%` rejects a negative divisor).
- A state field whose name the module generates (`vars`, `Init`, ...) or
  TLA+ reserves is held in a renamed variable; the record label is kept.

Adds examples/tla/toggle_sync.rs and tests for each case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nassigned variables

- EuclidDiv/EuclidMod take reserved parameters (euclid_a, euclid_b), so a
  state field named `a` or `b` no longer redefines a variable (SANY
  rejected the module).
- A cast to a bounded type (`as u8`, `as nat`, ...) is refused unless it
  casts a literal in range; printing it as the identity let TLC leave the
  type and report violations Verus rules out.
- Bounding a quantifier by its type substitutes the datatype's type
  arguments into its fields, and names the hole after the instantiation:
  Option<bool> is bounded by BOOLEAN, Option<int> gets
  Dom_Option_int_Some_v0 of its own.
- The report lists each transition Next reaches with the variables it
  never primes (`transitions`), the .cfg names those that leave one
  unassigned, and the summary line counts them.
- Tests for each; PROBES' recursive function no longer uses `as nat`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…assignments, string escapes

- TypeOK keeps every bounded integer the state holds (nat, uN, iN, through
  records, enum payloads, tuples and Seq/Set/Map elements) in its type's
  range, conjoined to Init and primed to Next, so a step that would leave
  the type is disabled as in Verus (TLC had run a nat below 0 and a u8 to
  256 and reported violations Verus rules out). The report lists the
  constrained variables in `typed_variables`.
- A trait method call Verus resolved to an impl (DynamicResolved) exports
  the impl's function instead of refusing the bodiless declaration.
- A variable counts as assigned only by a conjunct-level `v' = e` (or an
  IF/match/disjunction all of whose branches assign it); guards reading v',
  predicates on the post state and negated equalities no longer count, and
  calls outside conjunct level no longer join a transition. The README and
  the .cfg say what the check can still miss.
- String and char literals escape backslashes (and quotes, newlines, tabs).

Tests: bounded fields checked under TLC, a trait impl, guard-only and
branching assignments, and escaped char literals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt empty invariant set

- An equality with a primed field only on the right (`pre.y == post.y`,
  `pre.s =~= post.s`) is printed `y' = y`: TLC assigns only a primed
  variable on the left, and stopped on `y = y'` while the report counted y
  as assigned.
- With a primed field on both sides, the one assigned earlier in the
  operator (or before an enclosing branch) goes on the right and the other
  is assigned; when neither is, nothing is, and the transition is reported.
- A hand-rolled `(State) -> bool` spec fn named `invariant` is checked by
  default like any other.
- When no invariant is checked, the .cfg says so and lists the candidates
  with their reasons.
- Tests: reversed `==` and `=~=` under TLC, two primed fields (assigned
  before, in a branch, and neither), `invariant` checked under TLC, and the
  empty-invariant .cfg comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ction, widening casts

- `post =~= State { .. }` (and `s =~= ..` in init) goes through the same
  per-field assignment as `==`; it printed a record equality TLC could not
  evaluate while the report counted every variable assigned.
- A conjunct-level call assigns what its operator assigns on every path, and
  a transition counts what its callers assign around it (outside its branch
  or inside it), so `(a || b) && post.z == pre.z` no longer reports `a`, `b`
  and `next` as leaving variables unassigned. An operator that branches is
  reported only for a variable none of its called branches is reported for.
- A cast whose operand's type lies in the target range (`u8 as u16`, `as
  nat`, `as i16`) is the identity rather than a refusal.

Tests (TLC-checked with TLA2TOOLS_JAR): whole-state =~=, a frame condition
factored out of a disjunction (plus a real gap in a called and in an inline
branch), and widening versus narrowing casts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…riables, `tag` fields, checked narrowing casts

- A branch that is the literal `false` (VerusSync's
  `dummy_to_use_type_params => false` arm, `if c { false } else { .. }`)
  never holds and counts as assigning every variable, so `next_by` is no
  longer reported as leaving every variable unassigned.
- Init is checked for variables it never assigns (report
  `init_unassigned`, a `.cfg` line), by a walk kept apart from the
  primed-state tracking; `0 == s.x` is printed `x = 0`.
- A field named `tag` (or `tag` plus underscores) takes one more
  underscore, so it no longer collides with the variant label (SANY
  rejected `[tag |-> "A", tag |-> 1]`).
- A narrowing cast of a non-literal is a value check, `IF <in range> THEN
  v ELSE Assert(FALSE, ...)`, instead of a refusal: TLC stops only in a
  reached state that leaves the type, and nothing is tainted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Sync asserts, primed guards, chained bounds

- A quantifier domain read off the guard is intersected with the binder's
  integer type range (`x < 300` over u8 was 0..299, so TLC refuted a
  forall Verus proves); the type closes a side the guard leaves open, and
  an unguarded binder of at most 16 bits takes its whole range.
- Hole constants and their `typ` are named after the integer range
  (`Dom_u32`, `Dom_nat`) rather than `Dom_int` for every integer type.
- VerusSync lowers `assert` to `tmp_assert => (update ...)`; the
  consequent stays at conjunct level, so its updates count as assigning
  and the transition is no longer reported (new fixture assert_sync.rs).
- A predicate init/next reads on the post state (`lit(post)`) is a guard
  and is left out of the invariants, as one read on the pre state is.
- Chained guards bound a binder through later binders
  (`0 <= a < b < len` gives `a` the domain 0..len - 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bolic closures refused

A recursive spec fn read on the post state got a primed RECURSIVE variant;
SANY gives every RECURSIVE operator the highest level among them, so the
unprimed one (and any invariant calling it) became an action and the module
failed to parse. A recursive function other than a root now takes its state
parameters explicitly and is called with `[f |-> v, ...]` or `[f |-> v', ...]`.

A closure-valued variable held only symbolically (a `let` closure, a closure
parameter) was printed by its bare name when passed on, an unknown operator
to SANY. It is now a refusal.

Tests: a recursive state fn read on pre and post beside an invariant using it
(SANY-parsed, TLC-checked), and a `let` closure passed to a spec fn
(refused, SANY-parsed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… refused as the whole call, None checks assign

- A call whose state argument is neither the pre nor the post state (the
  record a recursive function holds, a constructed state) goes to the
  callee's record variant `f_rec`, which takes every parameter explicitly,
  so a recursive walk can call a per-index predicate. It was refused.
- A call swapping the pre and post states is one refusal for the whole
  call. The Assert used to be printed as an extra argument, giving the
  operator more arguments than its definition, so SANY rejected the module.
- `s.o is None` and `s.o.is_none()` (a variant without fields) print as
  `o = [tag |-> "None"]`, which assigns `o` in Init and `o'` in a
  transition, and Init's unassigned check counts it.
- Tests: a swapped call (SANY, TLC stops at the refusal), a record state
  passed to a helper from a recursive walk and a constructed state (TLC),
  None checks in Init and Next (TLC).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…entary implications assign, seq! literals

- A function with `decreases` is printed only as its record variant
  (`f_rec`), roots included: a recursive root is a wrapper applying it to
  the state (`cnt == cnt_rec([n |-> n, m |-> m])`). A primed copy declared
  RECURSIVE made SANY reject the module. Only operators in a call cycle are
  declared RECURSIVE.
- A conjunct-level implication is a branch beside an empty one; two with
  complementary guards (`g`/`!g`, `x == y`/`x != y`, `x < y`/`x >= y`)
  assign what both consequents do, in Next and in Init. The unassigned
  report no longer flags `pre.f ==> post.x == 1` beside `!pre.f ==>
  post.x == 2`.
- `seq![a, b]` (an array literal viewed through the array's View impl) is
  the tuple `<<a, b>>` instead of a refusal.

Tests (SANY and TLC checked): a recursive invariant next reads on post,
a recursive invariant and a mutual recursion, by default and named;
complementary implications in Next and Init, and a non-complementary one
reported; seq! literals in Next and invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…exists body a guard, TLA+ proof keywords reserved

- `peel` and `read_var` look through `#[trigger]` and mode coercions, so
  `forall|k| #[trigger] m.dom().contains(k) ==> ...` is bounded by
  `DOMAIN m` rather than a `Dom_int` hole.
- An `exists` body that is a single guard (`exists|k| m.dom().contains(k)`,
  `exists|i| 0 <= i < s.len()`) is read as the guard.
- The TLA+ proof-language keywords (STATE, ACTION, NEW, USE, BY, DEF, QED,
  ...) are reserved, so a spec const named `STATE` is printed `STATE_`.
- Probe: both trigger idioms, both exists shapes and a `STATE` const, with
  no hole and no refusal, SANY-parsed and TLC-checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 2^10 values

The export ran inside verify_crate_inner, which --no-verify skips, so the
flag pair exited 0 and wrote nothing; it now runs in verify_crate on the
VIR crate before simplification, whether or not anything is verified.

bound_from_type enumerated every integer type of at most 16 bits and
multiplied a variant's fields together, so a Step::Upd(u16, u16) was a set
of 2^32 records TLC could not get through. A domain read off a type alone
now takes at most 2^10 values: a larger integer type is a Dom_<type> hole,
and a variant whose fields together take more has a hole for each field of
more than one value (dropping the holes that bounded those fields).

Tests: an export under --no-verify of a crate with an unprovable proof
(TLC-checked against the counter's numbers), and a step with Put(u16, u16),
Pair(u8, u8) and Mixed(Option<int>, u8, u8) beside a whole Nudge(u8) and a
Dom_u16 binder (TLC-checked through an MC module).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a datatype's union, tuple holes named after their elements

- verus-tla: `init()` and the invariants must return a closure over the
  state type (and `next()` over two of it), so a `() -> spec_fn(int) ->
  bool` helper is no longer checked as an invariant with the state record
  as its argument.
- A domain read off a type is capped at 2^10 values for a datatype's
  variants together too, not only per variant: when the union would take
  more, every variant of more than one value has a hole per field.
- A tuple binder is bounded like a one-variant datatype (its values TLA+
  tuples), and its holes are named after its element types
  (`Dom_tuple2_u8_u8_v0`), so binders of different tuple types never
  share a constant.
- Tests for each (TLC-checked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d, literal and range patterns, bare bool fields assign

- A candidate another selected invariant calls is its helper, not an
  invariant (`inv(s) = big(s) ==> !marked(s)` no longer checks `big`
  alone); the report and .cfg say which invariant calls it.
- Init tracks what earlier conjuncts assigned: `s.x == s.y` assigns the
  one still unassigned and is printed with it on the left.
- Literal and range match patterns print as comparisons of the scrutinee
  instead of a refusal.
- A bare or negated bool field at conjunct level prints as `f = TRUE`/
  `f = FALSE` (primed on post) and assigns it, in Init and Next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its record variant, SANY checked by exit status and marker

A call whose pre and post states are swapped (`moved(post, pre)`) or give
one state twice (`frame(post, post)`, `same(pre, pre)`) was a refusal,
which in Next stopped TLC on every step. It is now called in the callee's
record variant, each state passed as its record.

The test harness's sany() now asserts java's exit status and SANY's
"Semantic processing of module <M>" line, so a jar that cannot run SANY
no longer passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hecked caller, a one-value datatype enumerated

init_assigns follows a call with an empty before-set, since the callee is
printed once and orients s.x == s.y by its own conjuncts. A helper is left
out only when a caller that is in the .cfg calls it; a helper of an
invariant that reaches a refusal is checked on its own. A datatype of one
variant without fields is enumerated as {[tag |-> "unit"]} rather than
a hole. Tests for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esentation; an or-pattern binding nothing a disjunction

A domain read off a type no longer walks a collection's Rust
representation: Seq and Map (and any never-transparent datatype, which VIR
gives one fieldless variant) are holes named after the type, and a Set is
SUBSET of its elements' domain when that has at most 2^10 subsets and no
hole, else a hole. An or-pattern whose alternatives bind nothing prints as
the disjunction of their conditions; a constructor pattern's condition is
parenthesised so it stands alone there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…CK_DEADLOCK FALSE in the cfg

VerusSync lowers `assert` to `tmp_assert => (update ...)`. Under
--no-verify nothing proves the assertion, and when it failed TLC stopped
with an evaluation error on an unassigned variable. It is now printed as
`IF tmp_assert THEN (update ...) ELSE Assert(FALSE, msg)`, the updates
still at conjunct level. The message names the transition and the
assertion's location (the range of its operands, the macro giving the
`&&` its own span), and of several asserts the first that fails, following
the macro's `tmp_assert_k == tmp_assert_{k-1} && c_k` chain.

The .cfg sets CHECK_DEADLOCK FALSE: Verus has no notion of deadlock, so
TLC run on the export as written no longer reports one (the mutex fixture
did).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y; reals refused

A conjunct-level call now adds what its operator assigns on the unprimed
state to the caller's, so `init(s) = x_zero(s) && s.x == s.y` prints
`y = x` (it printed `x = y`, which TLC cannot evaluate, while the report
said every variable was assigned). A real literal and a conversion
between int and real are refused like a float literal: TLC rejects a
module holding a real outright.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tatypes apart in hole constants

A char is a TLA+ string, so `c as u32` became a range check on a string
and `'a' <= c` a string ordering, both TLC type errors rather than
refusals. Both are now refused where they occur, and a char binder's guard
gives no range (its domain is the hole Dom_char).

Hole constants named a datatype by its last segment, so `a::Id` and `b::Id`
shared one constant for fields of different types. The first datatype of a
name keeps it; another of the same name takes a suffix (`Id_2`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kiranandcode
kiranandcode merged commit b4c0903 into main Sep 26, 2026
24 checks passed
@kiranandcode
kiranandcode deleted the kg/tla-export branch September 26, 2026 03:56
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