Add -V tla-export: export a Verus transition-system model to TLA+ - #49
Merged
Merged
Conversation
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>
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.
Summary
vir::tlaexports any module's(State, init, next, invariants)triple to TLA+ so thetlc_*MCP tools can model-check it, with nothing keyed on one project. Plan:verus-research/plans/tla_export.md.init(s)/next(pre, post)with a step enum and(State) -> boolinvariants; VerusSync'sstate_machine!output (init_by,next_by,Step); verus-tla'sinit()/next()closures withAction { precondition, transition }records, reduced symbolically through variables, record fields and calls returning closures.post == eon a whole state becomes one primed assignment per field, the shape TLC needs to enumerate.tagfor enums), 1-based sequences forSeq(every index shifted once;seq![a, b]is<<a, b>>), sets, functions forMap(DOMAIN,:>/@@), operators for spec fns in dependency order,IFchains formatch(a literal pattern an equality with the scrutinee, a range pattern its comparisons),LETforlet.0 <= a < b < nboundsabyn - 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 aDom_<Type>_<variant>_<field>hole per field, soStep::Upd(u16, u16)is never enumerated), else aCONSTANT 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 asAssert(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.#[invariant]s for VerusSync (the conjuncts of the generatedState::invariant, so<transition>_enabledpredicates are not checked), every() -> spec_fn(State) -> boolfor verus-tla, and every(State) -> boolspec fn for a hand-rolled model (one namedinvariantincluded) except oneinit/nextread, unprimed or primed (a guard), and one another selected invariant calls (a helper:bigininv(s) = big(s) ==> !marked(s)is checked insideinv, not alone). When none is checked, the.cfgsays so and lists the candidates with their reasons.-V tla-export=<module>:inv1,inv2checks exactly the named ones. The report'scandidateslists every candidate, included or not, with the reason./and%by anything but a positive literal go throughEuclidDiv/EuclidMod(Verus's Euclidean semantics; TLC's\divrounds 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.TypeOKkeeps every bounded integer the state holds (nat,uN,iN, through records, enum payloads, tuples andSeq/Set/Mapelements) in its type's range, conjoined toInitand primed toNext, 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'styped_variableslists them.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 likepre.x > 0). It is not a refusal and taints nothing. A literal is decided at export: kept in range, refused out of it.transitionslists each transitionNextreaches (an operator called in a disjunct,IF/matcharm orexists, with the operators it conjoins) and the variables it never assigns (only a conjunct-levelv' = eor=~=(VerusSync'stmp_assert => ...lowering ofassertkeeps its consequent at conjunct level), a conjunct-level call to an operator that assigns it, or anIF/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 literalfalse(VerusSync'sdummy_to_use_type_params => falsearm) 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.yis printedy' = 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.cfgnames 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'sinit_unassignedand a.cfgline name any variable Init never assigns (TLC: "current state is not a legal state"), and0 == s.xis printedx = 0;s.x == s.yassigns whichever an earlier conjunct left unassigned (printed on the left); a bare or negated bool field (s.done,!post.done) is printeddone = TRUE/done' = FALSEand assigns it, in Init and Next.-V tla-export=<module>[:inv1,inv2]runs beforeast_simplify, under--no-verifytoo, and writes<State>_tla.tla(named after the module it declares, so TLC loads it directly), a.cfgskeleton and a.tla.jsonreport under the log directory.tag, so a field namedtag(ortagplus 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 variantf_rec, the state passed as a record (a recursive root is a wrappercnt == cnt_rec([n |-> n, m |-> m])), and only operators in a call cycle are declaredRECURSIVEwith their arity; a callee givenpostis a primed variant of its operator rather than a primed application.examples/tla/has one fixture per shape (plustoggle_sync.rs, VerusSync with parameterless transitions, andassert_sync.rs, VerusSync with anassert) and the hand-writtenCounter.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'sintparameter, 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 --releaseclean.rust_verify_test --test tla_export: exports the four fixtures and probes for each shape the export once got wrong (including a crate fn namedunwrap, negative div/mod, a field namedvars, a guard predicate, an explicit invariant list, an unknown invariant name, state fieldsa/bbeside Euclidean division, au8cast,Option<bool>besideOption<int>, a transition leaving a field unassigned, bounded-integer fields kept in range byTypeOK(TLC-checked), a trait impl call, guard-only and branching assignments, escaped char literals,pre.y == post.yand=~=with the post state on the right (TLC-checked), equalities of two primed fields, a hand-rolledinvariant(TLC-checked), the.cfgcomment 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 leavesu8, a guardedas nat/as u8decrement (TLC-checked), afalsebranch counted as assigning everything (TLC-checked), what Init leaves unassigned (plus a fixed Init, TLC-checked), a field namedtag(TLC-checked),u8/i8binders bounded by their type and au32hole namedDom_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 VerusSyncassertcounted as assigning (TLC-checked), a recursive invariantnextreads onpostplus 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-verifyof a crate with an unprovable proof (TLC-checked), a step withPut(u16, u16),Pair(u8, u8)andMixed(Option<int>, u8, u8)left as per-field holes beside a wholeNudge(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 helpersame(s) = s.x == s.yread as it is printed (x = y, soyis reported unassigned afters.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/Setbinders and step payloads as holes named after the type (Dom_Seq_u8,Dom_Step_Store_v0) and aSet<bool>asSUBSET 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)); withTLA2TOOLS_JARset, SANY parses every export (checked by exit status and its semantic-processing line) and TLC checks counter (againstCounter.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 warningsandcargo 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'stla_exportused to expectcrate-<State>.tlaand rename it; https://github.com/BasisResearch/verus-tools-mcp/pull/55 (branchkg/tlc-tools) takes<State>_tla.tlaas written. Merge the two together: this one alone would leave the tool writingState_tla_tla.tla.🤖 Generated with Claude Code