Keep record-flattening refactorings tractable in TLAPS - #285
Conversation
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>
muenchnerkindl
left a comment
There was a problem hiding this comment.
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?
|
@glondu If time permits, could you please review the Ocaml code in this PR? |
Yes, sure. I had a look at 134f78b. I will have a look at the other commit later. |
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
… 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
|
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.
Silver lining: SANY rejects the constructor ("Non-unique fields in constructor"), so #213 would keep this input away from the backends. |
|
@glondu Record widths in practice are small, so this was never a bottleneck, but it's changed anyway: |
Two independent changes for behaviour-preservation proofs over a flattened state record:
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
Stabydir,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_PutXunder~Dir.Dirty /\ elsifCond /\ ~Dir.Localthe flat spec leftProcunchanged 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 inExpr.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_normalizebeforeexcept_normalizeso 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 = rec2is discharged via FunExt plus RecIsafcn/RecDomDef/RecAppDef; instantiating the nested\A x \in DOMAINdominates for wide records and for obligations with many such equalities.simpl_receqreduces this syntactically on constructors (domain and field values statically known):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).