Skip to content

Commit f47633a

Browse files
dingf3ngclaude
andcommitted
backend: CoqInterface always proves on a copy
Proving on a scratch copy was arranged by each entry point: main.py and test_folder_batch each built a ScratchProof, swapped the path, and passed source_path so records would still name the original. Every other caller -- 17 of the 19 construction sites -- got no copy at all, and the default was the destructive one. There is no read-only mode to justify that. load() alone pops the trailing "Admitted.", clear_all_proof_scripts() rewrites the file, and coqpyt writes every accepted tactic straight to disk. Any CoqInterface built on a file the caller cares about will damage it. So the copy moves into the constructor, and both knobs disappear with it: - work_on_copy is gone: there is nothing to opt out of. - source_path is gone: the file you pass IS the source, so the interface derives it. The question "when is source_path None?" no longer exists. ScratchProof now has exactly one caller. Nothing else in the tree references it. Two things had to move, because they edited the file before a copy existed and would otherwise have hit the user's own file: the Hammer import injection, and proof cleaning. Both now run between construction and load(), on coq_interface.file_path, with clean_success threaded back through the components dict. Saving became coq_interface.save_result(), behind a _harvest_proof() helper the signal handler and the normal exit both use. Scratch cleanup is registered with atexit rather than done in close(), because load() calls close() to tear down the previous coq-lsp session and would otherwise delete the file out from under itself. ScratchProof.close() only unlinks files, so it is safe at interpreter exit. Also drops getattr(self.coq, 'source_path', None) in ProofController. The attribute is always set, and the None fallback resolved to proof_file.path -- the scratch name, the exact value the feature exists to keep out of records. Verified end to end against a real run (gpt-4.1): 🎉 Proof completed successfully! examples/example.v a380c035 -> a380c035 (untouched) examples/autorocq-20260901-153147/example.v holds the found proof stray *_autorocq_*.v none Full suite: 34 passed, 1 skipped, 3 errors -- the errors pre-existing and fixed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 35a4be4 commit f47633a

4 files changed

Lines changed: 75 additions & 65 deletions

File tree

proof-search/agent/proof_controller.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ def _init_proof_session(self, theorem_name: str = None) -> bool:
228228
proof_file=proof_file,
229229
theorem_name=self.current_theorem_name,
230230
metadata={'max_steps': self.max_steps, 'controller_version': '1.0'},
231-
proof_file_path=getattr(self.coq, 'source_path', None)
231+
proof_file_path=self.coq.source_path
232232
)
233233
except Exception as e:
234234
self.logger.error(f"❌ Failed to start proof recording: {e}")

proof-search/backend/coq_interface.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# backend/coq_interface.py
22

3+
import atexit
34
import os
45
import re
56
import signal
@@ -10,6 +11,7 @@
1011
from pathlib import Path
1112
from contextlib import contextmanager
1213
from utils.logger import setup_logger, clean_ansi_codes
14+
from utils.scratch import ScratchProof
1315

1416

1517
class CoqSessionDesync(RuntimeError):
@@ -24,21 +26,31 @@ def __init__(self, file_path: str, workspace: Optional[str] = None,
2426
library_paths: Optional[List[Dict[str, str]]] = None,
2527
auto_setup_coqproject: bool = False,
2628
coqproject_extra_options: Optional[List[str]] = None,
27-
timeout: int = 10,
28-
source_path: Optional[str] = None):
29+
timeout: int = 10):
2930
"""
3031
Initialize Coq interface.
3132
3233
NEW PARAMETERS:
3334
- library_paths: List of library mappings [{"path": "/path", "name": "libname"}, ...]
3435
- auto_setup_coqproject: Whether to automatically create/update _CoqProject
3536
- coqproject_extra_options: Additional options for _CoqProject
36-
- source_path: The file this one is a scratch copy of, when it is one.
37-
Proofs are run on a throwaway copy (see utils/scratch.py), so records
38-
and reports must name the original rather than the copy.
37+
38+
There is no read-only mode: load() alone pops the trailing "Admitted.",
39+
clear_all_proof_scripts() rewrites the file, and coqpyt writes every
40+
accepted tactic straight to disk. So file_path is never touched -- it is
41+
the source, and all work happens on a copy beside it. source_path names
42+
the original for anything that reports or records a proof; file_path is
43+
the copy the agent actually edits. Call save_result() for the outcome.
3944
"""
40-
self.file_path = os.path.abspath(file_path)
41-
self.source_path = os.path.abspath(source_path) if source_path else self.file_path
45+
self.source_path = os.path.abspath(file_path)
46+
self.logger = setup_logger("CoqInterface")
47+
48+
self._scratch = ScratchProof(file_path, self.logger)
49+
self.file_path = str(self._scratch.open())
50+
# Not in close(): load() calls close() to tear down the previous coq-lsp
51+
# session and would delete the file out from under itself.
52+
# ScratchProof.close() only unlinks files, so it is safe at exit.
53+
atexit.register(self._scratch.close)
4254
if workspace is not None and not os.path.isabs(workspace):
4355
workspace = os.path.abspath(workspace)
4456
self.workspace = workspace
@@ -52,7 +64,6 @@ def __init__(self, file_path: str, workspace: Optional[str] = None,
5264
self.proof_file = None
5365
self.proof = None
5466
self.last_error = None
55-
self.logger = setup_logger("CoqInterface")
5667

5768
# Cache for recent goal queries so we avoid back-to-back LSP `proof_goals` calls
5869
# when the proof state hasn't changed.
@@ -992,6 +1003,10 @@ def close(self):
9921003
self.logger.warning(f"Error during CoqInterface close: {e}")
9931004
# Don't raise - just log and continue
9941005

1006+
def save_result(self, dest_dir, name: Optional[str] = None):
1007+
"""Copy the proof the agent produced into dest_dir. Returns the path."""
1008+
return self._scratch.save(dest_dir, name)
1009+
9951010
def force_close(self):
9961011
try:
9971012
self.logger.info("Forcing coq-lsp shutdown...")

proof-search/main.py

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323

2424
from utils.config import load_config, ProofAgentConfig
2525
from utils.logger import setup_logger, global_logger
26-
from utils.scratch import ScratchProof
2726

2827

2928
def parse_arguments():
@@ -202,27 +201,39 @@ def initialize_components(args, config: ProofAgentConfig, logger) -> Dict[str, A
202201
logger.info(f" - {lib['name']}: {lib['path']}")
203202
logger.info(f"🔧 Auto setup CoqProject: {getattr(config.coq, 'auto_setup_coqproject', True)}")
204203

205-
# If hammer is enabled, add hammer library import to proof file
206-
if config.enable_hammer:
207-
logger.info("🔧 Hammer enabled. Importing hammer library...")
208-
with open(args.proof_file, 'r', encoding='utf-8') as f:
209-
content = f.read()
210-
if "From Hammer Require Import Hammer." not in content:
211-
with open(args.proof_file, 'w', encoding='utf-8') as f:
212-
f.write("From Hammer Require Import Hammer.\nFrom Hammer Require Import Tactics.\n\n" + content)
213-
else:
214-
logger.debug("🔧 Hammer already imported - skipping")
215-
216204
coq_interface = CoqInterface(
217205
file_path=args.proof_file,
218-
source_path=config.coq.proof_file_path,
219206
workspace=workspace,
220207
library_paths=library_paths,
221208
auto_setup_coqproject=getattr(config.coq, 'auto_setup_coqproject', True),
222209
coqproject_extra_options=coqproject_extra_options,
223210
timeout=getattr(config.coq, 'timeout', 60)
224211
)
225212

213+
# Everything below edits the file, so it has to come after the
214+
# constructor -- that is what puts the scratch copy in place.
215+
scratch_file = coq_interface.file_path
216+
217+
if config.enable_hammer:
218+
logger.info("🔧 Hammer enabled. Importing hammer library...")
219+
with open(scratch_file, 'r', encoding='utf-8') as f:
220+
content = f.read()
221+
if "From Hammer Require Import Hammer." not in content:
222+
with open(scratch_file, 'w', encoding='utf-8') as f:
223+
f.write("From Hammer Require Import Hammer.\nFrom Hammer Require Import Tactics.\n\n" + content)
224+
else:
225+
logger.debug("🔧 Hammer already imported - skipping")
226+
227+
# Clean proof by removing existing tactics. Skip in interactive mode
228+
if config.interactive.enabled:
229+
logger.debug("🤝 Interactive mode enabled - preserving existing proof tactics")
230+
clean_success = ensure_proof_admitted(scratch_file, logger)
231+
else:
232+
logger.debug("🧹 Pre-cleaning proof file to ensure unproven state...")
233+
clean_success = clean_proof_file(scratch_file, logger)
234+
if not clean_success:
235+
logger.warning("⚠️ Could not clean proof file - will try CoqInterface methods later")
236+
226237
# Load the file using proper method
227238
success = coq_interface.load()
228239
if not success:
@@ -291,7 +302,8 @@ def initialize_components(args, config: ProofAgentConfig, logger) -> Dict[str, A
291302
"coq_interface": coq_interface,
292303
"context_manager": context_manager,
293304
"coq_chat_session": context_manager.chat_session,
294-
"controller": controller
305+
"controller": controller,
306+
"clean_success": clean_success
295307
}
296308

297309
except Exception as e:
@@ -553,14 +565,24 @@ def clean_proof_file(file_path: str, logger) -> bool:
553565
return False
554566

555567

568+
def _harvest_proof(components, output_dir, logger):
569+
"""Save whatever the agent proved into the run's output directory."""
570+
coq_interface = (components or {}).get("coq_interface")
571+
if coq_interface is None:
572+
return
573+
try:
574+
coq_interface.save_result(output_dir)
575+
except Exception as e:
576+
logger.warning(f"Could not save the resulting proof: {e}")
577+
578+
556579
def main():
557580
"""Main entry point with history management."""
558581

559-
global components, logger, exit_code, scratch
582+
global components, logger, exit_code
560583
components = {}
561584
logger = None
562585
exit_code = 1
563-
scratch = None
564586

565587
def signal_handler(signum, frame):
566588
sig_name = signal.Signals(signum).name
@@ -569,10 +591,8 @@ def signal_handler(signum, frame):
569591
if components and logger:
570592
cleanup_components(components, logger)
571593

572-
if scratch:
573-
scratch.save(output_dir)
574-
scratch.close()
575-
594+
_harvest_proof(components, output_dir, logger)
595+
576596
sys.exit(128 + signum)
577597

578598
# Register signal handlers
@@ -661,24 +681,6 @@ def signal_handler(signum, frame):
661681
if args.interactive:
662682
config.interactive.enabled = True
663683

664-
# Prove on a throwaway copy. Cleaning below strips the existing tactics and
665-
# coqpyt writes every accepted tactic back to disk, so the file being proved
666-
# must never be the user's own. config.coq.proof_file_path keeps pointing at
667-
# the original, which is what reporting and recording should name.
668-
scratch = ScratchProof(args.proof_file, logger)
669-
scratch.open()
670-
args.proof_file = str(scratch.path)
671-
672-
# Clean proof by removing existing tactics. Skip in interactive mode
673-
if config.interactive.enabled:
674-
logger.debug("🤝 Interactive mode enabled - preserving existing proof tactics")
675-
clean_success = ensure_proof_admitted(args.proof_file, logger)
676-
else:
677-
logger.debug("🧹 Pre-cleaning proof file to ensure unproven state...")
678-
clean_success = clean_proof_file(args.proof_file, logger)
679-
if not clean_success:
680-
logger.warning("⚠️ Could not clean proof file - will try CoqInterface methods later")
681-
682684
# Initialize components
683685
try:
684686
components = initialize_components(args, config, logger) # Pass both args and config
@@ -689,6 +691,7 @@ def signal_handler(signum, frame):
689691

690692
# Log final proof file verification
691693
coq_interface = components["coq_interface"]
694+
clean_success = components["clean_success"]
692695
logger.info(f"✅ Coq interface loaded: {coq_interface.file_path}")
693696

694697
if not clean_success and not config.interactive.enabled:
@@ -798,10 +801,8 @@ def signal_handler(signum, frame):
798801

799802
cleanup_components(components, logger)
800803

801-
# Coq session is closed, so the scratch file is safe to harvest and remove.
802-
if scratch:
803-
scratch.save(output_dir)
804-
scratch.close()
804+
# Coq session is closed, so the scratch copy is safe to harvest.
805+
_harvest_proof(components, output_dir, logger)
805806

806807
sys.exit(exit_code)
807808

proof-search/tests/test_folder_batch.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from agent.context_manager import ContextManager
1717
from agent.proof_controller import ProofController
1818
from utils.config import ProofAgentConfig
19-
from utils.scratch import ScratchProof
2019

2120
# --- CONFIGURATION ---
2221
# Folder containing .v files to prove
@@ -87,18 +86,11 @@ def prove_single_file(
8786
crash_count = 0
8887

8988
while crash_count < max_crash_retries:
90-
scratch = ScratchProof(coq_file)
89+
coq_interface = None
9190
try:
92-
scratch.open()
93-
94-
# Clean the scratch copy, never the benchmark file itself
95-
if not clean_proof_file(scratch.path):
96-
return False
97-
98-
# Initialize CoqInterface
91+
# CoqInterface proves on a copy, so the benchmark file is never touched.
9992
coq_interface = CoqInterface(
100-
file_path=str(scratch.path),
101-
source_path=str(coq_file),
93+
file_path=str(coq_file),
10294
workspace=config.coq.workspace or str(Path(coq_file).parent),
10395
library_paths=config.coq.library_paths,
10496
auto_setup_coqproject=config.coq.auto_setup_coqproject,
@@ -107,6 +99,9 @@ def prove_single_file(
10799
)
108100

109101
try:
102+
if not clean_proof_file(coq_interface.file_path):
103+
return False
104+
110105
# Load the cleaned file
111106
if not coq_interface.load():
112107
return False
@@ -161,9 +156,8 @@ def prove_single_file(
161156
raise e
162157

163158
finally:
164-
if results_dir is not None:
165-
scratch.save(results_dir, result_name)
166-
scratch.close()
159+
if coq_interface is not None and results_dir is not None:
160+
coq_interface.save_result(results_dir, result_name)
167161

168162
return False
169163

0 commit comments

Comments
 (0)