Skip to content

autosetup: converge the compilation-workaround loop instead of timing out - #134

Open
shellygr wants to merge 8 commits into
masterfrom
shelly/workaround-loop-convergence
Open

autosetup: converge the compilation-workaround loop instead of timing out#134
shellygr wants to merge 8 commits into
masterfrom
shelly/workaround-loop-convergence

Conversation

@shellygr

@shellygr shellygr commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The incident

A corpus project mixing pragma solidity 0.6.4; (37 files) and 0.8.17 (16 files) burned a full
60-minute job timeout in the compilation-workaround loop without ever converging:

compiler_version_mismatch:  X requires 0.6.4              -> compiler_map[X] = solc6.4
solc_not_found_fallback:    Detected missing solc binary  -> falls back to solc8.34
compiler_version_mismatch:  X requires 0.6.4              -> compiler_map[X] = solc6.4
... 240 rounds, one certoraRun each

solc6.4 is not installed. The fallback was version-blind: it rewrote every contract pinned to the
missing binary to whatever compiler was default, with no reference to the pragma, so the substitution
could never compile and the mismatch detector re-pinned it every pass.

The existing guard could not see this. It compares a pass against its own starting state, so it
only catches a pass that changed nothing. Two workarounds that undo each other each change the conf
relative to their own baseline, and they alternate across passes.

The fixes

1. The fallback is a per-contract plan. Each contract pinned to the missing binary is offered
only compilers its pragma admits (pragma_admits -> True / False / None, where None means
the spec could not be parsed and is treated as no evidence). When a contract has no viable
substitute the run raises UnsatisfiableSolcPinError naming the contract, its pragma and the binary
to install, rather than retrying a substitution that cannot work.

2. The loop remembers the states it has compiled. _retry_state already serializes the command
and both conf dicts, and already carries the invariant that makes this sound: an apply that makes
real progress must change one of them. The loop keeps every state it has run and gives up when one
comes round again, which subsumes the old "this pass changed nothing" guard. One check, not two.

The regression test drives the real shape (missing pin, substitute, re-pin) with the pragma
unreadable so the pragma guard deliberately does not apply: 2 compiles instead of 273.

What replaced the change ledger, and why

The first version of fix 2 was a change ledger: every apply recorded (workaround, conf delta), and
a pass whose changes had all been made before stopped the loop. @jar-ben asked why the criterion was
not simply "stop when a normalized conf repeats". Investigating that turned up two problems with the
ledger, both measured against the real workaround table:

  • It could give up on a conf that compiles. Once compiler_version_mismatch bumps the compiler,
    cancun_opcode_evm_version re-adds an identical delta and the pass looks like pure repetition. The
    ledger stops at 3 compiles; the state memo reaches that conf and compiles it on the 4th. Whether a
    compiler rejects cancun depends on which compiler compiler_map pins, and two other workarounds
    write that field, so this is reachable rather than theoretical.
  • Its cost scaled with the contract count on the case it was written for. The fallback replaces
    every entry pinned to the missing binary in one apply, while the mismatch detector re-pins one
    contract per pass, so the fallback's delta differs every pass. For 1, 3 and 6 pinned contracts the
    ledger needs 4, 8 and 14 compiles. The state memo needs 2, 3 and 3.

The state memo has the opposite failure mode: an orthogonal one-way change (a warning flag, an
optimizer rung) makes each state unique and buys the cycle one more pass. There are about eight such
keys, all one-shot, so that cost is a constant, and max_retries still bounds it.

Validation

  • uv run --no-sync python -m pytest tests/ -m "not expensive" -q -> 518 passed, 9 skipped; pyright -> 0 errors
  • new tests: the fallback plan (blocked pins, per-contract selection, unreadable and non-UTF-8
    pragmas, candidate ordering), the state memo stopping a cycle, an orthogonal change letting the
    loop continue, and the memo being scoped to one loop rather than to the manager
  • the missing-library harness workaround now has a test of its own, since dropping the ledger removed
    the exemption it carried and nothing in tests/ exercised it
  • the two failure modes above were reproduced end to end against the shipped module: the previously
    abandoned conf now compiles, and the incident converges in 2, 3 and 3 compiles at 1, 3 and 6
    pinned contracts

Known, not fixed here

missing_library_harness never regenerates a harness for a second library, and its _harnessed_libs
guard cannot bound the FooHarnessHarness cascade. Pre-existing and independent of these commits;
filed as #179.

shellygr and others added 3 commits August 8, 2026 18:00
A contract pinned to an absent compiler was rewritten to whichever compiler was
default, regardless of what its pragma allows. For an exact pragma the rewrite
cannot compile, so the next pass re-detects the mismatch and re-pins — the two
workarounds undo each other until the retry budget or the job timeout ends it.

The fallback is now a per-contract plan: each pinned contract is offered only
compilers its pragma admits, and an unreadable or unparseable pragma still takes
the first candidate. When a contract has no viable substitute the run raises
UnsatisfiableSolcPinError naming the contract, its pragma and the binary to
install, instead of retrying a substitution that cannot work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The loop compares each pass against its own starting state, so it only notices a
pass that changed nothing. Two workarounds that undo each other each change the
conf relative to their own baseline, and alternate across passes, so the loop
runs to the retry budget — one full certoraRun per pass.

Record every (workaround, conf delta) applied in the run. A pass whose changes
have all been made before means something is undoing them, so stop there and name
them. A pass that also lands a new change is still converging and continues.

missing_library_harness is exempt via progress_outside_conf: regenerating its
harness covers one more library each time, which is real progress the conf does
not show. Its own _harnessed_libs guard bounds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six defects found by review of the two commits below it:

- Candidate order put whatever `solc` is on PATH ahead of the project's own
  default, so a wide pragma could pick an unrelated compiler. The default now
  comes first and plain solc is the last resort.
- A non-UTF-8 source (an accented byte in a header comment) raised out of
  read_pragma_from_source_file and killed the loop; it now reads as an unknown
  pragma.
- parse_pragma_constraint understands `=X.Y.Z` and `~X.Y.Z`, and reports a
  disjunction as unknown rather than mis-parsing it.
- The terminal raise is gated on a pin the conf actually carries, so a pin
  seeded from the default compiler no longer fails the run.
- certora-fixconf reports the unsatisfiable pin and still writes back the fixes
  already applied, instead of exiting on a traceback.
- The change ledger is scoped to one run of the loop: fixconf runs it twice on
  one manager, and the second run may legitimately re-apply the first's changes.

An exempt workaround no longer vetoes repeat detection for the whole pass — it
only keeps its own changes out of the ledger. The note on missing_library_harness
now states what its guard keys on and that max_retries is what bounds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>


@dataclass(frozen=True)
class BlockedPin:

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.

Can you rename it e.g. to BlockedSolcPin? BlockedPin is quite generic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude answers: renamed to BlockedSolcPin in e4ee229. UnsatisfiableSolcPinError already owned that spelling in the module, so it is the house form.

"""

failed_solc: str
rewrites: Dict[str, str]

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.

I don't like the name rewrites - how about compliant or something like that?

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.

alright, after reading more of the codebase, rewrites makes some sense, but here it's still confusing, especially in connection with the comment saying:

rewrites covers the contracts an installed compiler can serve

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude answers: it is compliant now, in e4ee229, and the class docstring was rewritten so the prose and the field agree — it opens with what the plan is (which installed compiler, if any, stands in for the missing one at each contract pinned to it), then says compliant maps such a contract to an installed compiler its pragma admits and blocked holds the ones no installed compiler can serve. rewrites was describing the edit the apply performs on compiler_map, whereas the field is the decision, computed a stage earlier by a planner that writes nothing.

"""Sorted (path, new value) for every key an apply added or changed, plus
(path, '<removed>') for keys it deleted.

The previous value is deliberately absent: a change is identified by what it

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.

the comment describes more how the function is used rather than describing what the function does. Especially the second paragraph of the comment.

Saying it computes the diff and perhaps providing a particular small example would be better.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude answers: that docstring is gone — _conf_delta and _flatten_conf were deleted along with the change ledger in e4ee229. See the reply on the convergence thread for why.

self._solc_candidates: Optional[List[Tuple[str, str]]] = None
# (workaround name, conf delta) for every application in this run. Seeing one
# twice means a later workaround undid it, so the loop is circling rather than
# converging — see the repeat check in run_compilation_with_workarounds.

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.

re-setting the same value of a particular conf field might still lead to a convergence assuming some of the other fields also changed, i.e. perhaps it would make sense to simply store all the already seemed "full" conf files (normalized ofc) and stop once we see a conf we have seen before? That's looser stopping criterion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude answers: you were right, and the ledger is gone as of e4ee229. The loop now keeps every state it has compiled and stops when one comes round again. _retry_state already serialized exactly that (cmd plus both conf dicts), and it already carried the invariant that makes the stop sound: an apply that makes real progress must change one of them. That also folds the old "this pass changed nothing" guard into the same check, so there is one criterion instead of two.

On your specific worry: the ledger did not actually have that hole. A repeat only stops the loop when nothing new landed in the same pass, so a re-set value riding along with other changes kept going. But looking for that scenario turned up two others that are real, both measured against the actual workaround table:

  1. The ledger could give up on a conf that compiles. Once compiler_version_mismatch bumps the compiler, cancun_opcode_evm_version re-adds an identical delta and the pass looks like pure repetition. Ledger: gives up at 3 compiles. State memo: reaches that conf and compiles it on the 4th. "This solc rejects cancun" depends on which compiler compiler_map pins, and two other workarounds write that field, so it is reachable rather than theoretical.

  2. The ledger's cost scaled with the contract count on the very case it was written for. _apply_solc_fallback_workaround replaces every entry pinned to the missing binary in one apply, while _detect_compiler_version_mismatch re-pins one contract per pass, so the fallback's delta is different every pass and the ledger cannot fire until the same contract comes round again. For 1, 3 and 6 pinned contracts: 4, 8 and 14 compiles. The state memo: 2, 3 and 3. The project that started this mixed pragmas across 53 files.

The looseness you named is real and it is the price: an orthogonal one-way change (a warning flag, an optimizer rung) makes each state unique and buys the cycle one more pass. There are about eight such keys, all one-shot, so it is a constant, and max_retries still bounds it. A false negative that costs a constant number of certoraRuns beats a false positive that abandons a conf which would have compiled.

One consequence worth flagging: dropping the ledger also drops the progress_outside_conf exemption missing_library_harness carried, and nothing in tests/ exercised that workaround at all, so it now has a test of its own. Reviewing it turned up a real defect there, filed separately as #179 — it never regenerates a harness for a second library, because the apply renames the consumer handle it would need to look up on the next firing.

return None


def pragma_admits(pragma_spec: str, version: str) -> Optional[bool]:

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.

I am surprised we did not have a pragma_admits function yet. are you sure it's not already existing somewhere? (I have not checked)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude answers: checked, and no, it did not exist. What existed was the raw expression Version(v) in parse_pragma_constraint(spec) open-coded at four sites: solc_version_resolver.py:277 and :295 inside resolve_pragma_to_version, enhanced_config_manager.py:849, and certoraRunAutoSolc.py:85. Nothing in the installed certora-cli interprets a pragma at all (it only shells out to solc --version), and no semver library is a dependency — packaging is the only thing available.

Naming it is what buys the two things the fallback planner needs and that a caller-side membership test does not give you: None meaning 'spec not understood', never conflated with False, and a guard around Version(version), which raises on a non-PEP440 string. resolve_pragma_to_version cannot serve either, since it returns None both for an unparseable pragma and for 'no matching version found', and it fetches the version list over the network on the way.

parse_pragma_constraint stays public regardless: certoraRunAutoSolc.py:73-85 needs the SpecifierSet objects to intersect many pragmas against many versions. One follow-up worth doing separately: enhanced_config_manager.py:841-851 could drop its own inline membership test and call pragma_admits, which would make it the single entry point.

Unrelated find on the same function, fixed in e4ee229: the third docstring example claimed pragma_admits("~0.6.4", "0.6.4") returns None, but this branch added tilde parsing, so it returns True. It now shows the disjunction case instead, which is the real unknown.

The loop now remembers every state it has compiled and gives up when one comes
round again. That is what _retry_state already serializes, and it is the same
criterion the no-op guard used, widened from "this pass's own start" to "any
state this loop has compiled", so the two checks collapse into one.

The change ledger this replaces could give up on a conf that compiles. Once
compiler_version_mismatch bumps the compiler, cancun_opcode_evm_version re-adds
an identical delta, the pass looks like pure repetition, and the loop stops at 3
compiles. The memo reaches the same conf and compiles it on the 4th. The
ledger's cost also grew with the contract count on the case it was written for,
since the fallback replaces every pinned entry at once while the mismatch
re-pins one per pass: 1, 3 and 6 pinned contracts cost 4, 8 and 14 compiles,
against 2, 3 and 3 for the memo.

Also renames BlockedPin to BlockedSolcPin and SolcFallbackPlan.rewrites to
compliant, and corrects a pragma_admits docstring example that this branch's own
tilde support had already invalidated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shellygr and others added 2 commits August 19, 2026 17:20
Two conflicts, both in the same region of the retry loop, both resolved by
keeping each side whole:

#131 added a terminal check for a contract that leaves an inherited function
unimplemented, next to the abstract-main-contract check this branch's
unsatisfiable-solc-pin check also sits beside. All three now run in order, each
raising before any workaround is applied, and none reads another's state. The
test file conflict was the two imports landing on the same line.

#164 changed how remapping contexts are expressed and touched a different
function, so it merged on its own.

Convergence re-measured against the merged module and unchanged: the conf the
change ledger used to abandon still compiles on the 4th run, and the incident
still converges in 2, 3 and 3 compiles at 1, 3 and 6 pinned contracts. Full
suite 966 passed, 12 skipped; pyright 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shellygr
shellygr requested a review from jar-ben August 19, 2026 14:27
shellygr and others added 2 commits August 19, 2026 18:14
One conflict, and it is a real one: #168 rewrote the same guard this branch
replaces. It kept the "did this pass change anything" condition and appended an
unresolved-import diagnosis to the message the loop gives up with. This branch
drops that condition for a memo of the states the loop has already compiled.

Kept both. The memo is the condition, since it fires everywhere the old check
did and in the cycles it could not see, and #168's diagnosis moves inside it
unchanged: whenever the loop gives up here it still says which imports could not
be resolved, and still refreshes last_import_diagnostics on the way out, which
is what setup_prover reads to name the failure class.

Convergence re-measured against the merged module and unchanged: the conf the
change ledger used to abandon still compiles on the 4th run, and the incident
still converges in 2, 3 and 3 compiles at 1, 3 and 6 pinned contracts. Full
suite 1038 passed, 12 skipped; pyright 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shelly merged #135 into this branch from the web UI while the same master
lineage was being merged locally, alongside #168 and #169. The two are the same
content: this commit takes the branch history and leaves the tree exactly as the
local merge left it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants