Skip to content

Keep record-flattening refactorings tractable in TLAPS - #285

Open
lemmy wants to merge 2 commits into
tlaplus:mainfrom
lemmy:mku-records
Open

Keep record-flattening refactorings tractable in TLAPS#285
lemmy wants to merge 2 commits into
tlaplus:mainfrom
lemmy:mku-records

Conversation

@lemmy

@lemmy lemmy commented Jul 26, 2026

Copy link
Copy Markdown
Member

Two independent changes for behaviour-preservation proofs over a flattened state record:

  1. Expr.Elab: fold record-literal EXCEPT during normalization (prevents front-end term explosion).
  2. SMT encoding: Encode.Rewrite.simpl_receq decomposes an equality of two record constructors into its field equalities (avoids record/function extensionality on the back end).

Both fire only on explicit record constructors and never push a selection/update/equality through IF (unsound unless the guard is Boolean), so neither assumes a type for any subterm.

Motivation

Larger, mechanical refactorings of existing specs are likely to become more common as specs are increasingly synthesized and rewritten by AI tooling. Proving one behaviour-preserving -- Next <=> R_Next, per action, under a refinement mapping relating the two variable sets -- is thus a shape TLAPS should discharge cheaply. The FlashProtocol flattening in tlaplus/Examples#216 (FlashWithMutexEquiv, a >20-field state record) is the working example.

Flattening a record-valued state variable into separate variables (replace Sta by dir, p, ...) is one such refactoring: the refinement mapping glues them back, StaBar == [dir |-> dir, p |-> p, ...], so per action the equivalence reduces to comparing the two next-state records. The obligations are therefore dominated by EXCEPT-updates of, and equalities between, wide record CONSTRUCTORS.

Discharging them is not a formality: in the Flash flattening the per-action proof refuted the refactoring and pinpointed a real divergence that model checking had missed -- in NI_Local_GetX_PutX under ~Dir.Dirty /\ elsifCond /\ ~Dir.Local the flat spec left Proc unchanged where the record spec invalidates the home cache line -- fixed in tlaplus/Examples#216 ("Fix NI_Local_GetX_PutX to invalidate Proc[Home] when not Dir.Local"). No reachable state and none of the invariants distinguished the two encodings.

1. Front-end blow-up: fold record-literal EXCEPT

The refinement mapping expands the update to [ recLit EXCEPT !.h ... = v ] over a wide constructor. The naive EXCEPT desugaring re-embeds the base once per path component and per exspec -- multiplicative: one Flash per-action obligation expanded to millions of terms in Expr.Elab.normalize, exhausting memory before any backend ran.

Fix: fold each update into the constructor in place (materialising a shared prefix once), and run let_normalize before except_normalize so LET-bound constructors are exposed, not re-embedded. Result is linear in the record width. A size regression guards it (test/regression_tests/record_except_explosion_test.tla): at 4 layers the normalized obligation is ~5000 printed lines with the fold, >=39000 without (>=9M at 6 layers).

2. Back-end cost: decompose record-constructor equalities

A record is a function whose domain is its field names, so rec1 = rec2 is discharged via FunExt plus RecIsafcn/RecDomDef/RecAppDef; instantiating the nested \A x \in DOMAIN dominates for wide records and for obligations with many such equalities. simpl_receq reduces this syntactically on constructors (domain and field values statically known):

[h1 |-> a1, ..., hn |-> an] = [h1 |-> b1, ..., hn |-> bn]
  -->  a1 = b1 /\ ... /\ an = bn      (fields paired by name)

The result is ground, quantifier-free, and emits no extensionality axiom. It runs in the SMT-LIB pipeline before type synthesis, recurses into nested constructors, decomposes only equal field sets (unequal domains left to the solver), and rewrites equalities in every position; Zenon/Isabelle are unaffected.

Performance (FlashWithMutexEquiv, 266 obligations, from scratch, --threads 8 --stretch 30): with simpl_receq 1046 s, all on Z3; without ~1490 s, the widest equality (ABS_NI_Local_GetX_PutX) times out on Z3 and falls back to zenon. ~30% wall-clock and no fallback.

Prior art

The old SMT backend already specialized record equality (finite extensionality instances); the new minimal-axiom encoding ("a record is a function") dropped it. simpl_receq reinstates it as a pre-encoding REWRITE, not an axiom: it removes the equality (and the outer \A f, g) entirely, so it costs the solver nothing and cannot misfire.

Deferred: tuple constructors

The same decomposition applies to tuple literals (arity fixed by syntax, position as field name; drops the Tup* axioms), but is deferred: flattening produces records, not tuples, so its payoff here is less clear. Sequences and arbitrary function constructors stay on FunExt (domain not a statically known finite enumeration).

lemmy and others added 2 commits July 25, 2026 16:33
Normalize `[ recLit EXCEPT !.h ... = v ]` by folding updates into the
record constructor in place, instead of re-embedding the base once per
path component and per exspec. The naive desugaring blows up
multiplicatively when updates share a leading path component, or when the
base is a wide record literal -- as when a refinement mapping / INSTANCE
substitution expands a defined operator to a record constructor -- turning
one proof obligation into millions of normalized terms.

except_normalize now applies exspecs left-to-right, materializing each
shared prefix once and grouping only adjacent same-prefix updates to
preserve EXCEPT semantics. A `Dot`/`FcnApp` selection on a record
constructor collapses to the selected field, keeping field-by-field
projection of a wide literal linear. normalize runs let_normalize before
except_normalize, so LET-bound constructors are exposed and folded instead
of duplicating opaque variables.

Selection and update are never pushed through IF: `(IF c THEN t ELSE u).h`
equals `IF c THEN t.h ELSE u.h` only for `c \in BOOLEAN`, which is unknown
during normalization, so an IF base falls back to a single-component
EXCEPT; `@`/`At` are already resolved by Elab.desugar.

Semantic correctness is covered by inline tests in e_elab.ml; the term-size
regression is guarded by
test/regression_tests/record_except_explosion_test.tla.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Markus Alexander Kuppe <github.com@lemmster.de>
Rewrite an equality of two record constructors
`[ h1 |-> a1, ... ] = [ h1 |-> b1, ... ]` with equal field sets into the
conjunction of field-wise equalities `a1 = b1 /\ ... /\ an = bn`, paired by
field name. This avoids emitting record- and function-extensionality
axioms and comparing whole literals, which is what makes refinement-mapping
obligations blow up in the backend.

The new pass Encode.Rewrite.simpl_receq is a top-down map that fires only
on a literal record-constructor equality, guarded by equal field sets;
non-constructor field values are copied verbatim, so nothing is distributed
through IF. It runs in the SMT-LIB pipeline after elim_flex and before
Type.Synthesize, so the decomposition is in place before type synthesis.

The old SMT backend already specialized record equality with finite
extensionality instances; the new minimal-axiom encoding ("a record is a
function") dropped it. simpl_receq reinstates it as a pre-encoding rewrite
rather than an axiom, removing the equality outright instead of
instantiating function extensionality.

It fires only when both sides are explicit record constructors with the
same field set, where record extensionality applies; `Record # Record` is
left untouched.

Tested by test/unit/h_records/recordeq_*_smt_test.tla and
test/soundness_tests/recordeq_swap_stest.tla.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Markus Alexander Kuppe <github.com@lemmster.de>
@lemmy lemmy added the enhancement A new feature, an improvement, or other addition. label Jul 26, 2026
@lemmy lemmy self-assigned this Jul 26, 2026

@muenchnerkindl muenchnerkindl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this contribution! The code looks good to me (but I cannot claim to have understood every detail) and the tests are convincing.
@damiendoligez Can you also have a look?

@lemmy
lemmy marked this pull request as ready for review July 27, 2026 19:25
@lemmy
lemmy requested a review from damiendoligez August 10, 2026 23:21
@lemmy

lemmy commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

@glondu If time permits, could you please review the Ocaml code in this PR?

@glondu

glondu commented Aug 14, 2026

Copy link
Copy Markdown

@glondu If time permits, could you please review the Ocaml code in this PR?

Yes, sure.

I had a look at 134f78b. same_fieldset is correct only if each field label appears once (is it the case in this context?). Moreover, the combination of List.length, List.for_all and List.mem_assoc (plus List.assoc in decompose_receq), each linear in the list size, looks very inefficient, especially if the record is big (what is the typical/worst size?). I would have used a string map.

I will have a look at the other commit later.

wkirschenmann pushed a commit to wkirschenmann/tlapm that referenced this pull request Aug 21, 2026
Three gaps in the adoption plan, all of which a reviewer hits immediately.

**Files.** The table gave a count, not paths. Adds the paths per item.
They are concentrated: src/backend/prep.ml carries items 3, 4 and 15 --
the whole throughput story -- and items 1, 2, 5, 6, 7, 8, 9 and 12 are
one file each.

**Guards.** States the rule we applied (a change needs a runtime guard
when it alters what the provers receive, or the order and content of
client-visible messages; the golden-dump protocol classifies it
mechanically), inventories what is guarded today, and names what is
missing:

  1. the context prunes have NO off switch, and they are the one change
     on the list that alters prover input. `--prune-context=none|defs|
     defs+facts` (or `--debug noprune`) should land with the prune, not
     after the first bug report. The `__pruned__` self-check makes such a
     bug loud, but loud is not recoverable.
  2. the single-pass expand_defs cannot be turned off either --
     `--debug noprepcache` restores the cache, not the algorithm, and the
     iterated formulation is gone from the file. Keep it behind
     `--debug oldexpand` for one release as a bisection tool.
  3. the 14 TLAPM_* probe variables should converge on the existing
     `--debug` namespace; environment variables are right only for what
     must be read before argument parsing.
  4. the editor modes should graduate to initializationOptions, and
     TLAPM_STREAM_GEN must become a flag if ever default-on: it changes
     the emission order of "being proved", a client-visible contract even
     though the message set is identical.

**Provenance.** Issue tlaplus#286 (qdelamea-aneo, 2026-07-27) already describes
four patch families with measured speedups up to >41x. Items 3, 6, 14, 15
and 20 ARE those families -- not new ideas. What this branch adds on them
is single-topic reviewable commits with per-commit attribution, and the
plan now says so rather than presenting them as findings. New here: items
1, 2, 4, 5, 7-13, 16-19, and the five negative results.

PR tlaplus#285 (lemmy) is a different subject -- folding record EXCEPT chains --
but not orthogonal in code: it modifies `let_normalize` and
`except_normalize`, the two functions our item 15 exposes and calls per
hypothesis. A textual conflict is certain, and our per-hypothesis
equivalence argument must be re-established after it lands, with
TLAPM_CHECK_ELABCACHE rerun on that PR's regression test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUUoeEmuL3jsYhUb3UrhJH
wkirschenmann pushed a commit to wkirschenmann/tlapm that referenced this pull request Aug 21, 2026
… and survey them

Two fixes to the upstream section.

**tlaplus#286 is ours**, not an external reference, and the plan now says so
plainly: same team, opened 2026-07-27, still unanswered, and its four
patch families ARE our items 3, 6, 14, 15 and 20 -- already-public
proposals re-implemented, not contributions of this branch. What the
branch adds on them is what tlaplus#286 could not offer: single-topic reviewable
commits with stated invariants and mechanical gates, and attribution per
commit instead of per patch set.

**Other people's PRs get their own section**, after checking upstream:
`master` is at 4600b24, exactly this branch's base, so nothing has landed
since the fork and only the open PRs matter.

  * tlaplus#284 (open, LGTM) kills orphaned provers via `exec setpriv
    --pdeathsig KILL` when *tlapm dies*. Same family as our item 2,
    complementary failure mode: ours covers tlapm alive but its kill
    ignored (SIGHUP set to SIG_IGN by nohup, inherited through exec).
    Neither subsumes the other, and tlaplus#284 supplies the SIGKILL escalation
    our fix lacks -- reference it, do not duplicate it.
  * tlaplus#285 (open) modifies `let_normalize`/`except_normalize`, the two
    functions item 15 calls per hypothesis. Textual conflict certain; the
    per-hypothesis equivalence argument must be re-established with the
    oracle afterwards. Kept in the survey for that reason only.
  * tlaplus#275 (open) makes SANY an opt-in parser, so item 7 keeps its value --
    but the editor floor is now 95 % parse, and SANY does semantic
    analysis inside "parsing", which item 19 does not assume.
  * tlaplus#268 (open, extends the merged tlaplus#241) is the feature items 18-19
    currently break: the decomposition code actions locate steps by
    range, and scoped re-elaboration leaves inner positions stale. This
    is why those modes stay flag-gated.
  * tlaplus#283 (merged) gives a deterministic Z3 budget -- worth adopting in
    measurement protocol P2 to remove prover-side variance.
  * tlaplus#266 (open) changes an SMT axiom, so item 3's subset gate must be
    re-run against it; tlaplus#248 (open) upgrades Z3 and invalidates absolutes.
  * tlaplus#264 closed without adopting an LLM policy -- escalated to the TLA+
    Foundation board. The stated maintainer position (human first
    contact, per-commit disclosure of models used) is the one to assume,
    and the 441-lines-for-most-of-the-gain framing is what answers the
    review-workload concern behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUUoeEmuL3jsYhUb3UrhJH
@lemmy

lemmy commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

The TLAPM parser accepts duplicate record fields, and the encoding is then inconsistent, so FALSE is provable:

---- MODULE Foo -----
THEOREM Eq    == [a |-> 1, a |-> 2] = [a |-> 1, a |-> 3] OBVIOUS
THEOREM False == ASSUME NEW r, r = [a |-> 1, a |-> 2] PROVE FALSE OBVIOUS
=====
-> % opam exec -- tlapm Foo.tla
File "./Foo.tla", line 1, character 1 to line 4, character 5:
[INFO]: All 2 obligations proved.

False has no constructor-to-constructor equality, so simpl_receq never fires: this is pre-existing and orthogonal to this PR. The cause shows in [a |-> 1, a |-> 2].a = 1 /\ [a |-> 1, a |-> 2].a = 2, also proved — the record axioms are instantiated per field entry, giving the duplicated name two values. Only Zenon objects (duplicate record field "a").

Silver lining: SANY rejects the constructor ("Non-unique fields in constructor"), so #213 would keep this input away from the backends.

@lemmy

lemmy commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

@glondu Record widths in practice are small, so this was never a bottleneck, but it's changed anyway: same_fieldset and the List.assoc pairing are gone, replaced by Util.Coll.Sm. Both field lists are indexed by name, Sm.equal (fun _ _ -> true) compares the domains and Sm.find does the pairing, so it's no longer quadratic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement A new feature, an improvement, or other addition.

Development

Successfully merging this pull request may close these issues.

3 participants