Skip to content

[llm] LOAD -noproof: admit the prefix's lemmas instead of proving them - #1117

Open
bgregoir wants to merge 3 commits into
llm-interactivefrom
llm-interactive-fast-load
Open

[llm] LOAD -noproof: admit the prefix's lemmas instead of proving them#1117
bgregoir wants to merge 3 commits into
llm-interactivefrom
llm-interactive-fast-load

Conversation

@bgregoir

@bgregoir bgregoir commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Replaying a long file to reach one proof spends nearly all its time re-proving lemmas that were already verified. -nosmt only silences the provers; the elaboration of every tactic in the prefix remains.

LOAD "f.ec" LINE -noproof skips the prefix's proofs whole. The mechanism is the one require already uses: a file is read with proof checking off, so Ax.add starts each lemma in PSNoCheck, its script is not even typed, and qed binds the statement as it stands.

The one proof that must still be checked is the one LINE points inside -- seeing its goal state is the reason for stopping there. Which proof that is cannot be known when its opening sentence is read, so it is settled beforehand by a parse-only pass over the prefix (target_proof): EasyCrypt's grammar does not depend on the environment, and parsing is nothing next to proving. Checking goes back on at that sentence, so the goals LOAD reports are the true ones.

On theories/datatypes/List.ec up to line 1487: 8.1s plain, 1.3s under -nosmt, 0.5s under -noproof, for byte-identical goals. Compared against a plain LOAD at the midpoint of all 77 stdlib theories, the goal state matches everywhere.

The prefix is admitted, not proved, so replies carry a [noproof] tag: a successful load is no evidence that the file compiles. Skipping ends with the LOAD -- the mode is restored on every exit path, failures included -- so phrases typed afterwards are checked as usual.

Two things the flag is careful about. A prefix holding an undo moves the engine in a way the parse-only pass cannot follow, so it is loaded with checking on throughout: slower, never wrong, and the missing tag says so. And a fail tac. inside a skipped proof pins an error that can no longer happen, the tactic not being run, so process_action gains ~nofail to drop that verdict there -- without it -noproof failed on files that compile, tests/bullets-errors.ec among them.

Plumbing: EcScope.Prover.{get,set}_check_mode read and write the mode as it is, which check_proof cannot (it is an On/Off toggle that ignores Forced), and EcCommands.{check,set_check}_mode apply it to the whole undo stack, so it behaves like a pragma rather than a scope the undo stack could take back.

Claude-Session: https://claude.ai/code/session_014sRvA8imiFo9jSNLbPuX9W

Replaying a long file to reach one proof spends nearly all its time
re-proving lemmas that were already verified. `-nosmt` only silences
the provers; the elaboration of every tactic in the prefix remains.

`LOAD "f.ec" LINE -noproof` skips the prefix's proofs whole. The
mechanism is the one `require` already uses: a file is read with proof
checking off, so `Ax.add` starts each lemma in `PSNoCheck`, its script
is not even typed, and `qed` binds the statement as it stands.

The one proof that must still be checked is the one LINE points inside
-- seeing its goal state is the reason for stopping there. Which proof
that is cannot be known when its opening sentence is read, so it is
settled beforehand by a parse-only pass over the prefix (`target_proof`):
EasyCrypt's grammar does not depend on the environment, and parsing is
nothing next to proving. Checking goes back on at that sentence, so the
goals LOAD reports are the true ones.

On `theories/datatypes/List.ec` up to line 1487: 8.1s plain, 1.3s under
`-nosmt`, 0.5s under `-noproof`, for byte-identical goals. Compared
against a plain LOAD at the midpoint of all 77 stdlib theories, the goal
state matches everywhere.

The prefix is admitted, not proved, so replies carry a `[noproof]` tag:
a successful load is no evidence that the file compiles. Skipping ends
with the LOAD -- the mode is restored on every exit path, failures
included -- so phrases typed afterwards are checked as usual.

Two things the flag is careful about. A prefix holding an `undo` moves
the engine in a way the parse-only pass cannot follow, so it is loaded
with checking on throughout: slower, never wrong, and the missing tag
says so. And a `fail tac.` inside a skipped proof pins an error that can
no longer happen, the tactic not being run, so `process_action` gains
`~nofail` to drop that verdict there -- without it `-noproof` failed on
files that compile, `tests/bullets-errors.ec` among them.

Plumbing: `EcScope.Prover.{get,set}_check_mode` read and write the mode
as it is, which `check_proof` cannot (it is an `On`/`Off` toggle that
ignores `Forced`), and `EcCommands.{check,set_check}_mode` apply it to
the whole undo stack, so it behaves like a pragma rather than a scope
the undo stack could take back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014sRvA8imiFo9jSNLbPuX9W
@bgregoir
bgregoir requested a review from strub September 7, 2026 06:43
bgregoir and others added 2 commits September 7, 2026 11:20
What a LOAD costs is almost never the file. On mediummem.ec, a
470-line development over the Jasmin libraries, loading into the last
proof takes 33s of which 30s is `require': `require import Goldbach.'
alone is 49s, `Sieve' 33s, and the file's own body is milliseconds.
None of it is proofs -- a required file is already read with checking
off -- so neither -nosmt nor -noproof can touch it, and .eco is no
help either, being a record of digests rather than a compiled theory.

EcScope declines to read a theory twice: `Theory.require' consults the
scope's `sc_loaded' before it runs a loader. But that table is part of
the scope, and LOAD rebuilds the scope from nothing on every call, so
every reload started from an empty one. The theories are now kept
outside the scope as well, in `EcCommands.ThCache', and a rebuilt
scope is seeded from it. Four alternating LOADs of memory_pool.ec and
mediummem.ec go from 83.7s to 37.4s; a second LOAD of mediummem.ec at
line 470 goes from 33s to 2s under -noproof -nosmt, and that 2s is the
target proof being replayed, nothing else.

A kept theory must never outlive its source. An entry is served only
while the file it was read from digests to what it did then and every
theory it required is served too, so an edit five requires down
invalidates everything above it -- checking the closure, not the file,
is the point. The include path is the other half of the key, since
under a different one a name may name another file; a rebuild that
starts from a different one drops the table whole rather than reason
about which names moved. That is narrower than it sounds: `addidir'
ignores a directory already searched, so loading file after file of
one project keeps everything, which is what a session does.

LOAD therefore adds the loaded file's own directory to the include
path *before* it rebuilds the session rather than after: added after,
it sat outside the key, and two files of the same name in two
directories were served each other's theories. The `shadowed' scenario
below caught exactly that.

The cache is off unless a front-end asks for it, and only the LLM REPL
and the MCP server do. The batch compiler reads each file once per
process, so it has nothing to gain and no reason to carry the risk.
`Theory.require' also bumps the prelude snapshot on the path that
takes a theory from `sc_loaded', as the loading path always has:
nothing reached that path during the prelude before, and a seeded
table does -- without it `for_loading' rewound to a prelude that had
never been recorded.

Checked three ways. Every one of the 128 stdlib theories, loaded to
its midpoint and then reloaded in the same session, gives byte-
identical replies. `tests/llm/scripts/warm-reload.script' freezes both
halves of such a pair, so a cache that ever showed on the wire would
diff. And `scripts/testing/llm-warm-reload' drives the REPL over stdin
-- which a -eval script cannot do, the edit having to land between two
LOADs of one session -- to check that a warm session answers what a
cold process answers after an edit to a required file, an edit to a
file reached only through another, and an include path that changes so
a name resolves elsewhere. Both new tests pass against the binary from
before this commit too: they pin the contract the cache had to meet,
not its presence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2YZRRxpC8R1Ho2NGpD1fg
A session behaves as a source file does: a failing phrase is reported
and whatever comes next runs against wherever it left the engine. For
a client that sends one phrase per call and acts on each reply, that
is a trap. `split.' opens two goals, the tactic after it fails, and
the phrase after *that* lands on the first subgoal rather than on the
state it was written for. Nothing says so. The proof stops making
sense several phrases later, and the way back is a checkpoint.

`STRICT ON' (`ec_strict' over MCP) stops the session there instead.
Any failure of an operation that could have advanced arms the stop --
whether or not that particular failure advanced anything, since the
drift is in the client's picture of where the session is and not in
the engine -- and everything that could move the engine further is
refused until the session is put somewhere the client chose: UNDO,
REVERT and LOAD by arriving somewhere definite, RESUME (`ec_resume')
by saying so. Being stopped is not being locked out: GOALS, TREE,
SEARCH, CHECKPOINT and COMMIT all answer, the point being to look at
the failure.

The gate is in EcLlmCore, not in either front-end, so the REPL and the
MCP server cannot come to differ on what a stopped session refuses.
`try_step' is the one advancing operation whose failures do not arm
it: they restore the state the call started from and report having
done so, so the client's picture stays exact. It is still refused
*while* stopped, since succeeding would advance from a point the
client has not acknowledged, and a refusal restores the stop it was
refused by, so being refused changes nothing either.

RESUME fails on a session that is not stopped, and on one where the
mode is off. That is not pedantry: a client resuming a session that
was never stopped has lost track of it, which is the one thing this
mode is here to say.

Off by default, so nothing changes for anyone who has not asked --
the 36 existing REPL goldens and 17 MCP goldens are untouched, and
only the tool table moved.

Three scenarios and a parity leg. `strict-stop' plays the trap: a
phrase fails after `split.' has moved the engine, the next one is
refused, GOALS still answers, RESUME releases it, and the COMMIT at
the end shows the emitted body carries no trace of either the failed
phrase or the refused one. `strict-resume-unstopped' pins RESUME's two
refusals. The MCP `strict-stop' covers what a -eval script cannot
show, ec_try being refused while stopped although its own failure
never stopped anything. And mcp-parity gains five steps, so the two
wires are checked to say the same thing at the stop, at the refusal
and at the release.

`Schema.bool' takes `default' as optional now: `ec_strict.on' is
required, and a schema that declares a default and demands the
property anyway says two things at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2YZRRxpC8R1Ho2NGpD1fg
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