diff --git a/benchmarks/AutoRocq-bench b/benchmarks/AutoRocq-bench index b026c9f..6ff0534 160000 --- a/benchmarks/AutoRocq-bench +++ b/benchmarks/AutoRocq-bench @@ -1 +1 @@ -Subproject commit b026c9f582ef4f7442c65632944dd75a753c142d +Subproject commit 6ff053416fcb710297332b8c11d2736dd7b46805 diff --git a/handoff.md b/handoff.md new file mode 100644 index 0000000..9a32bcf --- /dev/null +++ b/handoff.md @@ -0,0 +1,19 @@ +# Rocq wired review handoff + +## Stack + +- Base: `contrib/rocq-backend`. +- This branch removes `CoqInterface` and runs the agent, CLI, interactive session, context search, rollback, and proof saving through `ProverBackend` and `CoqPytBackend`. +- The backend factory exposes only `rocq`; Lean and Isabelle remain in their own review stacks. + +## Verification + +- `PYTHONPATH=proof-search pytest -q -m 'not integration and not live_api' proof-search/tests` — 92 passed, 37 deselected, one pre-existing `PytestReturnNotNoneWarning`. +- `PYTHONPATH=proof-search pytest -q proof-search/tests/test_agent_rocq_workflow.py` — 1 passed against the installed Rocq/coq-lsp toolchain in 7.27s. +- `rg -n "backend\\.coq_interface|CoqInterface|coq_interface" proof-search --glob '*.py'` — no matches. + +## Next task + +Review the narrow fork stack [#1](https://github.com/dingf3ng/LemmaNet/pull/1) → [#2](https://github.com/dingf3ng/LemmaNet/pull/2) → [#5](https://github.com/dingf3ng/LemmaNet/pull/5). The corresponding upstream sequence is [#3](https://github.com/NUS-Program-Verification/LemmaNet/pull/3) → [#4](https://github.com/NUS-Program-Verification/LemmaNet/pull/4) → draft [#7](https://github.com/NUS-Program-Verification/LemmaNet/pull/7). + +After upstream #4 merges, rebase this branch onto the updated upstream `main`, push with `--force-with-lease`, and mark #7 ready for review. diff --git a/proof-search/agent/context_manager.py b/proof-search/agent/context_manager.py index 70dfe74..1e484b4 100644 --- a/proof-search/agent/context_manager.py +++ b/proof-search/agent/context_manager.py @@ -2,6 +2,7 @@ import time import json import traceback +from dataclasses import dataclass from typing import List, Dict, Any, Optional import litellm @@ -9,14 +10,110 @@ from agent.history_recorder import TacticHistoryManager from agent.context_search import ContextSearch +from agent.rendering import ( + render_goals as render_state_goals, + render_hypotheses as render_state_hypotheses, + render_initial_context, +) +from backend.prover_backend import HelperLemmaSpec, ProofState, ProverBackend from utils.logger import setup_logger, clean_ansi_codes from utils.coq_utils import * litellm.drop_params = True # drop unsupported params e.g. temperature + +@dataclass(frozen=True) +class BackendPromptProfile: + """The wording a chat session needs to talk about one prover correctly. + + Native command syntax differs enough between provers that generic wording + ("a tactic") would be too vague to be useful, so each field is spelled out + per backend rather than templated. `query_help` and `tactic_examples` are + the exact syntax validated in the Milestone 6 NTP4VC runs recorded in + handoff.md. + """ + + display_name: str + tactic_examples: str + query_help: str + unsound_examples: str + helper_lemma_native: str + + +_ROCQ_PROFILE = BackendPromptProfile( + display_name="Rocq 9.0.0", + tactic_examples="'intros.', 'apply H.', 'reflexivity.'", + query_help=( + "- Search [identifier]: Find theorems about a specific identifier\n" + "- Search ([pattern]): Find theorems matching a pattern. AVOID searching for " + "patterns to match concrete values, and use placeholder variables instead " + "(e.g., 'Search (lsl _ _)').\n" + "- Print [identifier]: Show the definition of an identifier\n" + "- Print Assumptions: Show all unproven assumptions\n" + "- Check [term]: Show the type of a term or expression\n" + "- About [identifier]: Show type, universe info, and transparency" + ), + unsound_examples="'admit' or similar tactics", + helper_lemma_native="Rocq's assert tactic", +) + +_LEAN_PROFILE = BackendPromptProfile( + display_name="Lean 4 (4.21.0)", + tactic_examples="'intro h', 'constructor', 'exact h.2'", + query_help=( + "- Prefer exact?, apply?, and #check for focused, bounded searches.\n" + "- exact? / apply?: Search for a term or tactic that closes the current goal.\n" + "- #check [term]: Show the type of a term or expression; use " + "'open Namespace in #check term' when a scoped name is needed.\n" + "- #find [specific pattern]: Find declarations matching a constrained pattern. " + "An open-ended #find pattern can be slow and may be rejected.\n" + "- #print [identifier]: Show the definition of an identifier.\n" + "- #print axioms [identifier]: List the axioms a proof depends on." + ), + unsound_examples="'sorry'", + helper_lemma_native="Lean's have", +) + +_ISABELLE_PROFILE = BackendPromptProfile( + display_name="Isabelle/HOL (Isabelle2025-2)", + tactic_examples="'apply (rule conjI)', 'apply auto', 'apply (simp add: fact0)'", + query_help=( + "- find_theorems \"[pattern]\": Find theorems matching a pattern, e.g. " + "'find_theorems \"sorted (drop _ _)\"'\n" + "- find_consts \"[type]\": Find constants of a given type\n" + "- thm [identifier]: Show a fact's statement\n" + "- print_statement [identifier]: Show a named theorem's full statement\n" + "- term \"[expr]\": Type-check and elaborate a term\n" + "- typ \"[type]\": Show a type" + ), + unsound_examples="'sorry' or 'oops'", + helper_lemma_native="Isabelle's subgoal_tac", +) + +BACKEND_PROMPT_PROFILES: Dict[str, BackendPromptProfile] = { + "rocq": _ROCQ_PROFILE, + "lean": _LEAN_PROFILE, + "isabelle": _ISABELLE_PROFILE, +} + + +def backend_prompt_profile(backend_name: str) -> BackendPromptProfile: + """Look up a backend's prompt profile, defaulting to Rocq's wording. + + The default keeps every existing caller that does not pass a backend name + working exactly as before; an unrecognized name is treated the same way + rather than raising, since a wrong prompt is recoverable and a crash here + would abort a proof session over a cosmetic mismatch. + """ + return BACKEND_PROMPT_PROFILES.get(backend_name, _ROCQ_PROFILE) + class CoqChatSession: """ - A class to manage an OpenAI chat session for Coq proof tactics. + A class to manage an LLM chat session for prover tactics. + + Despite the name (kept for compatibility with existing references), this + class is backend-agnostic: the wording it uses for a prover comes from + `BackendPromptProfile`, selected by the `backend_name` constructor argument. """ # Define the function tools for OpenAI API @@ -38,49 +135,6 @@ class CoqChatSession: } } } - TACTIC_TOOL = { - "type": "function", - "function": { - "name": "tactic", - "description": "Provide a Coq tactic command to apply to the current proof state. Use this when you have sufficient context to suggest a proof step. Make sure you follow the current plan. You will be given the updated proof tree if the tactic is applied successfully, or an error message from the Coq proof assistant if the tactic is not applicable.", - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The Coq tactic command to execute (e.g., 'intros.', 'apply H.', 'reflexivity.')" - } - }, - "required": ["command"], - "additionalProperties": False - } - } - } - QUERY_TOOL = { - "type": "function", - "function": { - "name": "query", - "description": "Request additional information from the Coq environment. Use this when you want to find existing lemmas/theorems to use in the proof, or need more information about definitions and types.", - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The Coq query command to execute (e.g., 'Search (_ <= _).', 'Print Z.abs.', 'About nat.', 'Check expr.').\n" + \ - "You can use the following commands to gather context information:\n" + \ - "- Search [identifier]: Find theorems about a specific identifier\n" + \ - "- Search ([pattern]): Find theorems matching a pattern. AVOID searching for patterns to match concrete values, and use placeholder variables instead (e.g., 'Search (lsl _ _)').\n" + \ - "- Print [identifier]: Show the definition of an identifier\n" + \ - "- Print Assumptions: Show all unproven assumptions\n" + \ - "- Check [term]: Show the type of a term or expression\n" + \ - "- About [identifier]: Show type, universe info, and transparency" - } - }, - "required": ["command"], - "additionalProperties": False - } - } - } ROLLBACK_TOOL = { "type": "function", "function": { @@ -103,33 +157,6 @@ class CoqChatSession: } } } - HELPER_LEMMA_TOOL = { - "type": "function", - "function": { - "name": "helper_lemma", - "description": "Propose a helper lemma that needs to be proved first to help complete the main proof. Use this when an intermediate result would simplify the main proof. The helper lemma will be introduced with Coq's assert tactic.", - "parameters": { - "type": "object", - "properties": { - "purpose": { - "type": "string", - "description": "A clear explanation of why the helper lemma is needed." - }, - "statement": { - "type": "string", - "description": "The Coq statement of the helper lemma without assert or hypothesis prefix." - }, - "name": { - "type": "string", - "description": "A meaningful hypothesis name, for example Hbound, Hadd_comm, or Hle_trans." - } - }, - "required": ["purpose", "statement", "name"], - "additionalProperties": False - } - } - } - # LiteLLM Prompt caching: https://docs.litellm.ai/docs/completion/prompt_caching # Providers with Anthropic-style cache_control markers EXPLICIT_CACHE_PROVIDERS = ('anthropic/', 'gemini/', 'vertex_ai/', 'vertex_ai_beta/') @@ -140,13 +167,16 @@ class CoqChatSession: GLOBAL_CACHE_FILE = "/tmp/litellm_cache.json" def __init__(self, - model=None, temperature=0, api_key=None, api_base=None, + model=None, temperature=0, reasoning_effort=None, + api_key=None, api_base=None, max_tokens=15000, timeout=30, enable_caching=True, enable_context_search=True, enable_rollback=True, enable_helper_lemma=True, - enable_local_session_caching=False): + enable_local_session_caching=False, + backend_name="rocq", + max_cost_usd=None): raw_model = model or os.getenv("LLM_MODEL", "openai/gpt-4.1") # Normalize with provider prefix. Default to OpenAI. @@ -154,8 +184,12 @@ def __init__(self, self.api_key = api_key self.api_base = api_base self.temperature = temperature + self.reasoning_effort = reasoning_effort self.max_tokens = max_tokens self.timeout = timeout + if max_cost_usd is not None and max_cost_usd < 0: + raise ValueError("max_cost_usd cannot be negative") + self.max_cost_usd = max_cost_usd self.logger = setup_logger("CoqChatSession") self.max_conversation_history = 4 self.enable_caching = enable_caching @@ -164,7 +198,8 @@ def __init__(self, self.enable_helper_lemma = enable_helper_lemma self.enable_local_session_caching = enable_local_session_caching self.current_plan = None - self.coq_version = "8.18.0" + self.backend_name = backend_name + self.profile = backend_prompt_profile(backend_name) # Token usage tracking self.total_prompt_tokens = 0 @@ -180,31 +215,104 @@ def __init__(self, self.cached_msg_len = 0 self.add_message("system", system_prompt) self.logger.debug(f"System prompt:\n{system_prompt}") - + # 'plan' and 'tactic' are always available - self.tools = [self.PLAN_TOOL, self.TACTIC_TOOL] + self.tools = [self.PLAN_TOOL, self._build_tactic_tool()] # add other tools only if enabled if self.enable_context_search: - self.tools.append(self.QUERY_TOOL) + self.tools.append(self._build_query_tool()) if self.enable_rollback: self.tools.append(self.ROLLBACK_TOOL) if self.enable_helper_lemma: - self.tools.append(self.HELPER_LEMMA_TOOL) - + self.tools.append(self._build_helper_lemma_tool()) + self.logger.info(f"Available tools:\n{', '.join([tool['function']['name'] for tool in self.tools])}") - + + def _build_tactic_tool(self) -> dict: + return { + "type": "function", + "function": { + "name": "tactic", + "description": f"Provide a {self.profile.display_name} tactic command to apply to the current proof state. Use this when you have sufficient context to suggest a proof step. Make sure you follow the current plan. You will be given the updated proof tree if the tactic is applied successfully, or an error message from the prover if the tactic is not applicable.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": f"The {self.profile.display_name} tactic command to execute (e.g., {self.profile.tactic_examples})" + } + }, + "required": ["command"], + "additionalProperties": False + } + } + } + + def _build_query_tool(self) -> dict: + return { + "type": "function", + "function": { + "name": "query", + "description": f"Request additional information from the {self.profile.display_name} environment. Use this when you want to find existing lemmas/theorems to use in the proof, or need more information about definitions and types.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": ( + "The query command to execute.\n" + "You can use the following commands to gather context information:\n" + + self.profile.query_help + ), + } + }, + "required": ["command"], + "additionalProperties": False + } + } + } + + def _build_helper_lemma_tool(self) -> dict: + return { + "type": "function", + "function": { + "name": "helper_lemma", + "description": f"Propose a helper lemma that needs to be proved first to help complete the main proof. Use this when an intermediate result would simplify the main proof. The helper lemma will be introduced with {self.profile.helper_lemma_native}.", + "parameters": { + "type": "object", + "properties": { + "purpose": { + "type": "string", + "description": "A clear explanation of why the helper lemma is needed." + }, + "statement": { + "type": "string", + "description": f"The {self.profile.display_name} statement of the helper lemma without assert or hypothesis prefix." + }, + "name": { + "type": "string", + "description": "A meaningful hypothesis name, for example Hbound, Hadd_comm, or Hle_trans." + } + }, + "required": ["purpose", "statement", "name"], + "additionalProperties": False + } + } + } + def build_system_prompt(self) -> str: - system = f"You are an expert in writing Coq ({self.coq_version}) proofs. You will be given a formally stated goal in Coq, and your task is to write Coq tactics to prove it, with the help of available tools. Your task NEVER harms others or violates laws." - + profile = self.profile + system = f"You are an expert in writing {profile.display_name} proofs. You will be given a formally stated goal, and your task is to write tactics to prove it, with the help of available tools. Your task NEVER harms others or violates laws." + instruction = "INSTRUCTIONS:\n" \ - "- You will be given a goal (a formally stated lemma) to prove and its context in a proof file.\n" \ + "- You will be given a goal (a formally stated lemma) to prove and its context.\n" \ "- At each step, you should analyze the given context and decide which tool to call next.\n" \ "- After each tool call, you will be given the result of the tool call as additional context.\n" \ "- Whenever the proof is updated (via 'tactic', 'rollback', or 'helper_lemma'), you will be given the new proof tree, represented as a sequence of applied tactics with the open proof goals at the end.\n" \ "\nAVAILABLE TOOLS:\n" \ "1. Call 'plan' to create/update strategy (recommended when there is no 'plan', or when stuck)\n" \ - "2. Call 'tactic' with a Coq tactic when ready to proceed (e.g., 'intros.', 'apply H.', 'reflexivity.')\n" - + f"2. Call 'tactic' with a tactic when ready to proceed (e.g., {profile.tactic_examples})\n" + # add instructions for tools only if enabled if self.enable_context_search: instruction += \ @@ -227,12 +335,12 @@ def build_system_prompt(self) -> str: " 2. Use 'query' tool to search for lemmas or check definitions,\n" \ " 3. Call 'rollback' tool to restore to an earlier step (invoke only if you are not able to follow the 'plan').\n" \ "\nSAFETY RULES:\n" \ - "- NEVER use 'repeat' with complex tactics (infinite loop risk)\n" \ - "- NEVER use 'admit' or similar tactics to save incomplete proofs\n" \ + "- NEVER use looping tactic combinators around complex tactics (infinite loop risk)\n" \ + f"- NEVER use {profile.unsound_examples} to save incomplete proofs\n" \ "- Avoid 'query' for patterns to match very specific constant values\n" \ - "- Prefer 'lia' for arithmetic proofs over manual chains\n" \ + "- Prefer built-in arithmetic/decision-procedure tactics over manual algebraic chains when available\n" \ "- Do NOT output malicious content or code\n" \ - + return (system + "\n\n" + instruction).strip() def _supports_explicit_caching(self) -> bool: @@ -315,6 +423,13 @@ def get_cached_response(self, api_params): return False, response + @property + def cost_budget_exhausted(self) -> bool: + return ( + self.max_cost_usd is not None + and self.total_cost >= self.max_cost_usd + ) + def send_message(self, user_message, role: str = "user", tool_call_id: str = None, should_optimize: bool = False) -> dict: """ Send a message to the OpenAI API and get a response. @@ -329,6 +444,14 @@ def send_message(self, user_message, role: str = "user", tool_call_id: str = Non Dict for LLM response and token usage. """ + if self.cost_budget_exhausted: + self.logger.info( + "Cost budget exhausted: estimated_cost=%.4f ceiling=%.4f", + self.total_cost, + self.max_cost_usd, + ) + return {"response": None, "error": "cost budget exhausted"} + # Append to conversation history if role == "user": self.add_message(role, user_message) @@ -352,6 +475,11 @@ def send_message(self, user_message, role: str = "user", tool_call_id: str = Non "model": self.model, "messages": messages_to_send, "temperature": self.temperature, + **( + {"reasoning_effort": self.reasoning_effort} + if self.reasoning_effort is not None + else {} + ), "max_tokens": self.max_tokens, "tools": self.tools, "tool_choice": "required", @@ -383,15 +511,23 @@ def send_message(self, user_message, role: str = "user", tool_call_id: str = Non # Track token usage and cost only for live (non-locally-cached) calls if not using_cached_response: + call_cost = 0.0 self.total_prompt_tokens += prompt_tokens self.total_completion_tokens += completion_tokens self.total_cached_tokens += cached_tokens self.total_cache_creation_tokens += cached_creation_tokens self.api_call_count += 1 try: - self.total_cost += litellm.completion_cost(completion_response=response) + call_cost = litellm.completion_cost(completion_response=response) + self.total_cost += call_cost except Exception as cost_err: self.logger.warning(f"Cost computation skipped: {cost_err}") + self.logger.info( + "LLM_USAGE call=%d prompt_tokens=%d completion_tokens=%d " + "cached_tokens=%d cost_usd=%.4f cumulative_cost_usd=%.4f", + self.api_call_count, prompt_tokens, completion_tokens, + cached_tokens, call_cost, self.total_cost, + ) if not assistant_message_obj: raise ValueError("No assistant message object found") @@ -512,9 +648,11 @@ def get_token_statistics(self): class ContextManager: def __init__( self, - coq_interface, + backend: ProverBackend, + initial_state: ProofState, model=None, temperature=0, + reasoning_effort=None, api_key=None, api_base=None, max_tokens=15000, @@ -527,8 +665,13 @@ def __init__( enable_caching: bool = True, proof_plan: str = None, enable_local_session_caching: bool = False, + backend_name: str = "rocq", + max_state_chars: int = 12_000, + max_cost_usd: float | None = None, ): - self.coq = coq_interface + self.backend = backend + self.backend_name = backend_name + self.backend_state = initial_state self.logger = setup_logger("ContextManager") # LiteLLM reads provider API keys from env vars automatically # (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, etc.) @@ -536,6 +679,7 @@ def __init__( self.api_key = api_key self.api_base = api_base self.temperature = temperature + self.reasoning_effort = reasoning_effort self.max_tokens = max_tokens self.timeout = timeout self.enable_history_context = enable_history_context @@ -547,13 +691,19 @@ def __init__( self.current_step = 0 self.proof_plan = proof_plan self.enable_local_session_caching = enable_local_session_caching + if max_state_chars < 120: + raise ValueError("max_state_chars must be at least 120") + self.max_state_chars = max_state_chars + self.state_elisions = 0 + self.state_elided_chars = 0 + self.queries_after_elision = 0 # Tracks the most recent agent action for explain() / visualizer self.last_action_info: Dict[str, Any] = {} # Initialize context search try: - self.context_search = ContextSearch(coq_interface, history_file) + self.context_search = ContextSearch(backend, history_file) self.logger.info(f"✅ Context search initialized successfully") except Exception as e: self.logger.warning(f"⚠️ Failed to initialize context search: {e}") @@ -563,6 +713,7 @@ def __init__( self.chat_session = CoqChatSession( model=model, temperature=temperature, + reasoning_effort=reasoning_effort, api_key=api_key, api_base=api_base, max_tokens=max_tokens, @@ -571,22 +722,65 @@ def __init__( enable_rollback=self.enable_rollback, enable_helper_lemma=self.enable_helper_lemma, enable_caching=self.enable_caching, - enable_local_session_caching=self.enable_local_session_caching + enable_local_session_caching=self.enable_local_session_caching, + backend_name=self.backend_name, + max_cost_usd=max_cost_usd, ) self.logger.info(f"🤖 Chat session initialized with model: {self.chat_session.model}") - + + def _record_state_elision(self, kind: str, elided_chars: int) -> None: + self.state_elisions += 1 + self.state_elided_chars += elided_chars + self.logger.info( + "MITIGATION R1 state_elision kind=%s elided_chars=%d", + kind, + elided_chars, + ) + + def render_goals(self, state: ProofState | None = None) -> str: + return render_state_goals( + state or self.backend_state, + max_chars=self.max_state_chars, + on_elision=self._record_state_elision, + ) + + def render_hypotheses(self, state: ProofState | None = None) -> str: + return render_state_hypotheses( + state or self.backend_state, + max_chars=self.max_state_chars, + on_elision=self._record_state_elision, + ) + + def cost_budget_exhausted(self) -> bool: + return self.chat_session.cost_budget_exhausted + + def mitigation_statistics(self) -> dict[str, int]: + return { + "state_elisions": self.state_elisions, + "state_elided_chars": self.state_elided_chars, + "queries_after_elision": self.queries_after_elision, + } + def build_initial_prompt(self, proof_tree_str: str) -> str: + """Build the first user prompt from the typed initial `ProofState`. + + Built entirely from `self.backend_state`, which every backend returns + in the same shape, rather than re-parsing the source file. This is + what makes the prompt correct for Lean and Isabelle sources, which + `extract_essential_proof_content` (Rocq-syntax only) cannot parse. + """ prompt = "Given the following context, choose the best function call to help complete the proof.\n\n" - prompt += "## PROOF FILE CONTEXT:\n" - proof_file_content = self.coq.get_proof_file_content() - clean_proof_file_content = clean_ansi_codes(proof_file_content) - essential_content = self.extract_essential_proof_content(clean_proof_file_content) - prompt += essential_content + "\n\n" - + prompt += "## PROOF CONTEXT:\n" + prompt += render_initial_context( + self.backend_state, + max_chars=self.max_state_chars, + on_elision=self._record_state_elision, + ) + "\n\n" + # Initial plan: use proof plan if available if self.proof_plan: prompt += f"## CURRENT PROOF PLAN:\n{self.proof_plan}\n\n" - else: + else: prompt += f"## CURRENT PROOF PLAN: None\n\n" return prompt @@ -606,13 +800,19 @@ def handle_plan_call(self, plan_content: str, tool_call_id: str) -> bool: return tool_response - def handle_query_call(self, query_content: str, tool_call_id: str) -> str: + async def handle_query_call(self, query_content: str, tool_call_id: str) -> str: if not self.enable_context_search: return "No results found: 'query' tool not available." + if self.state_elisions: + self.queries_after_elision += 1 + self.logger.info( + "MITIGATION R1 query_after_elision count=%d", + self.queries_after_elision, + ) # Execute context search - search_result, success = self._execute_context_search(query_content) + search_result, success = await self._execute_context_search(query_content) # Store for explain() / visualizer self.last_action_info.update({ @@ -630,37 +830,29 @@ def handle_query_call(self, query_content: str, tool_call_id: str) -> str: return tool_response - def handle_helper_lemma_call(self, helper_lemma_data: dict, tool_call_id: str) -> str: - """ - Handle helper_lemma tool calls by constructing the assert tactic. - The controller applies the opening brace as a separate proof step. - """ + def handle_helper_lemma_call( + self, helper_lemma_data: dict, tool_call_id: str + ) -> HelperLemmaSpec: + """Return the prover-independent helper lemma selected by the agent.""" purpose = helper_lemma_data.get('purpose', '').strip() statement = helper_lemma_data.get('statement', '').strip() name = helper_lemma_data.get('name', '').strip() if not name or name == 'H': self.logger.warning("⚠️ Helper lemma name missing or generic; generating one.") - - assert_statement = format_assert_statement(statement, name) + name = generate_helper_lemma_name(statement) self.last_action_info.update({ - "helper_lemma": assert_statement, + "helper_lemma": {"name": name, "statement": statement}, "helper_lemma_purpose": purpose, }) self.logger.info(f"🔧 Helper lemma proposed: {name}") self.logger.info(f" Purpose: {purpose}") self.logger.info(f" Statement: {statement}") - self.logger.info(f" Generated tactic: {assert_statement}") - - return assert_statement + return HelperLemmaSpec(name=name, statement=statement) def get_tactic(self, tactic_content: str, tool_call_id: str) -> str: - # Ensure proper formatting - if not tactic_content.endswith('.') and not tactic_content.lower() == 'qed': - tactic_content += '.' - - return tactic_content + return tactic_content.strip() def get_action(self, context_prompt: str, role: str = "user", tool_call_id: str = None, tool_success: bool = False) -> tuple[dict, str]: """ @@ -783,14 +975,14 @@ def _parse_llm_decision(self, llm_result): response_text = llm_result.get("response") or "" return {'type': 'tactic', 'content': response_text.strip() if response_text else "reflexivity"} - def _execute_context_search(self, query) -> tuple[str, bool]: + async def _execute_context_search(self, query) -> tuple[str, bool]: """Execute context search query and return formatted results.""" try: if not self.context_search: return "Context search not available", False # Use the context search system's unified search method - search_result = self.context_search.search(query) + search_result = await self.context_search.search(query) if not search_result or search_result.result_size == 0: return f"No results found.", False @@ -799,7 +991,7 @@ def _execute_context_search(self, query) -> tuple[str, bool]: except Exception as e: self.logger.error(f"Error executing context search: {e}") - return f"Context search error: {str(e)}" + return f"Context search error: {str(e)}", False def get_similar_history(self, proof_state: str, n: int = 5) -> List[Dict[str, str]]: try: diff --git a/proof-search/agent/context_search.py b/proof-search/agent/context_search.py index ceeffb0..36d0498 100644 --- a/proof-search/agent/context_search.py +++ b/proof-search/agent/context_search.py @@ -304,256 +304,100 @@ def _categorize_entries(self, entries: List[Dict[str, str]]) -> Dict[str, int]: return categories -class CoqCommandSearch: - """Handles Coq command-line search operations with adaptive result reduction.""" - - def __init__(self, coq_interface): - """ - Initialize with CoqInterface from backend.coq_interface. - - Args: - coq_interface: Instance of CoqInterface from backend.coq_interface - """ - self.coq = coq_interface + +class ProverCommandSearch: + """Run native context queries through a typed prover backend.""" + + def __init__(self, backend): + from backend.prover_backend import ProverBackend + + if not isinstance(backend, ProverBackend): + raise TypeError("context search requires a ProverBackend") + self.backend = backend self.reducer = ResultReducer() + self.logger = setup_logger("ProverCommandSearch") - # Setup logger - self.logger = setup_logger("CoqCommandSearch") - - # Ensure the CoqInterface is loaded - if not hasattr(self.coq, 'proof_file') or self.coq.proof_file is None: - try: - self.coq.load() - self.logger.info("✅ CoqInterface loaded successfully") - except Exception as e: - self.logger.error(f"❌ Failed to load CoqInterface: {e}") - - def _create_search_result(self, content: str, query: str, query_type: str, goal_context: str = "") -> SearchResult: - """Create a SearchResult with adaptive size reduction.""" + def _create_search_result( + self, + content: str, + query: str, + query_type: str, + goal_context: str = "", + ) -> SearchResult: original_size = len(content) if content else 0 - - # Apply adaptive reduction - reduced_content, reduction_method = self.reducer.reduce_result(content, query_type, goal_context) + reduced_content, reduction_method = self.reducer.reduce_result( + content, query_type, goal_context + ) final_size = len(reduced_content) if reduced_content else 0 - - # Log reduction if applied - if reduction_method != "none": - self.logger.debug(f"Applied {reduction_method}: {original_size} → {final_size} chars ({original_size - final_size} saved)") - return SearchResult( content=reduced_content, - source='coq_command', - relevance_score=1.0 if reduced_content and "No results found" not in reduced_content else 0.0, + source="prover_query", + relevance_score=( + 1.0 + if reduced_content and "No results found" not in reduced_content + else 0.0 + ), metadata={ - 'query': query, - 'type': query_type, - 'reduction_applied': reduction_method, - 'original_size': original_size, - 'size_saved': original_size - final_size + "query": query, + "type": query_type, + "reduction_applied": reduction_method, + "original_size": original_size, + "size_saved": original_size - final_size, }, result_size=final_size, original_size=original_size, - reduction_applied=reduction_method + reduction_applied=reduction_method, ) - - def search_lemma(self, lemma_name: str, goal_context: str = "") -> SearchResult: - """Search for a specific lemma or theorem.""" - query = f"Search {lemma_name}." - result = self.coq.search(query) - return self._create_search_result(result, query, 'search_lemma', goal_context) - - def search_pattern(self, pattern: str, goal_context: str = "") -> SearchResult: - """Search for theorems matching a pattern.""" - # Clean the pattern for Coq search - if not pattern.startswith('(') and not pattern.endswith(')'): - pattern = f"({pattern})" - - query = f"Search {pattern}." - result = self.coq.search(query) - return self._create_search_result(result, query, 'search_pattern', goal_context) - - def print_definition(self, identifier: str) -> SearchResult: - """Print the definition of an identifier.""" - query = f"Print {identifier}." - result = self.coq.search(query) - return self._create_search_result(result, query, 'print_definition') - - def print_assumptions(self, identifier: str = None) -> SearchResult: - """Print assumptions of an identifier or all assumptions.""" - if identifier: - query = f"Print Assumptions {identifier}." - else: - query = "Print Assumptions." - - result = self.coq.search(query) - return self._create_search_result(result, query, 'print_assumptions') - - def locate_definition(self, identifier: str) -> SearchResult: - """Locate the definition of an identifier.""" - query = f"Locate {identifier}." - result = self.coq.search(query) - return self._create_search_result(result, query, 'locate_definition') - - def about_identifier(self, identifier: str) -> SearchResult: - """Get information about an identifier.""" - query = f"About {identifier}." - result = self.coq.search(query) - return self._create_search_result(result, query, 'about_identifier') - - def check_term(self, term: str) -> SearchResult: - """Check the type of a term.""" - query = f"Check {term}." - result = self.coq.search(query) - return self._create_search_result(result, query, 'check_term') - - def auto_search(self, search_request: str, goal_context: str = "") -> SearchResult: - """Automatically determine search type and execute with adaptive reduction.""" - search_request = search_request.strip() - - # All commands now go through the enhanced search() method - result = self.coq.search(search_request) - - # Determine type from command - cmd_type = search_request.split()[0].lower() if search_request else 'unknown' - type_mapping = { - 'search': 'direct_search', - 'print': 'direct_print', - 'locate': 'direct_locate', - 'about': 'direct_about', - 'check': 'direct_check' - } - query_type = type_mapping.get(cmd_type, 'auto_search') - return self._create_search_result(result, search_request, query_type, goal_context) - - def execute_coq_query(self, query_type: str, identifier: str = None, pattern: str = None, goal_context: str = "") -> SearchResult: - """Execute a Coq query by type with parameters and adaptive reduction.""" + async def _query( + self, command: str, query_type: str, goal_context: str = "" + ) -> SearchResult: + from backend.prover_backend import CommandRejectedError + try: - if query_type.lower() == 'search': - if pattern: - return self.search_pattern(pattern, goal_context) - elif identifier: - return self.search_lemma(identifier, goal_context) - else: - error_msg = "Search requires either identifier or pattern" - return SearchResult( - content=error_msg, - source='coq_command', - relevance_score=0.0, - metadata={'query_type': query_type, 'error': 'Missing parameters'}, - result_size=len(error_msg) - ) - elif query_type.lower() == 'print': - if identifier: - return self.print_definition(identifier) - else: - error_msg = "Print requires identifier" - return SearchResult( - content=error_msg, - source='coq_command', - relevance_score=0.0, - metadata={'query_type': query_type, 'error': 'Missing identifier'}, - result_size=len(error_msg) - ) - elif query_type.lower() == 'print_assumptions': - return self.print_assumptions(identifier) - elif query_type.lower() == 'locate': - if identifier: - return self.locate_definition(identifier) - else: - error_msg = "Locate requires identifier" - return SearchResult( - content=error_msg, - source='coq_command', - relevance_score=0.0, - metadata={'query_type': query_type, 'error': 'Missing identifier'}, - result_size=len(error_msg) - ) - elif query_type.lower() == 'about': - if identifier: - return self.about_identifier(identifier) - else: - error_msg = "About requires identifier" - return SearchResult( - content=error_msg, - source='coq_command', - relevance_score=0.0, - metadata={'query_type': query_type, 'error': 'Missing identifier'}, - result_size=len(error_msg) - ) - elif query_type.lower() == 'check': - if identifier: - return self.check_term(identifier) - else: - error_msg = "Check requires term" - return SearchResult( - content=error_msg, - source='coq_command', - relevance_score=0.0, - metadata={'query_type': query_type, 'error': 'Missing term'}, - result_size=len(error_msg) - ) - else: - error_msg = f"Unknown query type: {query_type}" - return SearchResult( - content=error_msg, - source='coq_command', - relevance_score=0.0, - metadata={'query_type': query_type, 'error': 'Unknown query type'}, - result_size=len(error_msg) - ) - except Exception as e: - error_msg = f"Error executing {query_type}: {str(e)}" - return SearchResult( - content=error_msg, - source='coq_command', - relevance_score=0.0, - metadata={'query_type': query_type, 'error': str(e)}, - result_size=len(error_msg) + result = await self.backend.query(command) + except CommandRejectedError as error: + message = error.feedback[0].message if error.feedback else str(error) + return self._create_search_result(message, command, query_type, goal_context) + return self._create_search_result( + result.output, result.command, query_type, goal_context + ) + + async def auto_search( + self, search_request: str, goal_context: str = "" + ) -> SearchResult: + command = search_request.strip() + if not command: + return self._create_search_result( + "No results found.", command, "direct_search", goal_context ) + query_type = command.split(maxsplit=1)[0].lower() + if query_type == "search": + query_type = "direct_search" + return await self._query(command, query_type, goal_context) + class ContextSearch: - """ - Simplified context search interface with adaptive result reduction. - """ - - def __init__(self, coq_interface, history_file: str = None): - """ - Initialize context search with CoqInterface. - - Args: - coq_interface: Instance of CoqInterface from backend.coq_interface - history_file: Ignored (kept for backward compatibility) - """ - self.coq_search = CoqCommandSearch(coq_interface) + """Context-query policy with adaptive result reduction.""" + + def __init__(self, backend, history_file: str | None = None): + del history_file + self.command_search = ProverCommandSearch(backend) self.logger = setup_logger("ContextSearch") - - def search(self, query: str, goal_context: str = "") -> SearchResult: - """ - Simplified search interface with adaptive result reduction. - - Args: - query: Search query string - goal_context: Current proof goal context for relevance ranking - - Returns: - SearchResult from Coq command execution - """ + + async def search( + self, query: str, goal_context: str = "" + ) -> SearchResult: try: - return self.coq_search.auto_search(query, goal_context) - except Exception as e: - self.logger.error(f"Error in Coq command search: {e}") - error_message = f"Search error: {str(e)}" + return await self.command_search.auto_search(query, goal_context) + except Exception as error: + self.logger.error(f"Error in prover command search: {error}") + message = f"Search error: {error}" return SearchResult( - content=error_message, - source='coq_command', + content=message, + source="prover_query", relevance_score=0.0, - metadata={'query': query, 'error': str(e)}, - result_size=len(error_message) + metadata={"query": query, "error": str(error)}, + result_size=len(message), ) - - def execute_coq_query(self, query_type: str, identifier: str = None, pattern: str = None, goal_context: str = "") -> SearchResult: - """Execute a Coq query with adaptive result reduction.""" - return self.coq_search.execute_coq_query(query_type, identifier, pattern, goal_context) - diff --git a/proof-search/agent/interactive_session.py b/proof-search/agent/interactive_session.py index cf52353..1664673 100644 --- a/proof-search/agent/interactive_session.py +++ b/proof-search/agent/interactive_session.py @@ -1,466 +1,353 @@ -""" -InteractiveSessionManager: REPL wrapper around ProofController for collaborative -human-agent proof development. - -Commands: - step — agent takes one tactic/rollback action - run — agent runs until subgoal changes, failure, or proof complete - tactic — apply user-supplied tactic - lemma — introduce a helper lemma and enter its sub-proof - drop — abandon the current helper lemma sub-proof - admit — admit the current helper lemma sub-proof and move on - hint — inject natural-language hint into next agent step - rollback [n] — undo last n tactics (user or agent, default 1) - search — run a Rocq query (e.g. Search Z.add, Print Z.add_comm, Check Z.add) - status — display current proof state - tree — display proof tree - explain — show agent reasoning trace - help — show this help - quit — exit -""" +"""Interactive REPL over the asynchronous proof controller.""" + +from __future__ import annotations import re from pathlib import Path from typing import Optional, Tuple -from agent.proof_controller import ProofController from agent import visualizer -from utils.coq_utils import format_assert_statement +from agent.proof_controller import ProofController +from backend.prover_backend import HelperLemmaSpec, ProverBackend +from utils.coq_utils import generate_helper_lemma_name from utils.logger import setup_logger _COMMANDS = [ - "step", "run", "tactic ", "lemma ", "drop", "admit", - "hint ", "rollback", "search ", - "status", "tree", "explain", "help", "quit", + "step", + "run", + "tactic ", + "lemma ", + "drop", + "admit", + "hint ", + "rollback", + "search ", + "status", + "tree", + "explain", + "help", + "quit", ] -# "Hfoo: 0 <= n" names the lemma; "forall x : Z, ..." does not, so it stays bare. -_NAMED_LEMMA_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_']*)\s*:\s*(.+)$", re.DOTALL) +_NAMED_LEMMA_RE = re.compile( + r"^([A-Za-z_][A-Za-z0-9_']*)\s*:\s*(.+)$", re.DOTALL +) -def _split_lemma_arg(arg: str) -> Tuple[str, str]: - """Split a `lemma` argument into (name, statement); name is '' if omitted.""" - match = _NAMED_LEMMA_RE.match(arg.strip()) +def _split_lemma_arg(argument: str) -> Tuple[str, str]: + """Split an optional helper name from its prover-independent statement.""" + match = _NAMED_LEMMA_RE.match(argument.strip()) if match: return match.group(1).strip(), match.group(2).strip() - return "", arg.strip() + return "", argument.strip() class InteractiveSessionManager: - def __init__(self, controller: ProofController): - self.controller: ProofController = controller - self._gen = None - self._done: bool = False + """Coordinate human actions with the same typed backend used by the agent.""" + + def __init__(self, controller: ProofController) -> None: + self.controller = controller + self.backend: ProverBackend = controller.backend + self._generator = None + self._done = False self.logger = setup_logger("InteractiveSession") self._readline_available = False self._history_file: Optional[Path] = None self._setup_readline() - def _setup_readline(self): + def _setup_readline(self) -> None: try: import readline except ImportError: - return # not available on Windows - + return self._readline_available = True self._history_file = Path.home() / ".autorocq_history" - if self._history_file.exists(): try: readline.read_history_file(str(self._history_file)) except OSError: pass - readline.set_completer(self._completer) readline.parse_and_bind("tab: complete") def _completer(self, text: str, state: int) -> Optional[str]: - matches = [c for c in _COMMANDS if c.startswith(text)] + matches = [command for command in _COMMANDS if command.startswith(text)] return matches[state] if state < len(matches) else None - def _save_readline_history(self): + def _save_readline_history(self) -> None: if not self._readline_available or self._history_file is None: return try: import readline + readline.write_history_file(str(self._history_file)) - except ImportError: - pass # not available on Windows - except OSError: + except (ImportError, OSError): pass - ##################### - ## Public API ## - ##################### - - def start(self, theorem_name: Optional[str] = None): - """Initialize the proof session and run the REPL.""" - self.logger.debug(f"Starting interactive session: theorem={theorem_name!r}") - - preexisting = self._extract_and_reset_tactics() - - if not self.controller._init_proof_session(theorem_name): - print("❌ Failed to initialize proof session.") + async def start(self, theorem_name: Optional[str] = None) -> bool: + if not await self.controller._init_proof_session(theorem_name): + print("Failed to initialize proof session.") return False - - self._gen = self.controller.step_generator() + self._generator = self.controller.step_generator() self._done = False - - if preexisting: - print(f"🔄 Replaying {len(preexisting)} pre-existing tactic(s)...") - self.logger.debug(f"Pre-existing tactics to replay: {preexisting}") - for tactic in preexisting: - self._do_user_tactic(tactic, silent=True) - if self._done: - break - # Replayed braces may leave us inside a sub-proof; resync the stack. - self.controller._refresh_helper_lemma_stack_from_history() - self._display_state() - if not self._done: - print("Interactive mode. Type 'help' for available commands.") - self._do_help() - self._repl() - + print("Interactive mode. Type 'help' for available commands.") + self._do_help() + await self._repl() self.controller._finish_proof(self.controller._tactics_with_states) return self.controller.is_successful - ##################### - ## REPL ## - ##################### - - def _prompt(self) -> str: - """Prompt showing which helper lemma sub-proof, if any, is being proved.""" - open_lemmas = self.controller.helper_lemma_context() - if not open_lemmas: - return "lemmanet> " - name = open_lemmas[-1]['name'] or 'lemma' - depth = len(open_lemmas) - return f"lemmanet[{name}]> " if depth == 1 else f"lemmanet[{name} @{depth}]> " - - def _repl(self): + async def _repl(self) -> None: while not self._done: try: raw = input(self._prompt()).strip() - except (EOFError, KeyboardInterrupt) as e: - self.logger.debug(f"REPL interrupted: {e}") + except (EOFError, KeyboardInterrupt): self._do_quit() return - if not raw: continue + command, _, argument = raw.partition(" ") + if command == "step": + await self._do_step() + elif command == "run": + await self._do_run() + elif command == "tactic": + await self._do_user_tactic(argument.strip()) + elif command == "lemma": + await self._do_lemma(argument.strip()) + elif command == "drop": + await self._do_drop() + elif command == "admit": + await self._do_admit() + elif command == "hint": + self._do_hint(argument.strip()) + elif command == "rollback": + try: + steps = int(argument.strip()) if argument.strip() else 1 + if steps <= 0: + raise ValueError + except ValueError: + print("Usage: rollback [n] (n must be positive)") + continue + await self._do_rollback(steps) + elif command == "search": + await self._do_search(argument.strip()) + elif command == "status": + self._display_state() + elif command == "tree": + self._do_tree() + elif command == "explain": + self._do_explain() + elif command == "help": + self._do_help() + elif command == "quit": + self._do_quit() + else: + print(f"Unknown command: {command!r}") - cmd, _, arg = raw.partition(" ") - self.logger.debug(f"User Command: {raw!r}") - match cmd: - case "step": - self._do_step() - case "run": - self._do_run() - case "tactic": - self._do_user_tactic(arg.strip()) - case "lemma": - self._do_lemma(arg.strip()) - case "drop": - self._do_drop() - case "admit": - self._do_admit() - case "hint": - self._do_hint(arg.strip()) - case "rollback": - n = 1 - if arg.strip(): - try: - n = int(arg.strip()) - if n <= 0: - raise ValueError - except ValueError: - print("Usage: rollback [n] (n must be a positive integer)") - continue - self._do_rollback(n) - case "search": - self._do_search(arg.strip()) - case "status": - self._display_state() - case "tree": - self._do_tree() - case "explain": - self._do_explain() - case "help": - self._do_help() - case "quit": - self._do_quit() - return - case _: - print(f"Unknown command: {cmd!r}") - self._do_help() - - ##################### - ## Commands ## - ##################### - - def _do_step(self): - result = self._advance_one() + def _prompt(self) -> str: + """Show the innermost helper scope in the interactive prompt.""" + open_lemmas = self.controller.helper_lemma_context() + if not open_lemmas: + return "lemmanet> " + name = open_lemmas[-1]["name"] or "lemma" + depth = len(open_lemmas) + if depth == 1: + return f"lemmanet[{name}]> " + return f"lemmanet[{name} @{depth}]> " + + async def _do_step(self) -> None: + result = await self._advance_one() if result is None: return self._report(result) - if result['type'] != 'done': + if result["type"] != "done": self._display_state() - def _do_run(self): - goals_before = self.controller.coq.get_goal_str() + async def _do_run(self) -> None: + goals_before = self.controller.goals() while not self._done: - result = self._advance_one() + result = await self._advance_one() if result is None: return self._report(result) - if result['type'] == 'done': - return - if result['type'] == 'rollback': - self._display_state() - return - if result.get('proof_complete'): - return - current_goals = self.controller.coq.get_goal_str() - if current_goals != goals_before: + if ( + result["type"] in {"done", "rollback"} + or result.get("proof_complete") + or self.controller.goals() != goals_before + ): self._display_state() return - def _do_user_tactic(self, tactic: str, silent: bool = False, record_helper_lemma: bool = True): + async def _do_user_tactic( + self, + tactic: str, + *, + silent: bool = False, + record_helper_lemma: bool = True, + allow_unsound: bool = False, + ) -> None: if not tactic: print("Usage: tactic ") return - - # Braces are steps in their own right and take no period. - if not tactic.endswith('.') and tactic not in ('{', '}'): - tactic += '.' - - subgoals_before = self.controller.coq.get_subgoals() - goals_before = self.controller.coq.get_goal_str() - hyps_before = self.controller.coq.get_hypothesis() - - success = self.controller.coq.apply_tactic(tactic) - if not success: - error = self.controller.coq.get_last_error() - self.logger.debug(f"User Tactic failed: {tactic!r} — {error}") - print(f"Tactic failed: {error}") - return - - goals_after = self.controller.coq.get_goal_str() - hyps_after = self.controller.coq.get_hypothesis() - subgoals_after = self.controller.coq.get_subgoals() - - self.controller.global_step_id += 1 - tactic_with_state = self.controller._handle_successful_tactic( - tactic, subgoals_before, subgoals_after, - goals_before or '', goals_after or '', - hyps_before or '', hyps_after or '' + accepted = await self.controller.apply_user_tactic( + tactic, + record_helper_lemma=record_helper_lemma, + allow_unsound=allow_unsound, ) - tactic_with_state['source'] = 'user' - self.controller._tactics_with_states.append(tactic_with_state) - self.logger.debug(f"User Tactic applied: {tactic!r}") - - if not silent: - print(f"✅ Tactic applied: {tactic}") - - status = self.controller.coq.get_proof_completion_status() - if status.get('is_complete') and status.get('qed_already_applied'): - print("🎉 Proof complete!") - self.controller.is_successful = True - self._done = True + if not accepted: + print(f"Tactic failed: {self.controller.last_error()}") return - - closed = self.controller.close_helper_lemma_if_complete( - subgoals_after, goals_after or '', hyps_after or '', - source='user', record=record_helper_lemma, - ) if not silent: - if closed is not None: - print(visualizer.render_action('helper_lemma_closed', closed, True), end='') + print(f"Tactic applied: {tactic}") + if self.controller.state.is_complete: + print("Proof complete.") + self._done = True + elif not silent: self._display_state() - def _do_lemma(self, arg: str): + async def _do_lemma(self, argument: str) -> None: if not self.controller.helper_lemma_enabled(): - print("Helper lemmas are disabled for this session (ablation.enable_helper_lemma = false).") + print("Helper lemmas are disabled for this session.") return - if not arg: - print("Usage: lemma [:] e.g. lemma Hpos: 0 <= n") + if not argument: + print("Usage: lemma [:] ") return - - name, statement = _split_lemma_arg(arg) - assert_statement = format_assert_statement(statement, name) - self.logger.debug(f"User helper lemma: {assert_statement!r}") - self.controller.global_step_id += 1 - result = self.controller.open_helper_lemma(assert_statement, source='user') - - # open_helper_lemma renders the failure itself - if not result['success']: + name, statement = _split_lemma_arg(argument) + if not name or name == "H": + name = generate_helper_lemma_name(statement) + result = await self.controller.open_helper_lemma( + HelperLemmaSpec(name=name, statement=statement), source="user" + ) + if not result["success"]: + print( + visualizer.render_action("helper_lemma", result, False), + end="", + ) return - - if result['replayed']: - print("↺ Proved automatically from a cached proof — back in the parent proof.") + if result["replayed"]: + print("Helper lemma replayed from history.") else: print( - f"Now proving the helper lemma " - f"(depth {result['depth']}/{self.controller.MAX_HELPER_LEMMA_DEPTH}). " - "Use 'drop' to abandon it." + "Now proving helper lemma " + f"{name} (depth {result['depth']}/" + f"{self.controller.MAX_HELPER_LEMMA_DEPTH})." ) self._display_state() - def _do_drop(self): - result = self.controller.abandon_helper_lemma() - if not result['success']: - print(result['message']) + async def _do_drop(self) -> None: + result = await self.controller.abandon_helper_lemma() + if not result["success"]: + print(result["message"]) return - label = result['name'] or result['assert_statement'] - print(f"Dropped helper lemma {label} ({result['rollback_distance']} step(s) removed).") + label = result["name"] or result["assert_statement"] + print( + f"Dropped helper lemma {label} " + f"({result['rollback_distance']} step(s) removed)." + ) self._display_state() - def _do_admit(self): + async def _do_admit(self) -> None: if not self.controller.helper_lemma_context(): - print("admit only applies inside a helper lemma sub-proof; use 'rollback' or 'quit' otherwise.") + print("admit is only available inside a helper lemma sub-proof.") return - print("⚠️ Admitting this sub-proof — the enclosing proof can no longer be closed with Qed.") - print(" Use 'drop' or 'rollback' to remove it before finishing the proof.") - self._do_user_tactic("admit.", record_helper_lemma=False) + print( + "Admitting this helper sub-proof; use drop or rollback to remove it " + "before saving a sound certificate." + ) + await self._do_user_tactic( + "admit.", + record_helper_lemma=False, + allow_unsound=True, + ) - def _do_hint(self, hint: str): + def _do_hint(self, hint: str) -> None: if not hint: print("Usage: hint ") return - self.logger.debug(f"User Hint queued: {hint!r}") self.controller._pending_hints.append(hint) - print("Hint queued for next agent step.") + print("Hint queued for the next agent step.") - def _do_rollback(self, n: int): - history = self.controller._tactics_with_states - if not history: + async def _do_rollback(self, steps: int) -> None: + if not self.controller._tactics_with_states: print("No tactics to roll back.") return - - actual_n = min(n, len(history)) - if actual_n < n: - print(f"Warning: only {len(history)} tactic(s) applied; rolling back all.") - - # Via the controller, so landing on a lemma's '{' also drops its assert. - proof_tree_str = ( - self.controller.proof_tree.get_proof_tree_string() - if self.controller.proof_tree is not None else '' - ) - result = self.controller._execute_rollback( - history, reason='User rollback', proof_tree_str=proof_tree_str, rb_steps=actual_n - ) - if not result['success']: - print(visualizer.render_action( - 'rollback', {'reason': 'User rollback', 'message': result['message']}, False - ), end='') - return - - target_step_number = result['target_step_number'] - if result['target_index'] > 0: - self.controller._tactics_with_states[:] = [ - t for t in self.controller._tactics_with_states - if t['step_number'] <= target_step_number - ] + if await self.controller.rollback(steps): + print(f"Rolled back {steps} tactic(s).") + self._display_state() else: - self.controller._tactics_with_states[:] = [] - self.controller._refresh_helper_lemma_stack_from_history() + print(f"Rollback failed: {self.controller.last_error()}") - self.logger.debug(f"User rollback: {result['rollback_distance']} tactic(s)") - print(f"Rolled back {result['rollback_distance']} tactic(s).") - self._display_state() - - def _do_search(self, cmd: str): - if not cmd: - print("Usage: search (e.g. search Search Z.add | search Print Z.add_comm)") + async def _do_search(self, command: str) -> None: + if not command: + print("Usage: search ") return - cs = self.controller.context_manager.context_search - if cs is None: + context_search = self.controller.context_manager.context_search + if context_search is None: print("Context search is disabled in this session.") return - goal_context = self.controller.coq.get_goal_str() or "" - result = cs.search(cmd, goal_context=goal_context) - if result and result.content: - print(result.content) - else: - print(f"No results for '{cmd}'.") + result = await context_search.search( + command, goal_context=self.controller.goals() + ) + print(result.content if result and result.content else "No results.") - def _do_explain(self): + def _do_explain(self) -> None: print(visualizer.render_explain(self.controller.context_manager)) - def _do_help(self): + def _do_help(self) -> None: print("Commands:") - print(" step — agent takes one action (tactic or rollback)") - print(" run — agent runs until subgoal changes, rollback, or proof complete") - print(" tactic — apply a user-supplied tactic") - print(" hint — inject hint into next agent step") - print(" rollback [n] — undo last n tactics, user or agent (default 1)") - print(" search — run a Rocq query, e.g. 'search Search Z.add' or 'search Print Z.add_comm'") - print(" lemma — introduce a helper lemma, e.g. 'lemma Hpos: 0 <= n'") - print(" drop — abandon the current helper lemma sub-proof") - print(" admit — admit the current helper lemma sub-proof (blocks Qed)") - print(" status — display current proof state") - print(" tree — display proof tree") - print(" explain — show agent reasoning trace") + print(" step — agent takes one action") + print(" run — agent runs until proof state changes") + print(" tactic — apply a native prover command") + print(" lemma [name:] P — open a helper-lemma sub-proof") + print(" drop — abandon the current helper sub-proof") + print(" admit — admit the current helper sub-proof") + print(" hint — inject guidance into the next agent step") + print(" rollback [n] — undo the last n accepted commands") + print(" search — run a native context query") + print(" status — display the current proof state") + print(" tree — display the proof tree") + print(" explain — show the last agent action") print(" help — show this help") print(" quit — exit") - def _do_tree(self): + def _do_tree(self) -> None: print(visualizer.render_tree(self.controller.proof_tree)) - def _do_quit(self): + def _do_quit(self) -> None: self._save_readline_history() self._done = True - ########################### - ## Internal helpers ## - ########################### - - def _extract_and_reset_tactics(self) -> list: - """Extract and pop pre-existing tactics so _init_proof_session sees a clean state.""" - coq = self.controller.coq - proof = coq.get_unproven_proof() - if not proof or len(proof.steps) <= 1: - return [] - - tactics = [step.text.strip() for step in proof.steps[1:]] - - for _ in range(len(tactics)): - coq.proof_file.pop_step(proof) - - coq.proof = coq.get_unproven_proof() - return tactics - - def _advance_one(self): - if self._gen is None or self._done: + async def _advance_one(self): + if self._generator is None or self._done: return None try: - result = next(self._gen) - if result.get('type') == 'done' or result.get('proof_complete'): - self._done = True - return result - except StopIteration: + result = await anext(self._generator) + except StopAsyncIteration: self._done = True - return {'type': 'done', 'success': self.controller.is_successful} - - def _display_state(self): - goals = self.controller.coq.get_goal_str() or "" - print(visualizer.render_state(goals, open_lemmas=self.controller.helper_lemma_context())) - - def _report(self, result): - # step_generator() already renders each action; only add what it omits. - t = result.get('type') - if t == 'tactic': - if result.get('success'): - if result.get('proof_complete'): - print("🎉 Proof complete!") - else: + return {"type": "done", "success": self.controller.is_successful} + if result.get("type") == "done" or result.get("proof_complete"): + self._done = True + return result + + def _display_state(self) -> None: + print( + visualizer.render_state( + self.controller.goals(), + open_lemmas=self.controller.helper_lemma_context(), + ) + ) + + @staticmethod + def _report(result) -> None: + """Report only outcomes not already rendered by the controller.""" + result_type = result.get("type") + if result_type == "tactic": + if result.get("success") and result.get("proof_complete"): + print("Proof complete.") + elif not result.get("success"): print(f" — {result.get('error', '')}") - elif t == 'rollback': - if not result.get('success'): - print("Agent rollback failed.") - elif t == 'done': - if result.get('success'): - print("🎉 Proof complete!") - else: - print("Session ended (max steps reached or proof failed).") + elif result_type == "rollback" and not result.get("success"): + print("Agent rollback failed.") + elif result_type == "done": + print("Proof complete." if result.get("success") else "Session ended.") diff --git a/proof-search/agent/proof_controller.py b/proof-search/agent/proof_controller.py index 1fd8232..2378984 100644 --- a/proof-search/agent/proof_controller.py +++ b/proof-search/agent/proof_controller.py @@ -1,25 +1,36 @@ -import os +from __future__ import annotations from pathlib import Path -from typing import Dict, Optional, Any, List -from backend.coq_interface import CoqInterface +from typing import Any, AsyncIterator, Dict, List, Optional + +from agent import visualizer from agent.context_manager import ContextManager from agent.proof_tree import ProofTree -from agent import visualizer -from utils.recorder import create_proof_recorder -from utils.logger import clean_ansi_codes, setup_logger -from utils.coq_utils import hints_from_error, goal_diff, parse_assert_statement, extract_theorem_name +from agent.rendering import render_goals, render_hypotheses +from backend.prover_backend import ( + Checkpoint, + CommandKind, + CommandRejectedError, + Goal, + HelperLemmaCommands, + ProofState, + ProverBackend, + ProverBackendError, + SavedProofCertificate, +) from utils.config import InteractiveConfig +from utils.coq_utils import goal_diff, hints_from_error, parse_assert_statement +from utils.logger import clean_ansi_codes, setup_logger +from utils.recorder import create_proof_recorder + class ProofController: - """ - Main controller for the proof agent. Orchestrates the proof search process - by coordinating between tactic generation, execution, and state management. - """ - + """Agent-owned proof policy operating only on ProverBackend.""" + def __init__( self, - coq_interface: CoqInterface, + backend: ProverBackend, + initial_state: ProofState, context_manager: ContextManager, max_steps: int = 50, max_errors: int = 3, @@ -29,231 +40,104 @@ def __init__( max_context_search: int = 3, history_file: str = "tactic_history.json", recording_output_dir: str = "data/statistics", - interactive: Optional[InteractiveConfig] = None - ): - - self.steps_since_restart = 0 - self.max_steps_before_restart = 200 - - # Setup logger - self.logger = setup_logger("ProofController") - - self.max_context_search = max_context_search - self.enable_error_feedback = enable_error_feedback - self.enable_hammer = enable_hammer - - self.coq = coq_interface + interactive: Optional[InteractiveConfig] = None, + ) -> None: + if not isinstance(backend, ProverBackend): + raise TypeError("ProofController requires a ProverBackend") + self.backend = backend + self.state = initial_state self.context_manager = context_manager + self.context_manager.backend_state = initial_state self.coq_chat_session = context_manager.chat_session - - self.is_successful = False - self.give_up = False - - self.proof_tree = None - - # Interactive mode - self.interactive = interactive or InteractiveConfig() - self.max_steps = max_steps self.max_errors = max_errors + self.max_context_search = max_context_search + self.enable_error_feedback = enable_error_feedback + self.enable_hammer = enable_hammer + self.interactive = interactive or InteractiveConfig() + self.logger = setup_logger("ProofController") - # Initialize counters and history - self.global_step_id = 0 # Global ID for tool call / proof step - self.gen_step_count = 0 # Global step count for tactic / HL / rollback - self.successful_tactics = [] # Global successful tactics - self.failed_tactics = [] # Global failed tactics - self.query_commands = [] # Global query commands - - # Interactive session state - self._pending_hints: List[str] = [] # User hints to inject into next prompt - self._tactics_with_states: List[Dict] = [] # All applied tactics (user + agent); source field distinguishes them - - # Helper lemma state - self.helper_lemma_stack = [] + self.is_successful = False + self.give_up = False + self.cost_budget_exhausted = False + self.termination_reason: str | None = None + self.proof_tree: ProofTree | None = None + self.global_step_id = 0 + self.gen_step_count = 0 + self.successful_tactics: list[str] = [] + self.failed_tactics: list[str] = [] + self.query_commands: list[str] = [] + self._pending_hints: list[str] = [] + self._tactics_with_states: list[Dict[str, Any]] = [] + self._initial_checkpoint: Checkpoint | None = None + self._last_error = "" + + self.helper_lemma_stack: list[HelperLemmaCommands] = [] self.MAX_HELPER_LEMMA_DEPTH = 3 - # Initialize proof recorder self.enable_recording = enable_recording - if self.enable_recording: + self.recorder = None + if enable_recording: try: self.recorder = create_proof_recorder( - output_dir=recording_output_dir, - auto_save=True + output_dir=recording_output_dir, auto_save=True ) - except Exception as e: - self.logger.error(f"❌ Failed to initialize proof recorder: {e}") - self.recorder = None + except Exception as error: + self.logger.error(f"Failed to initialize proof recorder: {error}") self.enable_recording = False - else: - self.recorder = None - self.logger.info(f"📊 Proof recording disabled") - - # Initialize tactic history manager with proper error handling and path consistency - try: - # Normalize the history file path to ensure consistency - if not os.path.isabs(history_file): - # Convert relative path to absolute path in the data directory - current_dir = Path(__file__).parent.parent # Go up to proof-search directory - history_path = str(current_dir / "data" / history_file) - else: - history_path = history_file - - self.logger.info(f"📁 Normalized history path: {history_path}") - - # ALWAYS try to get it from context_manager first to avoid multiple instances - if context_manager.tactic_history: - existing_path = str(context_manager.tactic_history.history_file) - self.logger.info(f"📁 ContextManager has existing history: {existing_path}") - - # Check if paths match (normalize both for comparison) - if os.path.normpath(existing_path) == os.path.normpath(history_path): - self.tactic_history = context_manager.tactic_history - self.logger.info(f"✅ Using existing TacticHistoryManager from ContextManager (paths match)") - else: - self.logger.warning(f"⚠️ Path mismatch - existing: {existing_path}, requested: {history_path}") - # Use existing one but log the discrepancy - self.tactic_history = context_manager.tactic_history - self.logger.warning(f"⚠️ Using existing TacticHistoryManager despite path mismatch") - - else: - # Create new instance only if context_manager doesn't have one - from agent.history_recorder import TacticHistoryManager - - # Ensure the directory exists - os.makedirs(os.path.dirname(history_path), exist_ok=True) - - self.tactic_history = TacticHistoryManager(history_path) - self.logger.info(f"✅ Created new TacticHistoryManager: {history_path}") - - # Share it back to context_manager to avoid duplication - if not context_manager.tactic_history: - context_manager.tactic_history = self.tactic_history - self.logger.info(f"📤 Shared TacticHistoryManager back to ContextManager") - - # Test the history manager - stats = self.tactic_history.get_statistics() - actual_path = str(self.tactic_history.history_file) - self.logger.info(f"📊 Tactic history initialized: {stats.get('total_entries', 0)} existing entries") - self.logger.info(f"📁 Final history file path: {actual_path}") - - # Verify file exists or can be created - if Path(actual_path).exists(): - file_size = Path(actual_path).stat().st_size - self.logger.info(f"📄 History file exists: {file_size} bytes") - else: - self.logger.info(f"📄 History file will be created on first save: {actual_path}") - - except Exception as e: - import traceback - self.logger.error(f"❌ Failed to initialize TacticHistoryManager: {e}") - self.logger.error(f"📋 Full error trace: {traceback.format_exc()}") - self.tactic_history = None - - ##################### - ## Cleanup methods ## - ##################### - - def _check_for_cleanup(self): - """Periodic memory cleanup and server restart.""" - if self.steps_since_restart > self.max_steps_before_restart: - self.logger.info(f"🔄 Preventive maintenance after {self.steps_since_restart} steps") - - # Clean up large states first - self._cleanup_large_states() - - if self.coq.restart_coq_server(): - self.steps_since_restart = 0 - # Reload current state - self._reload_current_proof_state() - else: - self.logger.error("❌ Failed to restart Coq server") - - def _cleanup_large_states(self): - """Clean up large proof states that might cause memory issues.""" - try: - # Clear chat history if it gets too long - if self.coq_chat_session: - if hasattr(self.coq_chat_session, 'messages'): - messages = self.coq_chat_session.messages - if len(messages) > 20: # Keep only last 20 messages - self.coq_chat_session.messages = messages[-20:] - self.logger.info("🧹 Cleaned up chat session history") - - # Clear query command history - if len(self.query_commands) > 10: - self.query_commands = self.query_commands[-10:] - self.logger.info("🧹 Cleaned up query command history") - - except Exception as e: - self.logger.warning(f"⚠️ Error during cleanup: {e}") - - def _reload_current_proof_state(self): - """Reload the current proof state after server restart.""" + self.tactic_history = self._initialize_history(history_file) + + def _initialize_history(self, history_file: str): try: - # Store current file path if not already stored - file_path = self.coq.proof_file.path - self.logger.info(f"🔄 Reloading proof file: {file_path}") - - success = self.coq.load() - if success: - self.logger.info("✅ Proof file reloaded successfully") - - return True - else: - self.logger.error("❌ Failed to reload proof file") - return False - - except Exception as e: - self.logger.error(f"❌ Error during proof state reload: {e}") - return False + if self.context_manager.tactic_history: + return self.context_manager.tactic_history + from agent.history_recorder import TacticHistoryManager + + path = Path(history_file) + if not path.is_absolute(): + path = Path(__file__).parent.parent / "data" / path + path.parent.mkdir(parents=True, exist_ok=True) + history = TacticHistoryManager(str(path)) + self.context_manager.tactic_history = history + return history + except Exception as error: + self.logger.error(f"Failed to initialize tactic history: {error}") + return None - ##################### - ## Proof methods ## - ##################### - - def _detect_theorem_name(self) -> str: - """ - Name of the theorem about to be proved, read from its statement. Used when - the caller supplies none, so history keeps provenance instead of 'unnamed'. - """ - proof = self.coq.get_unproven_proof() - if not proof: - return "" - name = extract_theorem_name(proof.text) - if name: - self.logger.info(f"📛 Detected theorem name: {name}") - else: - self.logger.warning(f"⚠️ Could not read a theorem name from: {proof.text[:60]}") - return name - - def _init_proof_session(self, theorem_name: str = None) -> bool: - """ - Initialize all state for a new proof attempt. - Called by both prove_theorem() and InteractiveSessionManager. - Returns False if initialization fails (e.g., no unproven proof found). - """ - self.current_theorem_name = theorem_name or self._detect_theorem_name() or 'unnamed' - self.logger.info(f"🚀 Starting proof for: {self.current_theorem_name}") - self.logger.info(f"⚙️ Max steps: {self.max_steps}") - - # Start recording - if self.enable_recording and self.recorder: - try: - proof_file = self.coq.proof_file - if proof_file is None: - raise ValueError("No proof file available") - self.recorder.start_proof_recording( - proof_file=proof_file, - theorem_name=self.current_theorem_name, - metadata={'max_steps': self.max_steps, 'controller_version': '1.0'} - ) - except Exception as e: - self.logger.error(f"❌ Failed to start proof recording: {e}") + def _set_state(self, state: ProofState) -> None: + self.state = state + self.context_manager.backend_state = state + + def goals(self) -> str: + renderer = getattr(self.context_manager, "render_goals", None) + return renderer(self.state) if renderer is not None else render_goals(self.state) + + def hypotheses(self) -> str: + renderer = getattr(self.context_manager, "render_hypotheses", None) + return ( + renderer(self.state) + if renderer is not None + else render_hypotheses(self.state) + ) + + def subgoals(self) -> tuple[Goal, ...]: + return self.state.goals + + def last_error(self) -> str: + return self._last_error + + async def _init_proof_session(self, theorem_name: str | None = None) -> bool: + self._set_state(await self.backend.state()) + self.current_theorem_name = theorem_name or self.state.theorem.name + if self.state.is_complete: + self.logger.error("The selected theorem is already complete") + return False - # Reset counters self.is_successful = False self.give_up = False + self.cost_budget_exhausted = False + self.termination_reason = None self.global_step_id = 0 self.gen_step_count = 0 self.successful_tactics = [] @@ -262,1131 +146,823 @@ def _init_proof_session(self, theorem_name: str = None) -> bool: self._pending_hints = [] self._tactics_with_states = [] self.helper_lemma_stack = [] - - # Check for unproven proof - unproven_proof = self.coq.get_unproven_proof() - if not unproven_proof: - self.logger.error("❌ No unproven proof available") - return False - - self._init_proof_tree() - self.logger.info("🌳 Initialized new ProofTree") + self._last_error = "" + self._initial_checkpoint = await self.backend.checkpoint() if self.enable_recording and self.recorder: + self.recorder.start_proof_recording( + proof_file=self.state.theorem.source.path, + theorem_name=self.current_theorem_name, + metadata={"max_steps": self.max_steps, "controller_version": "2.0"}, + ) self.recorder.start_proving_time() + self._init_proof_tree() return True - def _finish_proof(self, tactics_with_states: List[Dict[str, Any]]): - """ - Finalize a proof attempt: record history, save tree, end recording. - Called by prove_theorem() and InteractiveSessionManager. - """ - proof_file_dir = str(Path(self.coq.file_path).parent) - prefix = self.current_theorem_name + "_" - - completion_message = ( - "Proof completed" if self.is_successful - else "Max steps reached" if self.gen_step_count >= self.max_steps - else "Proof aborted" if self.give_up - else "Unable to proceed" - ) - + def _finish_proof(self, tactics_with_states: List[Dict[str, Any]]) -> None: if self.is_successful: - self.logger.info(f"🔍 Recording successful proof with {len(tactics_with_states)} tactics") self._record_successful_proof(tactics_with_states) - self.proof_tree.save_to_png(str(Path(proof_file_dir) / "proof_tree_final"), prefix=prefix) - self.proof_tree.save_to_json(str(Path(proof_file_dir) / "proof_tree_final.json"), prefix=prefix) - - if self.enable_recording and self.recorder: + if self.proof_tree is not None: + source_directory = self.state.theorem.source.path.parent + prefix = f"{self.current_theorem_name}_" try: - self.recorder.end_proof_recording( - success=self.is_successful, - message=completion_message, - final_stats={ - 'successful_tactics': len(self.successful_tactics), - 'failed_tactics': len(self.failed_tactics), - 'query_commands': len(self.query_commands), - 'steps_taken': self.global_step_id, - 'steps_to_completion': self.gen_step_count if self.is_successful else None, - 'successful_tactics_list': self.successful_tactics, - 'query_commands_list': self.query_commands, - } + self.proof_tree.save_to_png( + str(source_directory / "proof_tree_final"), prefix=prefix + ) + self.proof_tree.save_to_json( + str(source_directory / "proof_tree_final.json"), prefix=prefix ) - except Exception as e: - self.logger.error(f"❌ Failed to end proof recording: {e}") + except Exception as error: + self.logger.warning(f"Could not save proof-tree output: {error}") + if self.enable_recording and self.recorder: + message = ( + "Proof completed" + if self.is_successful + else "Cost budget exhausted" + if self.cost_budget_exhausted + else "Max steps reached" + if self.gen_step_count >= self.max_steps + else "Proof aborted" + if self.give_up + else "Unable to proceed" + ) + self.recorder.end_proof_recording( + success=self.is_successful, + message=message, + final_stats={ + "successful_tactics": len(self.successful_tactics), + "failed_tactics": len(self.failed_tactics), + "query_commands": len(self.query_commands), + "steps_taken": self.global_step_id, + "steps_to_completion": ( + self.gen_step_count if self.is_successful else None + ), + "successful_tactics_list": self.successful_tactics, + "query_commands_list": self.query_commands, + }, + ) - def prove_theorem(self, theorem_name: str = None) -> bool: - """Main entry point for autonomous proof generation.""" - if not self._init_proof_session(theorem_name): + async def prove_theorem(self, theorem_name: str | None = None) -> bool: + if not await self._init_proof_session(theorem_name): return False - - tactics_with_states = self.main_loop() - - self._finish_proof(tactics_with_states) + async for _ in self.step_generator(): + pass + self._finish_proof(self._tactics_with_states) return self.is_successful - - def main_loop(self) -> List[Dict[str, Any]]: - """ - Main agent loop for autonomous (non-interactive) mode. - Drives step_generator() to completion and returns the accumulated tactics. - """ - for _ in self.step_generator(): + async def main_loop(self) -> List[Dict[str, Any]]: + async for _ in self.step_generator(): pass return self._tactics_with_states - - def step_generator(self): - """ - Generator version of the agent loop. Yields a result dict after each - tactic attempt or rollback. Plans and queries are executed transparently. - - Yield format: - {'type': 'tactic', 'tactic': str, 'success': bool, 'error': str|None, - 'goals_after': str, 'proof_complete': bool} - {'type': 'rollback', 'success': bool, 'distance': int} - {'type': 'done', 'success': bool} - - Requires _init_proof_session() to have been called first. - self._tactics_with_states is updated in-place so callers always see current state. - """ - # self._tactics_with_states holds all applied tactics (user + agent, in order). - # source field on each entry distinguishes them. Using an instance variable - # means external user rollback is visible when the generator resumes. - + async def step_generator(self) -> AsyncIterator[Dict[str, Any]]: consecutive_queries = 0 consecutive_errors = 0 - error_tactics = [] - abort_pending = False # set by a first 'Abort.'; a second one confirms giving up + error_tactics: list[str] = [] + abort_pending = False + tool_call_id = None + last_tool_success = False + role = "user" + assert self.proof_tree is not None + prompt = self.context_manager.build_initial_prompt( + self.proof_tree.get_proof_tree_string() + ) - def _clear_error_tracking(): - nonlocal consecutive_queries, consecutive_errors, error_tactics, abort_pending + def clear_error_tracking() -> None: + nonlocal consecutive_queries, consecutive_errors, abort_pending consecutive_queries = 0 consecutive_errors = 0 error_tactics.clear() abort_pending = False - tool_call_id = None - last_tool_success = False - role = "user" - proof_tree_str = self.proof_tree.get_proof_tree_string() - prompt = self.context_manager.build_initial_prompt(proof_tree_str) - while self.gen_step_count < self.max_steps: if self.global_step_id > self.max_steps * (self.max_context_search + 1): - self.gen_step_count = self.max_steps # so that proof completion message is "Max steps reached" - self.logger.info(f"\n{'='*60}\n📊 [PROOF STEP {self.global_step_id}] {self.gen_step_count}/{self.max_steps}. Exiting...") + self.gen_step_count = self.max_steps break - # Inject pending user hints if self._pending_hints: - hints_text = "\n".join(f"- {h}" for h in self._pending_hints) - prompt += f"\n\n## USER GUIDANCE:\n{hints_text}\n" + hints = "\n".join(f"- {hint}" for hint in self._pending_hints) + prompt += f"\n\n## USER GUIDANCE:\n{hints}\n" self._pending_hints.clear() - self.steps_since_restart += 1 self.global_step_id += 1 - self.logger.info(f"\n{'='*60}\n📊 [PROOF STEP {self.global_step_id}] {self.gen_step_count}/{self.max_steps}\n{'='*60}") - proof_state = self._build_proof_state() - - current_step_count = len(self._tactics_with_states) - current_step_count_coq = len(self.coq.proof.steps) if self.coq.proof.steps else 0 - expected_coq_steps = current_step_count + 1 # +1 for Proof. - self.logger.debug(f"Step count: total={current_step_count}, coq_file={current_step_count_coq}") - if expected_coq_steps != current_step_count_coq: - self.logger.error(f"Error: Step count mismatch! expected={expected_coq_steps} != coq={current_step_count_coq}. Exiting early...") - exit(1) - - decision, tool_call_id = self.context_manager.get_action(prompt, role=role, tool_call_id=tool_call_id, tool_success=last_tool_success) - + decision, tool_call_id = self.context_manager.get_action( + prompt, + role=role, + tool_call_id=tool_call_id, + tool_success=last_tool_success, + ) + budget_check = getattr( + self.context_manager, "cost_budget_exhausted", lambda: False + ) + exhausted = ( + budget_check() if callable(budget_check) else bool(budget_check) + ) + if exhausted: + self.cost_budget_exhausted = True + self.termination_reason = "cost budget exhausted" + self.logger.warning("Cost budget exhausted") + break if decision is None: - self.logger.error(f"❌ Step {self.global_step_id}: Failed to parse LLM decision. Skipping step.") - prompt = "Failed to parse your response. Please ensure you're calling one of the available functions." + consecutive_errors += 1 + prompt = "Failed to parse the response. Call one available function." tool_call_id = None role = "user" - consecutive_errors += 1 if consecutive_errors > self.max_errors: - self.logger.error(f"❌ Step {self.global_step_id}: LLM call failed. Exiting...") - self.logger.debug(f"Last few messages: {self.context_manager.chat_session.messages[-5:]}") break continue - decision_type = decision.get('type') - decision_content = decision.get('content') + decision_type = decision.get("type") + decision_content = decision.get("content") role = "tool" last_tool_success = False - if decision_type == 'plan': + if decision_type == "plan": + prompt = self.context_manager.handle_plan_call( + decision_content, tool_call_id + ) last_tool_success = True - prompt = self.context_manager.handle_plan_call(decision_content, tool_call_id) - print(visualizer.render_action('plan', decision_content)) - self.logger.info(f"✅ Step {self.global_step_id}: PLAN FUNCTION CALLED!") - continue # plan is transparent + print(visualizer.render_action("plan", decision_content)) + continue - elif decision_type == 'query': + if decision_type == "query": consecutive_queries += 1 if consecutive_queries > self.max_context_search: - prompt = "You have hit the maximum number of 'query' calls. Please proceed with the current information until a successful 'tactic', 'helper_lemma', or 'rollback' is applied." - self.logger.info(f"⚠️ Step {self.global_step_id}: QUERY MAX REACHED ({self.max_context_search})") + prompt = "The context-query limit was reached. Choose another action." continue - if consecutive_queries > 1 and decision_content == self.query_commands[-1]: - prompt = "You have just queried this information. Please provide a different query." - self.logger.info(f"⚠️ Step {self.global_step_id}: REPEATED QUERY -- skipped!") + if self.query_commands and decision_content == self.query_commands[-1]: + prompt = "That query was just run. Use a different query." continue self.query_commands.append(decision_content) - prompt = self.context_manager.handle_query_call(decision_content, tool_call_id) - print(visualizer.render_action('query', decision_content)) - if consecutive_queries == self.max_context_search: - prompt += "\n\nYou have hit the maximum number of 'query' calls. Please proceed with the current information until a successful 'tactic', 'helper_lemma', or 'rollback' is applied." - self.logger.info(f"✅ Step {self.global_step_id}: QUERY success: {decision_content}") - continue # query is transparent - - elif decision_type == 'rollback': - rollback_data = decision_content - rollback_reason = rollback_data.get('reason', 'No reason provided') - rollback_steps = rollback_data.get('steps', 1) - self.gen_step_count += 1 - self.logger.info(f"🔄 Step {self.global_step_id}: ROLLBACK {rollback_steps} step{'s' if rollback_steps != 1 else ''}: {rollback_reason}") - - agent_only = [t for t in self._tactics_with_states if t.get('source') != 'user'] - rollback_result = self._execute_rollback( - agent_only, rollback_reason, proof_tree_str, rb_steps=rollback_steps + prompt = await self.context_manager.handle_query_call( + decision_content, tool_call_id ) + print(visualizer.render_action("query", decision_content)) + continue - if rollback_result['success']: - target_index = rollback_result['target_index'] - target_step_number = rollback_result['target_step_number'] - rollback_distance = rollback_result['rollback_distance'] - # Keep tactics at or before the target step number - if target_index > 0: - self._tactics_with_states[:] = [t for t in self._tactics_with_states - if t['step_number'] <= target_step_number] + if decision_type == "rollback": + self.gen_step_count += 1 + data = decision_content + result = await self._execute_rollback( + [item for item in self._tactics_with_states if item.get("source") != "user"], + data.get("reason", "No reason provided"), + data.get("steps", 1), + ) + if result["success"]: + target_index = result["target_index"] + if target_index: + target_step = result["target_step_number"] + self._tactics_with_states[:] = [ + item + for item in self._tactics_with_states + if item["step_number"] <= target_step + ] else: - self._tactics_with_states[:] = [] + self._tactics_with_states.clear() self._refresh_helper_lemma_stack_from_history() + clear_error_tracking() last_tool_success = True - _clear_error_tracking() - proof_tree_str = self.proof_tree.get_proof_tree_string() - self.logger.info(f"✅ Rollback successful: {rollback_distance} step{'s' if rollback_distance != 1 else ''} back to index {target_index} (step_number {target_step_number})") - print(visualizer.render_action('rollback', rollback_data)) prompt = ( - f"Rollback completed successfully. Returned {rollback_distance} step{'s' if rollback_distance != 1 else ''} back to step {target_step_number}.\n\n" - f"## CURRENT PROOF TREE:\n{proof_tree_str}\n\n" - "Now consider a different approach to complete the proof. Update the plan if needed. Avoid repeating the same tactics that led to this rollback." + f"Rollback completed by {result['rollback_distance']} step(s).\n\n" + f"## CURRENT PROOF TREE:\n" + f"{self.proof_tree.get_proof_tree_string()}" ) - yield {'type': 'rollback', 'success': True, 'distance': rollback_distance} else: - prompt = f"Rollback failed: {rollback_result.get('message', 'Unknown error')}\nPlease continue with tactics." - self.logger.error(f"❌ Rollback failed: {rollback_result.get('message')}") - print(visualizer.render_action( - 'rollback', {**rollback_data, 'message': rollback_result['message']}, False - ), end='') - yield {'type': 'rollback', 'success': False, 'distance': 0} - continue - - elif decision_type == 'helper_lemma': - - self.gen_step_count += 1 # Update generation step count - - # Handle helper lemma proposal - returns the formatted assert statement - tactic_content = self.context_manager.handle_helper_lemma_call(decision_content, tool_call_id) - - hl_result = self.open_helper_lemma( - tactic_content, source='agent', purpose=decision_content['purpose'] - ) - - if hl_result['depth_exceeded']: - prompt = f"Helper lemma proposal rejected: maximum nesting depth ({self.MAX_HELPER_LEMMA_DEPTH}) reached.\n" - prompt += f"Current depth: {len(self.helper_lemma_stack)}. Please use regular tactics instead of proposing new helper lemmas.\n" - prompt += "Consider using existing hypotheses or searching for relevant lemmas." - yield {'type': 'tactic', 'tactic': 'helper_lemma', 'success': False, - 'error': 'Maximum helper lemma depth reached', 'goals_after': proof_state.get('goals', ''), 'proof_complete': False} - continue - - if hl_result['success']: - _clear_error_tracking() - last_tool_success = True - proof_tree_str = self.proof_tree.get_proof_tree_string() - - if hl_result['replayed']: - prompt = f"Helper lemma proved automatically from cached proof. Returned to parent proof context.\n\n" - prompt += "## CURRENT PROOF TREE:\n" - prompt += f"{proof_tree_str}\n" - prompt += f"Hypotheses: {hl_result['hypotheses_after'] if hl_result['hypotheses_after'] else 'None'}\n" - else: - # No cached proof or replay failed --> prove manually - prompt = f"Helper lemma assert statement applied successfully. You are now in a sub-proof context.\n\n" - prompt += "## CURRENT PROOF TREE:\n" - prompt += f"{proof_tree_str}\n\n" - prompt += f"Hypotheses: {hl_result['hypotheses_after'] if hl_result['hypotheses_after'] else 'None'}\n\n" - prompt += "Please provide tactics to prove this helper lemma." - - yield {'type': 'tactic', 'tactic': tactic_content, 'success': True, - 'error': None, 'goals_after': hl_result['goals_after'], 'proof_complete': False} - else: - # Failed to apply helper lemma assert statement - failed_error = hl_result['error'] - consecutive_errors += 1 - - prompt = f"Helper lemma assert statement application failed with error: {failed_error}\n" - prompt += "Please revise the helper lemma statement or try a different approach.\n" - if self.enable_error_feedback: - prompt += hints_from_error(tactic_content, failed_error) - - # Add history feedback - prompt += self._provide_history_feedback(consecutive_errors) - - yield {'type': 'tactic', 'tactic': tactic_content, 'success': False, - 'error': failed_error, 'goals_after': proof_state.get('goals', '').strip(), 'proof_complete': False} - + prompt = f"Rollback failed: {result['message']}" + yield { + "type": "rollback", + "success": result["success"], + "distance": result.get("rollback_distance", 0), + } continue - elif decision_type == 'tactic': + if decision_type == "helper_lemma": self.gen_step_count += 1 - tactic_content = self.context_manager.get_tactic(decision_content, tool_call_id) - - if tactic_content.startswith(("Search", "Print", "Check", "About")): - prompt = f"You have supplied a 'query' as a 'tactic'. Please call the 'query' tool instead.\n" - prompt += self.context_manager.handle_query_call(decision_content, tool_call_id) - self.logger.info(f"⚠️ Step {self.global_step_id}: QUERY AS TACTIC!") - self.query_commands.append(decision_content) - self.gen_step_count -= 1 - consecutive_queries += 1 - consecutive_errors += 1 - continue - - # Early abort: first 'Abort.' asks for confirmation, second one gives up - if tactic_content in ["Abort.", "abort.", ]: - if not abort_pending: - abort_pending = True - self.gen_step_count -= 1 - prompt = ( - "You have requested to abort the proof. If the goal is truly unprovable or you see no way " - "forward, reply with the 'tactic' 'Abort.' once more to give up permanently. " - "Otherwise, suggest a *different* tactic, review your plan, query for relevant terms, " - "or roll back to an earlier state.\n" - ) - self.logger.warning(f"⚠️ Step {self.global_step_id}: ABORT REQUESTED -- confirmation needed!") - continue - self.logger.warning(f"⚠️ Step {self.global_step_id}: ABORT CONFIRMED -- giving up!") - self.give_up = True - break - - if tactic_content in ["Admitted.", "admit."]: - prompt = "Tactic not allowed. Suggest a *different* tactic.\n" - prompt += "If some errors persist, you may consider (1) reviewing your plan, (2) searching/querying for relevant terms, or (3) rolling back to an earlier state." - self.logger.info(f"⚠️ Step {self.global_step_id}: ADMITTED TACTIC -- skipped!") - consecutive_errors += 1 - yield {'type': 'tactic', 'tactic': tactic_content, 'success': False, - 'error': 'Admitted not allowed', 'goals_after': '', 'proof_complete': False} + if len(self.helper_lemma_stack) >= self.MAX_HELPER_LEMMA_DEPTH: + yield self._failed_step( + "helper_lemma", + "Maximum helper lemma depth reached", + proof_state["goals"], + ) continue - - if tactic_content in error_tactics: - prompt = "This tactic has been tried and failed. Suggest a *different* tactic.\n" - prompt += "If some errors persist, you may consider (1) reviewing your plan, (2) searching/querying for relevant terms, or (3) rolling back to an earlier state." - self.logger.info(f"⚠️ Step {self.global_step_id}: REPEATING FAILED TACTIC -- skipped!") + helper_spec = self.context_manager.handle_helper_lemma_call( + decision_content, tool_call_id + ) + helper_result = await self.open_helper_lemma( + helper_spec, + source="agent", + purpose=decision_content.get("purpose", ""), + ) + tactic = helper_result["assert_statement"] + if not helper_result["success"]: consecutive_errors += 1 - yield {'type': 'tactic', 'tactic': tactic_content, 'success': False, - 'error': 'Repeated failed tactic', 'goals_after': '', 'proof_complete': False} + self.failed_tactics.append(tactic) + prompt = self._rejection_prompt( + tactic, consecutive_errors, helper=True + ) + yield self._failed_step( + tactic, helper_result["error"], proof_state["goals"] + ) continue - - subgoals_before = self.coq.get_subgoals() - goals_before = proof_state.get('goals', '').strip() - hypotheses_before = proof_state.get('hypotheses', '').strip() - - prompt = "" - success = self._apply_tactic(tactic_content) - - print(visualizer.render_action('tactic', tactic_content, success)) - - if not success: - consecutive_errors += 1 - error_tactics.append(tactic_content) - self.failed_tactics.append(tactic_content) - failed_error = self.coq.get_last_error() - self.logger.info(f"⚠️ Step {self.global_step_id}: TACTIC APPLICATION failed") - - if consecutive_errors == self.max_errors + 1 and self.enable_hammer: - success = self._try_hammer() - - if not success: - prompt += f"Tactic application failed with error: {failed_error}\n" - prompt += hints_from_error(tactic_content, failed_error) - if consecutive_errors > self.max_errors: - prompt += "\nIf errors persist, you may consider using other available tools." - prompt += self._provide_history_feedback(consecutive_errors) - yield {'type': 'tactic', 'tactic': tactic_content, 'success': False, - 'error': failed_error, 'goals_after': goals_before, 'proof_complete': False} - continue - else: - prompt += "Application failed with supplied tactic, but hammer succeeded.\n" - - # Tactic succeeded + clear_error_tracking() last_tool_success = True - _clear_error_tracking() - self.logger.info(f"🏆 Tactic applied: '{tactic_content}'") - - current_goals_after = self.coq.get_goal_str() - current_hypotheses_after = self.coq.get_hypothesis() - subgoals_after = self.coq.get_subgoals() - - tactic_with_state = self._handle_successful_tactic( - tactic_content, subgoals_before, subgoals_after, - goals_before, current_goals_after, hypotheses_before, current_hypotheses_after + replayed = helper_result["replayed"] + prompt = ( + "Helper lemma replayed from history." + if replayed + else "Helper lemma opened. Prove the focused subgoal." ) - tactic_with_state['source'] = 'agent' - self._tactics_with_states.append(tactic_with_state) - - post_tactic_status = self.coq.get_proof_completion_status() - proof_complete = post_tactic_status['is_complete'] and post_tactic_status['qed_already_applied'] - - self.logger.info(f"✅ Step {self.global_step_id}: TACTIC APPLIED SUCCESSFULLY!") - - if proof_complete: - self.is_successful = True - yield {'type': 'tactic', 'tactic': tactic_content, 'success': True, - 'error': None, 'goals_after': '', 'proof_complete': True} - break - - closed_lemma = self.close_helper_lemma_if_complete( - subgoals_after, current_goals_after, current_hypotheses_after, source='agent' - ) - if closed_lemma is not None: - print(visualizer.render_action('helper_lemma_closed', closed_lemma, True), end='') - proof_tree_str = self.proof_tree.get_proof_tree_string() - prompt += ( - "Helper lemma sub-proof completed and closed with '}'. Returned to parent proof context.\n\n" - f"## CURRENT PROOF TREE:\n{proof_tree_str}\n" - f"Hypotheses: {closed_lemma['hypotheses_after'] if closed_lemma['hypotheses_after'] else 'None'}\n" - ) - yield {'type': 'tactic', 'tactic': tactic_content, 'success': True, - 'error': None, 'goals_after': closed_lemma['goals_after'], 'proof_complete': False} - else: - proof_tree_str = self.proof_tree.get_proof_tree_string() - goals_after_str = str(current_goals_after).strip() if current_goals_after else '' - goals_before_str = goals_before - - prompt += f"Tactic '{tactic_content}' applied successfully.\n\n" - if goals_after_str != goals_before_str: - prompt += f"## CURRENT PROOF TREE:\n{proof_tree_str}\n" - else: - prompt += "Goals: No changes.\n" - prompt += f"Hypotheses: {current_hypotheses_after if current_hypotheses_after else 'None'}\n" - - yield {'type': 'tactic', 'tactic': tactic_content, 'success': True, - 'error': None, 'goals_after': goals_after_str, 'proof_complete': False} + yield self._successful_step(tactic) + continue - else: + if decision_type != "tactic": prompt = f"Invalid function call: {decision_type}" - self.logger.error(f"❌ Step {self.global_step_id}: INVALID FUNCTION CALL!") continue - yield {'type': 'done', 'success': self.is_successful} - - ############################ - ## Proof tree / state ## - ############################ - - def _init_proof_tree(self): - """Initialize a fresh proof tree with a root node from current Coq state.""" - self.proof_tree = ProofTree() - initial_goals = self.coq.get_goal_str() - self.logger.info(f"🎯 Initial goals: {initial_goals}") - initial_hypotheses = self.coq.get_hypothesis() - self.proof_tree.add_node( - tactic="Proof.", - goals_before=initial_goals.strip() if initial_goals else '', - goals_after=initial_goals.strip() if initial_goals else '', - hypotheses_before=initial_hypotheses.strip() if initial_hypotheses else '', - hypotheses_after=initial_hypotheses.strip() if initial_hypotheses else '', - step_number=0, - subgoals_after=self.coq.get_subgoals() - ) - - ##################### - ## Proof helpers ## - ##################### - - def _record_successful_proof(self, tactics_with_states: List[Dict[str, Any]]): - """ - Record a successful tactic application sequence in history. - This function should only be called if the sequence is a complete proof. - """ - # Record each tactic in the sequence - for state in tactics_with_states: - self.context_manager.tactic_history.add_successful_tactic( - tactic=state['tactic'], - goals_before=state['goals_before'], - goals_after=state['goals_after'], - hypotheses_before=state['hypotheses_before'], - hypotheses_after=state['hypotheses_after'], - theorem_name=self.current_theorem_name, - step_number=state['step_number'], - source=state.get('source', 'agent') + self.gen_step_count += 1 + tactic = self.context_manager.get_tactic( + decision_content, tool_call_id ) + command_kind = self.backend.classify_command(tactic) + if command_kind is CommandKind.ABORT: + if not abort_pending: + abort_pending = True + self.gen_step_count -= 1 + prompt = ( + "You requested to abort the proof. If the goal is truly " + "unprovable or there is no way forward, submit the abort " + "command once more to give up. Otherwise, try a different " + "tactic, revise the plan, query the prover, or roll back." + ) + continue + self.give_up = True + break + if command_kind is CommandKind.UNSOUND_COMPLETION: + consecutive_errors += 1 + yield self._failed_step( + tactic, "Incomplete proof commands are not allowed", self.goals() + ) + continue + if tactic in error_tactics: + consecutive_errors += 1 + yield self._failed_step( + tactic, "Repeated failed tactic", self.goals() + ) + continue - def _replay_helper_lemma_proof( - self, - cached_tactics: List[str], - successful_tactics_with_states: List[Dict[str, Any]], - source: str = 'agent', - ) -> bool: - """Replay a cached proof for a helper lemma sub-proof. - Applies all inner tactics, the closing brace, and pops the stack. - Returns True if the full sub-proof completed successfully.""" - - # Cached tactics do not include assert or open brace - # Cached tactics shoud end with closing brace - assert cached_tactics[-1] == '}' + accepted = await self._apply_and_record(tactic, source="agent") + print(visualizer.render_action("tactic", tactic, accepted)) + applied_tactic = tactic + if not accepted: + consecutive_errors += 1 + error_tactics.append(tactic) + self.failed_tactics.append(tactic) + failed_error = self._last_error + automation = self.backend.automation_command() + if ( + consecutive_errors == self.max_errors + 1 + and self.enable_hammer + and automation is not None + ): + accepted = await self._apply_and_record( + automation, source="agent" + ) + applied_tactic = automation + if not accepted: + prompt = self._rejection_prompt(tactic, consecutive_errors) + yield self._failed_step(tactic, failed_error, proof_state["goals"]) + continue - for tactic in cached_tactics: - subgoals_before = self.coq.get_subgoals() - goals_before = (self.coq.get_goal_str() or '').strip() - hyps_before = (self.coq.get_hypothesis() or '').strip() + clear_error_tracking() + last_tool_success = True + if self.state.is_complete: + self.is_successful = True + yield self._successful_step(applied_tactic) + break - if not self._apply_tactic(tactic): - self.logger.error(f"❌ Cached tactic failed during replay: {tactic}") - return False + if self.helper_lemma_stack: + closed = await self.close_helper_lemma_if_complete(source="agent") + if closed is not None: + prompt = ( + "Helper lemma completed. Returned to the parent proof.\n\n" + f"## CURRENT PROOF TREE:\n" + f"{self.proof_tree.get_proof_tree_string()}\n" + f"Hypotheses: {self.hypotheses() or 'None'}" + ) + yield self._successful_step(applied_tactic) + continue - self.global_step_id += 1 - subgoals_after = self.coq.get_subgoals() - goals_after = self.coq.get_goal_str() - hyps_after = self.coq.get_hypothesis() - - tw = self._handle_successful_tactic( - tactic, subgoals_before, subgoals_after, - goals_before, goals_after, - hyps_before, hyps_after + prompt = ( + f"Tactic '{applied_tactic}' applied successfully.\n\n" + f"## CURRENT PROOF TREE:\n" + f"{self.proof_tree.get_proof_tree_string()}\n" + f"Hypotheses: {self.hypotheses() or 'None'}" ) - tw['source'] = source - successful_tactics_with_states.append(tw) - self.successful_tactics.append(tactic) + yield self._successful_step(applied_tactic) - # '}' was the last cached tactic - self.helper_lemma_stack.pop() - self.logger.info(f"🔁 Helper lemma auto-replayed from cache ({len(cached_tactics)} tactics)") - return True + yield { + "type": "done", + "success": self.is_successful, + "reason": self.termination_reason, + } - def _record_successful_helper_lemma( - self, - assert_state: Dict[str, Any], - proof_tactics_with_states: List[Dict[str, Any]], - ): - """Record a complete helper lemma (assert + proof) for exact replay.""" + async def _apply_tactic(self, tactic: str) -> bool: + """Apply one command and retain typed rejection feedback.""" try: - self.context_manager.tactic_history.add_successful_helper_lemma( - assert_statement=assert_state['tactic'], - proof_tactics=[t['tactic'] for t in proof_tactics_with_states], - theorem_name=self.current_theorem_name, + result = await self.backend.apply(tactic) + except CommandRejectedError as error: + self._set_state(error.state) + self._last_error = ( + error.feedback[0].message if error.feedback else str(error) ) - self.logger.info(f"📝 Recorded helper lemma: {assert_state['tactic'][:60]}") - except Exception as e: - self.logger.error(f"❌ Failed to record helper lemma: {e}") + return False + self._set_state(result.state) + self._last_error = "" + return True - ############################### - ## Helper lemma lifecycle ## - ############################### - # Both step_generator() and the REPL drive helper lemmas through these. + async def _apply_and_record(self, tactic: str, *, source: str) -> bool: + before = self.state + if not await self._apply_tactic(tactic): + return False + self.global_step_id += 1 + record = self._handle_successful_tactic( + tactic, + before.goals, + self.state.goals, + ( + self.context_manager.render_goals(before) + if hasattr(self.context_manager, "render_goals") + else render_goals(before) + ), + self.goals(), + ( + self.context_manager.render_hypotheses(before) + if hasattr(self.context_manager, "render_hypotheses") + else render_hypotheses(before) + ), + self.hypotheses(), + ) + record["source"] = source + record["checkpoint"] = await self.backend.checkpoint() + self._tactics_with_states.append(record) + return True def helper_lemma_enabled(self) -> bool: - """Whether helper lemmas are available at all (ablation.enable_helper_lemma).""" + """Return whether helper lemmas are enabled for this proof run.""" return self.context_manager.enable_helper_lemma def helper_lemma_context(self) -> List[Dict[str, Any]]: - """Open helper lemma sub-proofs, outermost first; brace_index locates each '{'.""" + """Describe open helper scopes, outermost first.""" stack: List[Dict[str, Any]] = [] - states = self._tactics_with_states - for idx, state in enumerate(states): - tactic = state['tactic'].strip() - if tactic == '{': - assert_statement = states[idx - 1]['tactic'].strip() if idx > 0 else '' - name, statement = parse_assert_statement(assert_statement) + for index, state in enumerate(self._tactics_with_states): + scope = state.get("helper_scope") + commands = state.get("helper_commands") + if scope == "open" and isinstance(commands, HelperLemmaCommands): + name, statement = parse_assert_statement(commands.declaration) stack.append({ - 'assert_statement': assert_statement, - 'name': name, - 'statement': statement, - 'brace_index': idx, + "assert_statement": commands.declaration, + "name": name, + "statement": statement, + "brace_index": index, }) - elif tactic == '}' and stack: + elif scope == "close" and stack: stack.pop() return stack - def open_helper_lemma(self, assert_statement: str, source: str = 'agent', purpose: str = '') -> Dict[str, Any]: - """ - Introduce a helper lemma: apply its assert, then '{' to enter the sub-proof. - A cached proof of the same statement is replayed (result['replayed']). - - The caller allocates the assert's step number, as it does for any tactic. - Returns: success, error, depth_exceeded, replayed, depth, assert_statement, - name, statement, purpose, goals_after, hypotheses_after. - """ - name, statement = parse_assert_statement(assert_statement) + async def open_helper_lemma( + self, + spec: HelperLemmaSpec, + *, + source: str = "agent", + purpose: str = "", + ) -> Dict[str, Any]: + """Open an agent-selected helper scope and replay a cached proof if found.""" + commands = self.backend.helper_lemma_commands(spec) result: Dict[str, Any] = { - 'success': False, - 'error': None, - 'depth_exceeded': False, - 'replayed': False, - 'depth': len(self.helper_lemma_stack), - 'assert_statement': assert_statement, - 'name': name, - 'statement': statement, - 'purpose': purpose, - 'goals_after': '', - 'hypotheses_after': '', + "success": False, + "error": "", + "depth_exceeded": False, + "replayed": False, + "depth": len(self.helper_lemma_stack), + "assert_statement": commands.declaration, + "name": spec.name, + "statement": spec.statement, + "purpose": purpose, + "goals_after": self.goals(), + "hypotheses_after": self.hypotheses(), } - + if not self.helper_lemma_enabled(): + result["error"] = "Helper lemmas are disabled" + return result if len(self.helper_lemma_stack) >= self.MAX_HELPER_LEMMA_DEPTH: - result['depth_exceeded'] = True - result['error'] = f"Maximum helper lemma nesting depth ({self.MAX_HELPER_LEMMA_DEPTH}) reached" - self.logger.warning(f"⚠️ Helper lemma depth limit reached ({self.MAX_HELPER_LEMMA_DEPTH}). Rejecting new helper lemma proposal.") - print(visualizer.render_action('helper_lemma', result, False), end='') + result["depth_exceeded"] = True + result["error"] = ( + "Maximum helper lemma nesting depth " + f"({self.MAX_HELPER_LEMMA_DEPTH}) reached" + ) return result - - self.logger.info(f"🔧 Step {self.global_step_id}: HELPER LEMMA proposed (current depth: {len(self.helper_lemma_stack)})") - self.logger.info(f"Generated tactic:\n{assert_statement}") - - if self.enable_recording and self.recorder: - self.recorder.record_helper_lemma(assert_statement) - - subgoals_before = self.coq.get_subgoals() - goals_before = (self.coq.get_goal_str() or '').strip() - hypotheses_before = (self.coq.get_hypothesis() or '').strip() - - success = self._apply_tactic(assert_statement) - if not success: - result['error'] = self.coq.get_last_error() - print(visualizer.render_action('helper_lemma', result, success), end='') - - if not success: - self.failed_tactics.append(assert_statement) - self.logger.info(f"⚠️ Step {self.global_step_id}: HELPER LEMMA TACTIC failed with error: {result['error']}") + if self.recorder: + self.recorder.record_helper_lemma(commands.declaration) + if not await self._apply_and_record(commands.declaration, source=source): + result["error"] = self._last_error return result - - # Assert succeeded: record it, then open the sub-proof with '{' - subgoals_after_assert = self.coq.get_subgoals() - hypotheses_after_assert = self.coq.get_hypothesis() - goals_after_assert = self.coq.get_goal_str() - - tactic_with_state = self._handle_successful_tactic( - assert_statement, - subgoals_before, subgoals_after_assert, - goals_before, goals_after_assert, - hypotheses_before, hypotheses_after_assert + if not await self._apply_and_record(commands.open_scope, source=source): + error = self._last_error + if not await self.rollback(1, reason="Helper scope failed to open"): + raise ProverBackendError( + "backend rejected helper scope and rollback failed" + ) + result["error"] = error + return result + self._tactics_with_states[-1].update( + helper_scope="open", helper_commands=commands ) - tactic_with_state['source'] = source - self._tactics_with_states.append(tactic_with_state) - - brace_success = self._apply_tactic("{") - if brace_success: - self.helper_lemma_stack.append('{') - self.logger.info(f"🔧 Pushed '{{' to helper lemma stack (depth: {len(self.helper_lemma_stack)})") - - assert brace_success, "Failed to apply opening brace after helper lemma" - - subgoals_after_brace = self.coq.get_subgoals() - hypotheses_after_brace = self.coq.get_hypothesis() - goals_after_brace = self.coq.get_goal_str() + self.helper_lemma_stack.append(commands) + cached = ( + self.tactic_history.find_helper_lemma_proof(commands.declaration) + if self.tactic_history + else None + ) + replayed = bool(cached) and await self._replay_helper_lemma_proof( + cached, source=source + ) + result.update( + success=True, + replayed=replayed, + depth=len(self.helper_lemma_stack), + goals_after=self.goals(), + hypotheses_after=self.hypotheses(), + ) + return result - # INCREMENT STEP COUNT before applying opening brace - # This ensures { gets its own unique step number - self.global_step_id += 1 - self.logger.debug(f"📊 Incremented step count to {self.global_step_id} before applying opening brace") + async def close_helper_lemma_if_complete( + self, *, source: str = "agent", record: bool = True + ) -> Dict[str, Any] | None: + """Close the innermost helper scope when its native close command applies.""" + if not self.helper_lemma_stack: + return None + commands = self.helper_lemma_stack[-1] + if not await self._apply_and_record(commands.close_scope, source=source): + self._last_error = "" + return None + self._tactics_with_states[-1].update( + helper_scope="close", helper_commands=commands + ) + self.helper_lemma_stack.pop() + if record: + self._record_last_helper_lemma() + name, _ = parse_assert_statement(commands.declaration) + return { + "assert_statement": commands.declaration, + "name": name, + "recorded": record, + "goals_after": self.goals(), + "hypotheses_after": self.hypotheses(), + } - tactic_with_state = self._handle_successful_tactic( - "{", - subgoals_after_assert, subgoals_after_brace, - goals_after_assert, goals_after_brace, - hypotheses_after_assert, hypotheses_after_brace + async def abandon_helper_lemma(self) -> Dict[str, Any]: + """Roll back the innermost helper scope, including its declaration.""" + open_lemmas = self.helper_lemma_context() + if not open_lemmas: + return { + "success": False, + "message": "Not inside a helper lemma sub-proof.", + } + helper = open_lemmas[-1] + declaration_index = max(0, helper["brace_index"] - 1) + rollback_steps = len(self._tactics_with_states) - declaration_index + success = await self.rollback( + rollback_steps, reason="Abandon helper lemma sub-proof" ) - tactic_with_state['source'] = source - self._tactics_with_states.append(tactic_with_state) - - result['success'] = True - result['depth'] = len(self.helper_lemma_stack) - result['goals_after'] = goals_after_brace or '' - result['hypotheses_after'] = hypotheses_after_brace or '' - self.logger.info(f"✅ Step {self.global_step_id}: HELPER LEMMA ASSERT AND OPENING BRACE APPLIED SUCCESSFULLY!") - - # Try to auto-replay a cached proof for this helper lemma - cached_proof = self.tactic_history.find_helper_lemma_proof(assert_statement) if self.tactic_history else None - if cached_proof: - result['replayed'] = self._replay_helper_lemma_proof( - cached_proof, self._tactics_with_states, source=source + return { + "success": success, + "message": "" if success else self._last_error, + "rollback_distance": rollback_steps if success else 0, + "assert_statement": helper["assert_statement"], + "name": helper["name"], + } + + async def apply_user_tactic( + self, + tactic: str, + *, + record_helper_lemma: bool = True, + allow_unsound: bool = False, + ) -> bool: + tactic = tactic.strip() + if ( + self.backend.classify_command(tactic) + is CommandKind.UNSOUND_COMPLETION + and not allow_unsound + ): + self._last_error = "Incomplete proof commands are not allowed" + return False + accepted = await self._apply_and_record(tactic, source="user") + if not accepted: + return False + if self.helper_lemma_stack: + await self.close_helper_lemma_if_complete( + source="user", record=record_helper_lemma ) - if result['replayed']: - # inner tactics, closing brace and stack pop are all handled - result['depth'] = len(self.helper_lemma_stack) - result['goals_after'] = self.coq.get_goal_str() or '' - result['hypotheses_after'] = self.coq.get_hypothesis() or '' - self.logger.info(f"✅ Step {self.global_step_id}: HELPER LEMMA APPLIED AND AUTO-PROVED!") + if self.state.is_complete and not self.helper_lemma_stack: + self.is_successful = True + return True - return result + async def save_proof( + self, destination: Path, *, overwrite: bool = False + ) -> SavedProofCertificate: + return await self.backend.save_proof(destination, overwrite=overwrite) - def close_helper_lemma_if_complete( + async def _execute_rollback( self, - subgoals_before: Any, - goals_before: str, - hypotheses_before: str, - source: str = 'agent', - record: bool = True, - ) -> Optional[Dict[str, Any]]: - """ - Close the innermost helper lemma sub-proof when its goal is discharged. - The *_before arguments describe the state after the last tactic and before - '}', which is_helper_lemma_proof_complete() applies as a side effect. - - Returns None while the sub-proof is still open. record=False keeps the - proof out of history, as when 'admit' discharged it. - """ - if not self.helper_lemma_stack or not self.coq.is_helper_lemma_proof_complete(): - return None - - # '}' should have been applied in is_helper_lemma_proof_complete() - proof = self.coq.get_unproven_proof() - assert proof and proof.steps and proof.steps[-1].text.strip() in ['}', ' }'] + history: List[Dict[str, Any]], + reason: str, + rb_steps: int, + ) -> Dict[str, Any]: + if not history: + return {"success": False, "message": "No successful tactics to roll back"} + if rb_steps <= 0: + return {"success": False, "message": "Rollback steps must be positive"} + + target_index = max(0, len(history) - rb_steps) + if target_index and history[target_index].get("helper_scope") == "open": + target_index -= 1 + rollback_distance = len(history) - target_index + target_step = history[target_index - 1]["step_number"] if target_index else 0 + checkpoint = ( + history[target_index - 1]["checkpoint"] + if target_index + else self._initial_checkpoint + ) + if checkpoint is None: + return {"success": False, "message": "Initial checkpoint is unavailable"} - self.logger.info(f"🎉 Helper lemma sub-proof completed! Closing brace applied") - self.logger.debug(f"📚 Helper lemma stack depth before closing: {len(self.helper_lemma_stack)}") + try: + self._set_state(await self.backend.rollback(checkpoint)) + except ProverBackendError as error: + return {"success": False, "message": str(error)} + + if self.proof_tree is not None: + self.proof_tree.delete_subtree_by_step_number(target_step) + if self.recorder: + self.recorder.record_rollback( + at_step=self.global_step_id, + rollback_steps=rollback_distance, + target_step=target_step, + reason=reason, + ) + return { + "success": True, + "target_index": target_index, + "target_step_number": target_step, + "rollback_distance": rollback_distance, + "message": f"Rolled back {rollback_distance} steps", + } - self.helper_lemma_stack.pop() - self.logger.info(f"✅ Returned to parent proof context") - self.logger.debug(f"📚 Helper lemma stack depth after closing: {len(self.helper_lemma_stack)}") + async def rollback(self, steps: int, *, reason: str = "User rollback") -> bool: + result = await self._execute_rollback( + self._tactics_with_states, reason, steps + ) + if not result["success"]: + self._last_error = result["message"] + return False + self._tactics_with_states[:] = self._tactics_with_states[ + : result["target_index"] + ] + self._refresh_helper_lemma_stack_from_history() + return True - subgoals_after_brace = self.coq.get_subgoals() - goals_after_brace = self.coq.get_goal_str() - hypotheses_after_brace = self.coq.get_hypothesis() + def _init_proof_tree(self) -> None: + self.proof_tree = ProofTree() + goals = self.goals() + hypotheses = self.hypotheses() + self.proof_tree.add_node( + tactic="proof start", + goals_before=goals, + goals_after=goals, + hypotheses_before=hypotheses, + hypotheses_after=hypotheses, + step_number=0, + subgoals_after=self.subgoals(), + ) - # INCREMENT STEP COUNT after applying closing brace - # This ensures } gets its own unique step number - self.global_step_id += 1 - self.logger.debug(f"📊 Incremented step count to {self.global_step_id} after applying closing brace") + def _handle_successful_tactic( + self, + tactic, + subgoals_before, + subgoals_after, + goals_before, + goals_after, + hypotheses_before, + hypotheses_after, + ) -> Dict[str, Any]: + self.successful_tactics.append(tactic) + assert self.proof_tree is not None + if len(subgoals_after) > len(subgoals_before): + self.proof_tree.add_branching_node( + tactic=tactic, + goals_before=goals_before, + goals_after=goals_after, + hypotheses_before=hypotheses_before, + hypotheses_after=hypotheses_after, + step_number=self.global_step_id, + subgoals=list(subgoals_after), + ) + else: + self.proof_tree.attach_to_correct_subgoal( + tactic=tactic, + goals_before=goals_before, + goals_after=goals_after, + hypotheses_before=hypotheses_before, + hypotheses_after=hypotheses_after, + step_number=self.global_step_id, + subgoals_before=list(subgoals_before), + subgoals_after=list(subgoals_after), + ) + if self.recorder: + self.recorder.update_proof_statistics( + successful_tactics=len(self.successful_tactics), + failed_tactics=len(self.failed_tactics), + query_commands=len(self.query_commands), + total_steps=self.global_step_id, + ) + return { + "tactic": tactic, + "goals_before": goals_before.strip(), + "goals_after": goals_after.strip(), + "hypotheses_before": hypotheses_before.strip(), + "hypotheses_after": hypotheses_after.strip(), + "step_number": self.global_step_id, + } - tactic_with_state = self._handle_successful_tactic( - "}", - subgoals_before, subgoals_after_brace, - goals_before, goals_after_brace, - hypotheses_before, hypotheses_after_brace + async def _replay_helper_lemma_proof( + self, tactics: List[str], *, source: str = "agent" + ) -> bool: + if not self.helper_lemma_stack: + return False + helper_commands = self.helper_lemma_stack[-1] + if not tactics or tactics[-1] != helper_commands.close_scope: + return False + for tactic in tactics: + if not await self._apply_and_record(tactic, source=source): + return False + self._tactics_with_states[-1].update( + helper_scope="close", helper_commands=helper_commands ) - tactic_with_state['source'] = source - self._tactics_with_states.append(tactic_with_state) + self.helper_lemma_stack.pop() + return True - # Walk back to this sub-proof's '{', stepping over nested pairs: stopping at - # the first '{' would record a nested lemma against this proof's tail. - brace_idx = None + def _record_last_helper_lemma(self) -> None: + """Record the helper scope closed by the most recent command.""" + if not self._tactics_with_states: + return + close_index = len(self._tactics_with_states) - 1 nested = 0 - for idx in range(len(self._tactics_with_states) - 2, -1, -1): # skip the '}' just added - tactic = self._tactics_with_states[idx]['tactic'].strip() - if tactic == '}': + open_index: int | None = None + for index in range(close_index - 1, -1, -1): + scope = self._tactics_with_states[index].get("helper_scope") + if scope == "close": nested += 1 - elif tactic == '{': + elif scope == "open": if nested == 0: - brace_idx = idx + open_index = index break nested -= 1 + if open_index is None or open_index == 0: + return + assertion = self._tactics_with_states[open_index - 1] + proof = self._tactics_with_states[open_index + 1 : close_index + 1] + self._record_successful_helper_lemma(assertion, proof) - # The proof is everything after '{'; the assert sits right before it - hl_tactics_with_states = self._tactics_with_states[brace_idx + 1:] if brace_idx is not None else [] - assert_tactic_state = ( - self._tactics_with_states[brace_idx - 1] - if brace_idx is not None and brace_idx > 0 else None - ) - - # Record the complete helper lemma as a reusable unit - recorded = False - if assert_tactic_state is not None and record: - self._record_successful_helper_lemma(assert_tactic_state, hl_tactics_with_states) - recorded = True - - assert_statement = assert_tactic_state['tactic'] if assert_tactic_state else '' - return { - 'assert_statement': assert_statement, - 'name': parse_assert_statement(assert_statement)[0], - 'recorded': recorded, - 'goals_after': goals_after_brace or '', - 'hypotheses_after': hypotheses_after_brace or '', - } - - def abandon_helper_lemma(self) -> Dict[str, Any]: - """ - Roll back the innermost open helper lemma, removing its assert and '{' - together with every tactic tried inside it. - """ - open_lemmas = self.helper_lemma_context() - if not open_lemmas: - return {'success': False, 'message': 'Not inside a helper lemma sub-proof.'} - - # Remove the assert as well, so the parent goal is restored untouched - assert_idx = max(0, open_lemmas[-1]['brace_index'] - 1) - rb_steps = len(self._tactics_with_states) - assert_idx - - proof_tree_str = self.proof_tree.get_proof_tree_string() if self.proof_tree else '' - result = self._execute_rollback( - self._tactics_with_states, - reason='Abandon helper lemma sub-proof', - proof_tree_str=proof_tree_str, - rb_steps=rb_steps, + def _record_successful_helper_lemma( + self, assert_state: Dict[str, Any], proof_states: List[Dict[str, Any]] + ) -> None: + if not self.tactic_history: + return + self.tactic_history.add_successful_helper_lemma( + assert_statement=assert_state["tactic"], + proof_tactics=[state["tactic"] for state in proof_states], + theorem_name=self.current_theorem_name, ) - if result['success']: - target_step_number = result['target_step_number'] - if result['target_index'] > 0: - self._tactics_with_states[:] = [ - t for t in self._tactics_with_states if t['step_number'] <= target_step_number - ] - else: - self._tactics_with_states[:] = [] - self._refresh_helper_lemma_stack_from_history() - result['assert_statement'] = open_lemmas[-1]['assert_statement'] - result['name'] = open_lemmas[-1]['name'] - self.logger.info(f"🗑️ Abandoned helper lemma: {result['assert_statement'][:60]}") - - return result - - def _handle_successful_tactic(self, successful_tactic, subgoals_before, subgoals_after, goals_before, goals_after, hypotheses_before, hypotheses_after) -> Dict[str, Any]: - """ - Handle successful tactic by updating proof tree. - Returns all the information about the tactic application as a dictionary. - """ - try: - self.successful_tactics.append(successful_tactic) - # Update proof tree - tactic_with_state = self._update_proof_tree(subgoals_before, subgoals_after, successful_tactic, goals_before, goals_after, hypotheses_before, hypotheses_after) - # Update in recorder - if self.enable_recording and self.recorder: - self.recorder.update_proof_statistics( - successful_tactics=len(self.successful_tactics), - failed_tactics=len(self.failed_tactics), - query_commands=len(self.query_commands), - total_steps=self.global_step_id - ) - - return tactic_with_state - - except Exception as e: - self.logger.error(f"❌ Error handling successful tactic: {e}") - return False - - def _update_proof_tree(self, subgoals_before, subgoals_after, successful_tactic, goals_before, goals_after, hypotheses_before, hypotheses_after) -> Dict[str, Any]: - """ - Maintain proof tree by adding nodes for branching / linear tactics. - Returns the tactic_with_state dictionary. - """ - try: - # Helper function to convert Goal object to string - def goal_to_str(goal) -> str: - """Convert Goal object to string for comparison.""" - if hasattr(goal, 'ty'): - return str(goal.ty).strip() - elif isinstance(goal, str): - return goal.strip() - else: - return str(goal).strip() - - creates_branching = len(subgoals_after) > len(subgoals_before) - - if creates_branching: - if self.proof_tree.open_subgoals: - self.logger.debug(f"🌳 Branching tactic detected: {len(subgoals_before)} -> {len(subgoals_after)} subgoals") - # Add branching node with intermediate subgoal nodes - node = self.proof_tree.add_branching_node( - tactic=successful_tactic, - goals_before=goals_before, - goals_after=goals_after.strip() if goals_after else '', - hypotheses_before=hypotheses_before, - hypotheses_after=hypotheses_after.strip() if hypotheses_after else '', - step_number=self.global_step_id, - subgoals=subgoals_after - ) - self.logger.debug(f"🌳 Added branching node: {len(subgoals_after)} subgoals created") - else: - self.logger.warning(f"⚠️ No open subgoals to attach branching tactic [{successful_tactic}] to. Skipping node addition.") - - else: - # Regular linear step - attach to correct subgoal - if self.proof_tree.open_subgoals: - self.logger.debug(f"🌳 Linear tactic: attaching to correct subgoal") - # Use the smart attachment method - node = self.proof_tree.attach_to_correct_subgoal( - tactic=successful_tactic, - goals_before=goals_before, - goals_after=goals_after.strip() if goals_after else '', - hypotheses_before=hypotheses_before, - hypotheses_after=hypotheses_after.strip() if hypotheses_after else '', - step_number=self.global_step_id, - subgoals_before=subgoals_before, - subgoals_after=subgoals_after - ) - else: - # No open subgoals - add as regular node - self.logger.debug(f"🌳 Linear tactic: no open subgoals, adding regular node") - if self.proof_tree.open_subgoals: - node = self.proof_tree.add_node( - tactic=successful_tactic, - goals_before=goals_before, - goals_after=goals_after.strip() if goals_after else '', - hypotheses_before=hypotheses_before, - hypotheses_after=hypotheses_after.strip() if hypotheses_after else '', - step_number=self.global_step_id, - subgoals_after=subgoals_after - ) - else: - self.logger.warning(f"⚠️ No open subgoals to attach tactic [{successful_tactic}] to. Skipping node addition.") - - except Exception as tree_error: - import traceback - self.logger.error(f"❌ Error updating proof tree: {tree_error}") - self.logger.error(f"📋 Tree update traceback: {traceback.format_exc()}") - - # --- End proof tree update --- - return { - 'tactic': successful_tactic, - 'goals_before': goals_before.strip() if goals_before else '', - 'goals_after': goals_after.strip() if goals_after else '', - 'hypotheses_before': hypotheses_before.strip() if hypotheses_before else '', - 'hypotheses_after': hypotheses_after.strip() if hypotheses_after else '', - 'step_number': self.global_step_id - } + def _record_successful_proof( + self, tactics_with_states: List[Dict[str, Any]] + ) -> None: + if not self.tactic_history: + return + for state in tactics_with_states: + self.tactic_history.add_successful_tactic( + tactic=state["tactic"], + goals_before=state["goals_before"], + goals_after=state["goals_after"], + hypotheses_before=state["hypotheses_before"], + hypotheses_after=state["hypotheses_after"], + theorem_name=self.current_theorem_name, + step_number=state["step_number"], + source=state.get("source", "agent"), + ) def _build_proof_state(self) -> Dict[str, Any]: - """Build current proof state for tactic generation.""" return { - "goals": self.coq.get_goal_str(), - "hypotheses": self.coq.get_hypothesis(), + "goals": self.goals(), + "hypotheses": self.hypotheses(), "step_number": self.global_step_id, - "successful_tactics": self.successful_tactics[-5:], # Last 5 successful tactics - "failed_tactics": self.failed_tactics[-3:], # Last 3 failed tactics + "successful_tactics": self.successful_tactics[-5:], + "failed_tactics": self.failed_tactics[-3:], } - def _apply_tactic(self, tactic: str) -> bool: - """Apply a single tactic to the current proof state.""" - return self.coq.apply_tactic(tactic) - - def _execute_rollback(self, successful_tactics_with_states: List[Dict], reason: str, proof_tree_str: str, rb_steps: int) -> Dict[str, Any]: - """ - Execute rollback using the number of steps to rollback. - - Args: - successful_tactics_with_states: List of successful tactic records - reason: Reason for rollback (from LLM) - proof_tree_str: Current proof tree string - rb_steps: Number of steps to rollback - - Returns: - Dict with rollback result including success status and target step - """ - try: - if not successful_tactics_with_states: - return { - 'success': False, - 'message': 'Cannot rollback - no successful tactics to rollback to' - } - - self.logger.info(f"📊 Current state: {len(successful_tactics_with_states)} successful tactics") - - # Determine rollback target - current_step_count = len(successful_tactics_with_states) - - # Validate rollback steps - if rb_steps <= 0: - return { - 'success': False, - 'message': f'Invalid rollback steps: {rb_steps} (must be positive)' - } - - # Calculate target index in successful_tactics_with_states - target_index = max(0, current_step_count - rb_steps) - rollback_distance = current_step_count - target_index - - # SPECIAL HANDLING: Check if the target tactic is '{' (opening brace) - # If so, we need to rollback one more step to remove the preceding assert statement - if target_index > 0 and target_index < len(successful_tactics_with_states): - target_tactic = successful_tactics_with_states[target_index].get('tactic', '').strip() - - if target_tactic == '{': - self.logger.info(f"🔧 Detected rollback to opening brace '{{'. Rolling back one more step to remove helper lemma assert.") - - # Check if there's a preceding tactic (should be the assert statement) - if target_index > 0: - preceding_tactic = successful_tactics_with_states[target_index - 1].get('tactic', '').strip() - self.logger.info(f"🔧 Preceding tactic: {preceding_tactic[:50]}...") - - # Rollback one more step to include the assert statement - target_index -= 1 - rollback_distance += 1 - - self.logger.info(f"🔧 Adjusted rollback: will remove both assert and opening brace (total {rollback_distance} steps)") - - # Update helper lemma stack - pop one level since we're removing the opening brace - if self.helper_lemma_stack: - self.helper_lemma_stack.pop() - self.logger.info(f"🔧 Popped from helper lemma stack (new depth: {len(self.helper_lemma_stack)})") - - # Get the actual step_number from the proof tree at this index - # This is critical because successful_tactics_with_states may have gaps - # (failed tactics are not included, so indices != step_numbers) - if target_index > 0 and target_index <= len(successful_tactics_with_states): - target_step_number = successful_tactics_with_states[target_index - 1]['step_number'] - - self.logger.info(f"🎯 Target: index {target_index} → step_number {target_step_number}") - else: - target_step_number = 1 # Rollback to very beginning - target_index = 0 - self.logger.info(f"🎯 Target: beginning (step_number 1)") - - # Log warnings for edge cases - if rb_steps > current_step_count: - self.logger.warning(f"⚠️ Requested {rb_steps} steps but only {current_step_count} available. Rolling back to step 1.") - target_index = 0 - target_step_number = 1 - rollback_distance = current_step_count - - # For debugging - if target_index > 0: - kept_steps = [t['step_number'] for t in successful_tactics_with_states[:target_index]] - removed_steps = [t['step_number'] for t in successful_tactics_with_states[target_index:]] - self.logger.debug(f"📋 Keeping step_numbers: {kept_steps}") - self.logger.debug(f"📋 Removing step_numbers: {removed_steps}") - - reasoning = f'Rollback by {rb_steps} step{"s" if rb_steps != 1 else ""}' - - # Step 2: Update proof tree - delete subtree beyond target step_number - if self.proof_tree: - self.logger.debug(f"🌳 Proof tree BEFORE rollback:") - proof_tree_str_before = self.proof_tree.get_proof_tree_string() - self.logger.debug(f"\n{proof_tree_str_before[:200]}...\n") - - tree_result = self.proof_tree.delete_subtree_by_step_number(target_step_number) - if tree_result: - self.logger.debug(f"🌳 Proof tree updated: kept step_number {target_step_number}, removed descendants") - else: - self.logger.warning(f"⚠️ Failed to update proof tree for step_number {target_step_number}") - - self.logger.debug(f"🌳 Proof tree AFTER rollback:") - proof_tree_str_after = self.proof_tree.get_proof_tree_string() - self.logger.debug(f"\n{proof_tree_str_after[:200]}...\n") - - # Step 4: Rollback Coq proof state by popping steps - if rollback_distance > 0: - self.logger.info(f"🔙 Popping {rollback_distance} proof steps from Coq") - - # Get the current unproven proof from CoqInterface - proof = self.coq.get_unproven_proof() - if proof: - try: - for i in range(rollback_distance): - steps_before = len(proof.steps) - self.logger.debug(f"Step to pop: {proof.steps[-1].step}") - self.coq.proof_file.pop_step(proof) - steps_after = len(proof.steps) - if steps_before != steps_after + 1: - raise Exception(f"Error: Step count mismatch after pop_step(). Steps before: {steps_before}, Steps after: {steps_after}") - except Exception as pop_error: - self.logger.error(f"{pop_error}. Exiting early...") - exit(1) # Fail fast - - # Step 4b: Refresh CoqInterface's cached proof object - # Get a fresh proof object that reflects the current file state - self.coq.proof = self.coq.get_unproven_proof() - if self.coq.proof: - self.logger.debug(f"🔄 Refreshed proof object: now has {len(self.coq.proof.steps)} steps") - else: - self.logger.warning(f"⚠️ No proof object after rollback refresh") - - # Step 5: Record rollback in recorder if enabled - if self.enable_recording and self.recorder: - try: - self.recorder.record_rollback( - at_step=self.global_step_id, - rollback_steps=rollback_distance, - target_step=target_step_number, - reason=reason - ) - - except Exception as record_error: - self.logger.warning(f"⚠️ Failed to record rollback: {record_error}") - - return { - 'success': True, - 'target_index': target_index, # Index in successful_tactics list for slicing - 'target_step_number': target_step_number, # Actual step number in proof tree - 'rollback_distance': rollback_distance, - 'message': f'Rolled back {rollback_distance} steps to step_number {target_step_number}', - 'source': 'llm', - 'reasoning': reasoning - } - - except Exception as e: - import traceback - self.logger.error(f"💀 Exception during rollback execution: {e}") - self.logger.error(f"📋 Rollback traceback: {traceback.format_exc()}") - return { - 'success': False, - 'message': f'Rollback execution failed: {str(e)}' - } - + def _rejection_prompt( + self, tactic: str, consecutive_errors: int, *, helper: bool = False + ) -> str: + kind = "Helper lemma" if helper else "Tactic" + prompt = f"{kind} application failed with error: {self._last_error}\n" + if self.enable_error_feedback: + prompt += hints_from_error(tactic, self._last_error) + if consecutive_errors > self.max_errors: + prompt += "\nConsider a context query, rollback, or a different plan." + return prompt + self._provide_history_feedback(consecutive_errors) def _provide_history_feedback(self, consecutive_errors: int) -> str: - # Add suggestions from history ONCE per proof state - if not self.context_manager.enable_history_context: - return "" - if consecutive_errors != self.max_errors + 1: + if ( + not self.context_manager.enable_history_context + or consecutive_errors != self.max_errors + 1 + or not self.goals() + ): return "" - if not self.coq.get_goal_str(): - return "" - - clean_goal_str = clean_ansi_codes(self.coq.get_goal_str()) + current_goal = clean_ansi_codes(self.goals()) feedback = "" - - # Suggest similar tactics - similar_proof_states = self.context_manager.get_similar_history(clean_goal_str, n=5) - if similar_proof_states: - feedback += "\nHere are some tactics found in history, formatted as '. \\n ':\n\n" - for i, entry in enumerate(similar_proof_states, 1): - tactic = clean_ansi_codes(str(entry.get('tactic', 'Unknown'))).strip() - if tactic in ('', '{', '}', 'Unknown'): + similar = self.context_manager.get_similar_history(current_goal, n=5) + if similar: + feedback += "\nPreviously successful tactics for similar goals:\n" + for index, entry in enumerate(similar, 1): + tactic = clean_ansi_codes(str(entry.get("tactic", ""))).strip() + if ( + not tactic + or self.backend.classify_command(tactic) + is CommandKind.STRUCTURAL + ): continue - goals_before = clean_ansi_codes(str(entry.get('goals_before', ''))) - diff_str = goal_diff(clean_goal_str, goals_before) - feedback += f" {i}. {tactic}\n{diff_str[:100]}{'...' if len(diff_str) > 100 else ''}\n" - - similar_hls = self.tactic_history.get_similar_helper_lemmas(clean_goal_str, n=3) if self.tactic_history else [] - if similar_hls: - feedback += "\nHere are previously proved helper lemmas that may be relevant. Their proof will be applied automatically when you call `helper_lemma` with the same statement.\n\n" - for i, hl in enumerate(similar_hls, 1): - stmt = clean_ansi_codes(str(hl.get('assert_statement', ''))).strip() - feedback += f" {i}. {stmt}\n" - + previous_goal = clean_ansi_codes( + str(entry.get("goals_before", "")) + ) + feedback += ( + f"{index}. {tactic}\n" + f"{goal_diff(current_goal, previous_goal)[:100]}\n" + ) + if self.tactic_history: + helper_lemmas = self.tactic_history.get_similar_helper_lemmas( + current_goal, n=3 + ) + if helper_lemmas: + feedback += "\nPreviously proved helper lemmas:\n" + for index, item in enumerate(helper_lemmas, 1): + feedback += f"{index}. {item.get('assert_statement', '')}\n" return feedback - def _refresh_helper_lemma_stack_from_history(self): - """Rebuild helper-lemma nesting depth from applied tactic history.""" - depth = 0 + def _refresh_helper_lemma_stack_from_history(self) -> None: + stack: list[HelperLemmaCommands] = [] for state in self._tactics_with_states: - tactic = state.get('tactic', '').strip() - if tactic == '{': - depth += 1 - elif tactic == '}': - depth = max(0, depth - 1) - self.helper_lemma_stack = ['{'] * min(depth, self.MAX_HELPER_LEMMA_DEPTH) - - - def _try_hammer(self) -> bool: - """ - Try to apply hammer to the current proof state. - """ - success = self._apply_tactic("hammer.") - if success: - self.logger.info(f"🔧 Hammer applied successfully") - return True - else: - hammer_error = self.coq.get_last_error() - if "ATPs failed to find a proof" in hammer_error: - self.logger.info(f"🔧 Hammer is not able to find a proof. Continuing...") - else: - self.logger.warning(f"🔧 Hammer failed with unknown error: {hammer_error}") - return False - + scope = state.get("helper_scope") + commands = state.get("helper_commands") + if scope == "open" and isinstance(commands, HelperLemmaCommands): + stack.append(commands) + elif scope == "close" and stack: + stack.pop() + self.helper_lemma_stack = stack[-self.MAX_HELPER_LEMMA_DEPTH :] + + def _successful_step(self, tactic: str) -> Dict[str, Any]: + return { + "type": "tactic", + "tactic": tactic, + "success": True, + "error": None, + "goals_after": self.goals(), + "proof_complete": self.state.is_complete, + } + + @staticmethod + def _failed_step( + tactic: str, error: str, goals_after: str + ) -> Dict[str, Any]: + return { + "type": "tactic", + "tactic": tactic, + "success": False, + "error": error, + "goals_after": goals_after, + "proof_complete": False, + } + + def _cleanup_large_states(self) -> None: + if ( + self.coq_chat_session + and hasattr(self.coq_chat_session, "messages") + and len(self.coq_chat_session.messages) > 20 + ): + self.coq_chat_session.messages = self.coq_chat_session.messages[-20:] + if len(self.query_commands) > 10: + self.query_commands = self.query_commands[-10:] diff --git a/proof-search/agent/proof_tree.py b/proof-search/agent/proof_tree.py index 978417e..193aa01 100644 --- a/proof-search/agent/proof_tree.py +++ b/proof-search/agent/proof_tree.py @@ -160,6 +160,12 @@ def extract_goal_conclusion(goal) -> tuple: Returns: (conclusion_str, hypotheses_str) """ + if hasattr(goal, "conclusion"): + hypotheses = getattr(goal, "hypotheses", ()) + hyps_str = "\n".join( + f"{entry.name}: {entry.type_text}" for entry in hypotheses + ) + return (str(goal.conclusion).strip(), hyps_str) if hasattr(goal, 'ty'): goal_full = str(goal.ty).strip() @@ -737,4 +743,3 @@ def __str__(self): def pretty_print(self): self.logger.info(self.__str__()) - diff --git a/proof-search/agent/rendering.py b/proof-search/agent/rendering.py new file mode 100644 index 0000000..9694224 --- /dev/null +++ b/proof-search/agent/rendering.py @@ -0,0 +1,175 @@ +"""Prover-agnostic rendering of typed proof state for prompts and the UI. + +Shared by `context_manager.py` and `proof_controller.py`. It exists as its own +module because `proof_controller.py` imports `ContextManager`, so the renderers +cannot live in either file without a circular import. +""" + +from __future__ import annotations + +from typing import Callable + +from backend.prover_backend import ContextEntry, ProofState + +ElisionCallback = Callable[[str, int], None] + +# Keeps the initial prompt bounded on NTP4VC obligations, which can carry many +# generated global declarations. The agent's `query` tool is the paper-intended +# way to look up anything not shown here. +_MAX_GLOBAL_ENTRIES = 20 + + +def _cap_text( + text: str, + *, + max_chars: int | None, + kind: str, + on_elision: ElisionCallback | None, +) -> str: + if max_chars is None or len(text) <= max_chars: + return text + marker = "... [elided; use 'query' for details]" + prefix_length = max(0, max_chars - len(marker)) + while True: + elided = len(text) - prefix_length + marker = ( + f"... [elided {elided} characters; use 'query' for details]" + ) + adjusted = max(0, max_chars - len(marker)) + if adjusted == prefix_length: + break + prefix_length = adjusted + if on_elision is not None: + on_elision(kind, len(text) - prefix_length) + if len(marker) > max_chars: + return marker[:max_chars] + return text[:prefix_length] + marker + + +def render_goals( + state: ProofState, + *, + max_chars: int | None = None, + on_elision: ElisionCallback | None = None, +) -> str: + """Render typed goals for prompts and the existing proof-tree UI.""" + if not state.goals: + return "" + conclusions = [ + _cap_text( + goal.conclusion, + max_chars=max_chars, + kind="goal", + on_elision=on_elision, + ) + for goal in state.goals + ] + if len(conclusions) == 1: + return conclusions[0] + return "\n\n".join( + f"Goal {index + 1}:\n{conclusion}" + for index, conclusion in enumerate(conclusions) + ) + + +def render_hypotheses( + state: ProofState, + *, + max_chars: int | None = None, + on_elision: ElisionCallback | None = None, +) -> str: + """Render hypotheses for the focused goal.""" + if not state.goals: + return "" + return "\n".join( + render_context_entry( + entry, + max_chars=max_chars, + kind="hypothesis", + on_elision=on_elision, + ) + for entry in state.goals[0].hypotheses + ) + + +def render_context_entry( + entry: ContextEntry, + *, + max_chars: int | None = None, + kind: str = "context", + on_elision: ElisionCallback | None = None, +) -> str: + if entry.value_text is None: + rendered = f"{entry.name} : {entry.type_text}" + else: + rendered = f"{entry.name} := {entry.value_text} : {entry.type_text}" + return _cap_text( + rendered, + max_chars=max_chars, + kind=kind, + on_elision=on_elision, + ) + + +def render_global_context( + state: ProofState, + *, + limit: int = _MAX_GLOBAL_ENTRIES, + max_chars: int | None = None, + on_elision: ElisionCallback | None = None, +) -> str: + """Render a capped slice of the global declarations `open` reported.""" + entries = state.context.global_entries + if not entries: + return "" + shown = entries[:limit] + rendered = "\n".join( + render_context_entry( + entry, + max_chars=max_chars, + kind="global", + on_elision=on_elision, + ) + for entry in shown + ) + remaining = len(entries) - len(shown) + if remaining > 0: + rendered += ( + f"\n... and {remaining} more; use 'query' to look up a specific one." + ) + return rendered + + +def render_initial_context( + state: ProofState, + *, + max_chars: int | None = None, + on_elision: ElisionCallback | None = None, +) -> str: + """Render the theorem, goal, hypotheses, and global context for the initial prompt. + + Built entirely from the typed `ProofState` every backend returns, so it + needs no prover-specific source parsing. The agent's `query` tool covers + anything not surfaced here, matching the paper's context-awareness design: + the agent decides what more it needs rather than receiving everything + upfront. + """ + lines = [ + f"Theorem: {state.theorem.name}", + "", + "Goal:", + render_goals( + state, max_chars=max_chars, on_elision=on_elision + ), + ] + hypotheses = render_hypotheses( + state, max_chars=max_chars, on_elision=on_elision + ) + if hypotheses: + lines += ["", "Hypotheses:", hypotheses] + global_context = render_global_context( + state, max_chars=max_chars, on_elision=on_elision + ) + if global_context: + lines += ["", "Available global declarations:", global_context] + return "\n".join(lines) diff --git a/proof-search/backend/factory.py b/proof-search/backend/factory.py new file mode 100644 index 0000000..5c7c42f --- /dev/null +++ b/proof-search/backend/factory.py @@ -0,0 +1,65 @@ +"""Construction and selection of prover backends.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterable, Mapping + +from .rocq import CoqLibraryPath, CoqPytBackend +from .prover_backend import ProverBackend + + +class UnknownBackendError(ValueError): + """Raised when configuration names an unavailable backend.""" + + +SUPPORTED_BACKENDS = ("rocq",) + + +def _rocq_backend( + *, + timeout: int, + library_paths: Iterable[Mapping[str, str]], + coqproject_extra_options: Iterable[str], + workspace: Path | None, + options: Mapping[str, object], +) -> ProverBackend: + base = workspace.resolve() if workspace is not None else Path.cwd() + mappings = [] + for item in library_paths: + path = Path(item["path"]) + if not path.is_absolute(): + path = base / path + mappings.append(CoqLibraryPath(path.resolve(), item["name"])) + return CoqPytBackend( + timeout=timeout, + library_paths=mappings, + coqproject_extra_options=coqproject_extra_options, + index_imported_libraries=bool( + options.get("index_imported_libraries", True) + ), + ) + + +def create_backend( + name: str, + *, + timeout: int = 10, + library_paths: Iterable[Mapping[str, str]] = (), + coqproject_extra_options: Iterable[str] = (), + workspace: Path | None = None, + options: Mapping[str, object] | None = None, +) -> ProverBackend: + """Create a configured backend without opening a prover session.""" + normalized = name.strip().lower() + if normalized == "rocq": + return _rocq_backend( + timeout=timeout, + library_paths=library_paths, + coqproject_extra_options=coqproject_extra_options, + workspace=workspace, + options=options or {}, + ) + raise UnknownBackendError( + f"unsupported backend {name!r}; available: {', '.join(SUPPORTED_BACKENDS)}" + ) diff --git a/proof-search/backend/prover_backend.py b/proof-search/backend/prover_backend.py new file mode 100644 index 0000000..ce6ea2a --- /dev/null +++ b/proof-search/backend/prover_backend.py @@ -0,0 +1,241 @@ +"""Typed asynchronous contract shared by interactive prover backends.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import NewType + +GoalId = NewType("GoalId", str) + + +@dataclass(frozen=True, slots=True) +class SourceLocation: + path: Path + workspace: Path | None = None + + +@dataclass(frozen=True, slots=True) +class TheoremIdentity: + source: SourceLocation + name: str + + +@dataclass(frozen=True, slots=True) +class ContextEntry: + name: str + type_text: str + value_text: str | None = None + + +@dataclass(frozen=True, slots=True) +class ProvingContext: + """Global declarations and theorem-level assumptions at session open.""" + global_entries: tuple[ContextEntry, ...] = () + local_entries: tuple[ContextEntry, ...] = () + + +@dataclass(frozen=True, slots=True) +class Goal: + id: GoalId + conclusion: str + hypotheses: tuple[ContextEntry, ...] = () + + +@dataclass(frozen=True, slots=True) +class ProofState: + theorem: TheoremIdentity + goals: tuple[Goal, ...] + context: ProvingContext + revision: int + + @property + def is_complete(self) -> bool: + return not self.goals + + +class FeedbackSeverity(Enum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class CommandKind(Enum): + PROOF_STEP = "proof_step" + ABORT = "abort" + UNSOUND_COMPLETION = "unsound_completion" + STRUCTURAL = "structural" + + +@dataclass(frozen=True, slots=True) +class ProverFeedback: + message: str + severity: FeedbackSeverity = FeedbackSeverity.INFO + code: str | None = None + + +@dataclass(frozen=True, slots=True) +class CommandResult: + command: str + state: ProofState + feedback: tuple[ProverFeedback, ...] = () + + +@dataclass(frozen=True, slots=True) +class QueryResult: + command: str + output: str + feedback: tuple[ProverFeedback, ...] = () + + +@dataclass(frozen=True, slots=True) +class Checkpoint: + """Opaque backend-owned handle; callers must not inspect its fields.""" + _backend_token: object = field(repr=False) + _session_token: object = field(repr=False) + _payload: object = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class HelperLemmaSpec: + """Agent-selected helper lemma without prover syntax.""" + + name: str + statement: str + + +@dataclass(frozen=True, slots=True) +class HelperLemmaCommands: + """Backend-rendered native commands for one helper-lemma scope.""" + + declaration: str + open_scope: str + close_scope: str + + +@dataclass(frozen=True, slots=True) +class SavedProofCertificate: + theorem: TheoremIdentity + destination: Path + format: str + commands: tuple[str, ...] + + +class LifecycleState(Enum): + CREATED = "created" + OPEN = "open" + COMPLETE = "complete" + CLOSED = "closed" + + +@dataclass(frozen=True, slots=True) +class BackendCapabilities: + checkpoints: bool = True + queries: bool = True + certificate_saving: bool = True + + +class ProverBackendError(Exception): + pass + + +class InvalidLifecycleError(ProverBackendError): + def __init__(self, operation: str, actual: LifecycleState, + expected: tuple[LifecycleState, ...]) -> None: + self.operation, self.actual, self.expected = operation, actual, expected + names = ", ".join(state.value for state in expected) + super().__init__(f"{operation} requires {names}; current state is {actual.value}") + + +class CommandRejectedError(ProverBackendError): + """A command rejected without changing state.""" + def __init__(self, command: str, state: ProofState, + feedback: tuple[ProverFeedback, ...]) -> None: + self.command, self.state, self.feedback = command, state, feedback + message = feedback[0].message if feedback else "command rejected" + super().__init__(f"prover rejected {command!r}: {message}") + + +class InvalidCheckpointError(ProverBackendError): + """A checkpoint is foreign, stale, or no longer retained.""" + + +class ProverTimeoutError(ProverBackendError): + pass + + +class ProverProtocolError(ProverBackendError): + pass + + +class UnsupportedOperationError(ProverBackendError): + pass + + +class ProverBackend(ABC): + """Single-session asynchronous prover contract. + + The backend owns prover interaction. The agent owns proof trees, tactic + selection, retries, persistent-error policy, and historical learning. + ``apply`` accepts one native command. Rejection is atomic and raises + ``CommandRejectedError``. Timeout raises ``ProverTimeoutError``; cancellation + propagates ``asyncio.CancelledError``. Both preserve pre-call state or close + the session if restoration is impossible. Checkpoints belong to their + creating backend and current session and remain valid only while retained. + """ + @property + @abstractmethod + def lifecycle(self) -> LifecycleState: ... + + @property + @abstractmethod + def capabilities(self) -> BackendCapabilities: ... + + @abstractmethod + async def open(self, theorem: TheoremIdentity) -> ProofState: ... + + @abstractmethod + async def state(self) -> ProofState: ... + + @abstractmethod + async def apply(self, command: str) -> CommandResult: ... + + @abstractmethod + def classify_command(self, command: str) -> CommandKind: + """Classify a native command for prover-independent agent policy.""" + + @abstractmethod + def automation_command(self) -> str | None: + """Return the native fallback automation command, if supported.""" + + @abstractmethod + async def checkpoint(self) -> Checkpoint: ... + + @abstractmethod + async def rollback(self, checkpoint: Checkpoint) -> ProofState: ... + + @abstractmethod + async def query(self, command: str) -> QueryResult: ... + + @abstractmethod + def helper_lemma_commands( + self, spec: HelperLemmaSpec + ) -> HelperLemmaCommands: + """Render syntax only; the agent owns helper-lemma policy.""" + + @abstractmethod + async def save_proof(self, destination: Path, *, overwrite: bool = False + ) -> SavedProofCertificate: + """Save from COMPLETE; reject existing paths unless overwrite is true.""" + + @abstractmethod + async def close(self) -> None: + """Release resources; idempotent from every lifecycle state.""" + + async def __aenter__(self) -> ProverBackend: + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.close() diff --git a/proof-search/backend/rocq/__init__.py b/proof-search/backend/rocq/__init__.py new file mode 100644 index 0000000..3ecdd58 --- /dev/null +++ b/proof-search/backend/rocq/__init__.py @@ -0,0 +1,11 @@ +"""Rocq backend implemented through CoqPyt and coq-lsp.""" + +from .backend import CoqLibraryPath, CoqPytBackend, discover_theorem_name +from .session import CoqPytSession + +__all__ = [ + "CoqLibraryPath", + "CoqPytBackend", + "CoqPytSession", + "discover_theorem_name", +] diff --git a/proof-search/backend/rocq/backend.py b/proof-search/backend/rocq/backend.py new file mode 100644 index 0000000..1dff029 --- /dev/null +++ b/proof-search/backend/rocq/backend.py @@ -0,0 +1,369 @@ +"""Rocq backend implemented with CoqPyt.""" + +from __future__ import annotations + +import asyncio +import json +import re +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable + +from .session import CoqPytSession +from ..prover_backend import ( + BackendCapabilities, Checkpoint, CommandKind, CommandRejectedError, CommandResult, + HelperLemmaCommands, HelperLemmaSpec, + ContextEntry, FeedbackSeverity, Goal, GoalId, InvalidCheckpointError, InvalidLifecycleError, + LifecycleState, ProofState, ProverBackend, ProverFeedback, + ProverProtocolError, ProverTimeoutError, ProvingContext, QueryResult, + SavedProofCertificate, TheoremIdentity, +) + + +@dataclass(frozen=True, slots=True) +class CoqLibraryPath: + """Rocq physical-to-logical library mapping used to build `_CoqProject`.""" + + path: Path + logical_name: str + +_THEOREM_DECLARATION = re.compile( + r"(?m)^\s*(?:Lemma|Theorem|Fact|Remark|Corollary|Proposition)\s+" + r"(?P[A-Za-z_][A-Za-z0-9_']*)\b" +) + + +def discover_theorem_name(source: Path) -> str: + """Return the first admitted Rocq theorem, or the final declaration.""" + content = source.read_text(encoding="utf-8") + matches = list(_THEOREM_DECLARATION.finditer(content)) + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else len(content) + if re.search(r"\bAdmitted\s*\.", content[match.end():end]): + return match.group("name") + if matches: + return matches[-1].group("name") + raise ProverProtocolError(f"no Rocq theorem declaration found in {source}") + + + +def _reset_selected_proof(path: Path, theorem_name: str) -> None: + """Reset one Rocq proof in the private working copy.""" + content = path.read_text(encoding="utf-8") + declarations = list(_THEOREM_DECLARATION.finditer(content)) + target_index = next( + ( + index + for index, declaration in enumerate(declarations) + if declaration.group("name") == theorem_name + ), + None, + ) + if target_index is None: + return + declaration = declarations[target_index] + block_end = ( + declarations[target_index + 1].start() + if target_index + 1 < len(declarations) + else len(content) + ) + proof = re.search(r"\bProof\s*\.", content[declaration.end():block_end]) + if proof is None: + return + proof_end = declaration.end() + proof.end() + terminator = re.search( + r"\b(?:Qed|Defined|Admitted)\s*\.", content[proof_end:block_end] + ) + if terminator is None: + replacement_end = block_end + else: + replacement_end = proof_end + terminator.end() + reset = content[:proof_end] + "\nAdmitted." + content[replacement_end:] + path.write_text(reset, encoding="utf-8") + +class CoqPytBackend(ProverBackend): + """Expose CoqPyt through the asynchronous backend contract. + + CoqPyt edits its input file, so this adapter works in a private temporary + workspace. The copy keeps the source basename for Rocq module identity. + The source named by ``TheoremIdentity`` changes only through an + explicit save to that path. + """ + + _capabilities = BackendCapabilities() + + def __init__( + self, *, timeout: int = 10, + session_factory: Callable[..., CoqPytSession] = CoqPytSession, + library_paths: Iterable[CoqLibraryPath] = (), + coqproject_extra_options: Iterable[str] = (), + index_imported_libraries: bool = True, + ) -> None: + self._timeout = timeout + self._session_factory = session_factory + self._library_paths = tuple(library_paths) + self._coqproject_extra_options = tuple(coqproject_extra_options) + self._index_imported_libraries = index_imported_libraries + self._lifecycle = LifecycleState.CREATED + self._session: CoqPytSession | None = None + self._theorem: TheoremIdentity | None = None + self._working_path: Path | None = None + self._temporary_directory: tempfile.TemporaryDirectory[str] | None = None + self._backend_token = object() + self._session_token: object | None = None + self._revision = 0 + self._commands: list[str] = [] + self._checkpoints: dict[int, tuple[int, tuple[str, ...]]] = {} + self._certificate_finalized = False + + @property + def lifecycle(self) -> LifecycleState: + return self._lifecycle + + @property + def capabilities(self) -> BackendCapabilities: + return self._capabilities + + def _require(self, operation: str, *expected: LifecycleState) -> None: + if self._lifecycle not in expected: + raise InvalidLifecycleError(operation, self._lifecycle, expected) + + async def _run(self, function, *args): + task = asyncio.create_task(asyncio.to_thread(function, *args)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + try: + await task + finally: + await self.close() + raise + + async def open(self, theorem: TheoremIdentity) -> ProofState: + self._require("open", LifecycleState.CREATED) + source = theorem.source.path.resolve() + if not source.is_file(): + raise ProverProtocolError(f"Rocq source does not exist: {source}") + temporary_directory = tempfile.TemporaryDirectory(prefix="lemmanet_coqpyt_") + working_path = Path(temporary_directory.name) / source.name + shutil.copy2(source, working_path) + _reset_selected_proof(working_path, theorem.name) + library_paths = [ + {"path": str(mapping.path.resolve()), "name": mapping.logical_name} + for mapping in self._library_paths + ] + cache_workspace = json.dumps( + { + "workspace": str((theorem.source.workspace or source.parent).resolve()), + "libraries": [ + [item["path"], item["name"]] for item in library_paths + ], + "coqproject_options": list(self._coqproject_extra_options), + }, + sort_keys=True, + ) + session = self._session_factory( + str(working_path), workspace=temporary_directory.name, + cache_workspace=cache_workspace, + library_paths=library_paths, + auto_setup_coqproject=bool( + library_paths or self._coqproject_extra_options + ), + coqproject_extra_options=list(self._coqproject_extra_options), + timeout=self._timeout, + index_imported_libraries=self._index_imported_libraries, + ) + self._temporary_directory = temporary_directory + self._working_path = working_path + self._session = session + try: + loaded = await self._run(session.load, theorem.name) + if not loaded: + message = session.get_last_error() or f"theorem not found: {theorem.name}" + if "timeout" in message.lower(): + raise ProverTimeoutError(message) + raise ProverProtocolError(message) + self._theorem = theorem + self._session_token = object() + self._lifecycle = LifecycleState.OPEN + state = self._read_state() + if state.is_complete: + self._lifecycle = LifecycleState.COMPLETE + return state + except BaseException: + await self.close() + raise + + def _goal_entries(self, goal) -> tuple[ContextEntry, ...]: + entries = [] + for hypothesis in goal.hyps: + entries.extend( + ContextEntry(name, hypothesis.ty, hypothesis.definition) + for name in hypothesis.names + ) + return tuple(entries) + + def _open_goals(self) -> tuple[Goal, ...]: + assert self._session is not None and self._session.proof_file is not None + answer = self._session.proof_file.current_goals + if answer is None or answer.goals is None: + return () + config = answer.goals + raw_goals = list(config.goals) + for left, right in config.stack: + raw_goals.extend(left) + raw_goals.extend(right) + raw_goals.extend(config.shelf) + raw_goals.extend(config.given_up) + return tuple( + Goal(GoalId(f"r{self._revision}-g{index}"), goal.ty, + self._goal_entries(goal)) + for index, goal in enumerate(raw_goals) + ) + + def _context(self, goals: tuple[Goal, ...]) -> ProvingContext: + assert self._session is not None + global_entries = tuple( + ContextEntry(name, getattr(term, "text", str(term))) + for name, term in self._session.get_context_terms().items() + ) + local_entries = goals[0].hypotheses if goals else () + return ProvingContext(global_entries, local_entries) + + def _read_state(self) -> ProofState: + assert self._theorem is not None + goals = self._open_goals() + return ProofState(self._theorem, goals, self._context(goals), self._revision) + + async def state(self) -> ProofState: + self._require("state", LifecycleState.OPEN, LifecycleState.COMPLETE) + return await self._run(self._read_state) + + async def apply(self, command: str) -> CommandResult: + self._require("apply", LifecycleState.OPEN) + if not command.strip(): + before = await self.state() + raise CommandRejectedError(command, before, ( + ProverFeedback("empty command", FeedbackSeverity.ERROR), + )) + before = await self.state() + assert self._session is not None + accepted = await self._run(self._session.apply_tactic, command) + if not accepted: + message = self._session.get_last_error() or "command rejected by Rocq" + if "timeout" in message.lower(): + raise ProverTimeoutError(message) + raise CommandRejectedError(command, before, ( + ProverFeedback(message, FeedbackSeverity.ERROR), + )) + self._revision += 1 + self._commands.append(command.strip()) + state = await self.state() + if state.is_complete: + self._lifecycle = LifecycleState.COMPLETE + return CommandResult(command, state) + + def classify_command(self, command: str) -> CommandKind: + normalized = command.strip().lower().removesuffix(".") + if normalized == "abort": + return CommandKind.ABORT + if normalized in {"admit", "admitted"}: + return CommandKind.UNSOUND_COMPLETION + if normalized in {"{", "}"}: + return CommandKind.STRUCTURAL + return CommandKind.PROOF_STEP + + def automation_command(self) -> str | None: + return "hammer." + + async def checkpoint(self) -> Checkpoint: + self._require("checkpoint", LifecycleState.OPEN, LifecycleState.COMPLETE) + assert self._session is not None + step = self._session.get_current_step_number() + self._checkpoints[self._revision] = (step, tuple(self._commands)) + return Checkpoint(self._backend_token, self._session_token, self._revision) + + async def rollback(self, checkpoint: Checkpoint) -> ProofState: + self._require("rollback", LifecycleState.OPEN, LifecycleState.COMPLETE) + valid = ( + checkpoint._backend_token is self._backend_token + and checkpoint._session_token is self._session_token + and checkpoint._payload in self._checkpoints + ) + if not valid: + raise InvalidCheckpointError("checkpoint is not valid for this session") + step, commands = self._checkpoints[checkpoint._payload] + assert self._session is not None + if not await self._run(self._session.reset_by_step, step): + raise ProverProtocolError( + self._session.get_last_error() or "Rocq rollback failed" + ) + self._revision = checkpoint._payload + self._commands = list(commands) + self._certificate_finalized = False + self._lifecycle = LifecycleState.OPEN + state = await self.state() + if state.is_complete: + self._lifecycle = LifecycleState.COMPLETE + return state + + def helper_lemma_commands( + self, spec: HelperLemmaSpec + ) -> HelperLemmaCommands: + return HelperLemmaCommands( + f"assert ({spec.name}: {spec.statement})", "{", "}" + ) + + async def query(self, command: str) -> QueryResult: + self._require("query", LifecycleState.OPEN, LifecycleState.COMPLETE) + before = await self.state() + assert self._session is not None + output = await self._run(self._session.search, command) + error_prefixes = ("Query error:", "Error executing", "Unsupported query") + if output.startswith(error_prefixes): + raise CommandRejectedError(command, before, ( + ProverFeedback(output, FeedbackSeverity.ERROR), + )) + return QueryResult(command, output) + + async def save_proof(self, destination: Path, *, overwrite: bool = False + ) -> SavedProofCertificate: + self._require("save_proof", LifecycleState.COMPLETE) + destination = destination.resolve() + if destination.exists() and not overwrite: + raise FileExistsError(destination) + assert self._session is not None and self._working_path is not None + if not self._certificate_finalized: + if not await self._run(self._session.apply_tactic, "Qed."): + raise ProverProtocolError( + self._session.get_last_error() or "Rocq rejected Qed" + ) + self._certificate_finalized = True + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as temporary: + temporary_path = Path(temporary.name) + try: + shutil.copyfile(self._working_path, temporary_path) + temporary_path.replace(destination) + finally: + temporary_path.unlink(missing_ok=True) + assert self._theorem is not None + return SavedProofCertificate( + self._theorem, destination, "rocq-source", tuple(self._commands) + ("Qed.",) + ) + + async def close(self) -> None: + session = self._session + temporary_directory = self._temporary_directory + self._session = None + self._working_path = None + self._temporary_directory = None + self._checkpoints.clear() + self._session_token = None + self._lifecycle = LifecycleState.CLOSED + if session is not None: + await asyncio.to_thread(session.close) + if temporary_directory is not None: + temporary_directory.cleanup() diff --git a/proof-search/backend/coq_interface.py b/proof-search/backend/rocq/session.py similarity index 94% rename from proof-search/backend/coq_interface.py rename to proof-search/backend/rocq/session.py index 915366a..c32c2d2 100644 --- a/proof-search/backend/coq_interface.py +++ b/proof-search/backend/rocq/session.py @@ -1,4 +1,4 @@ -# backend/coq_interface.py +"""Internal synchronous CoqPyt session used by the Rocq backend package.""" import os import re @@ -19,15 +19,17 @@ class CoqSessionDesync(RuntimeError): interface/session explicitly instead of taking a hard process exit. """ -class CoqInterface: - def __init__(self, file_path: str, workspace: Optional[str] = None, +class CoqPytSession: + def __init__(self, file_path: str, workspace: Optional[str] = None, library_paths: Optional[List[Dict[str, str]]] = None, auto_setup_coqproject: bool = False, coqproject_extra_options: Optional[List[str]] = None, - timeout: int = 10): + cache_workspace: Optional[str] = None, + timeout: int = 10, + index_imported_libraries: bool = True): """ - Initialize Coq interface. - + Initialize the low-level CoqPyt session. + NEW PARAMETERS: - library_paths: List of library mappings [{"path": "/path", "name": "libname"}, ...] - auto_setup_coqproject: Whether to automatically create/update _CoqProject @@ -37,24 +39,29 @@ def __init__(self, file_path: str, workspace: Optional[str] = None, if workspace is not None and not os.path.isabs(workspace): workspace = os.path.abspath(workspace) self.workspace = workspace - + self.cache_workspace = cache_workspace + # Library support attributes self.library_paths = library_paths or [] self.auto_setup_coqproject = auto_setup_coqproject self.coqproject_extra_options = coqproject_extra_options or [] - + self.timeout = timeout + self.index_imported_libraries = index_imported_libraries self.proof_file = None self.proof = None + # Steps already present when the proof was loaded belong to the + # source and must never be popped by a rollback. + self._initial_step_count = 0 self.last_error = None - self.logger = setup_logger("CoqInterface") + self.logger = setup_logger("CoqPytSession") # Cache for recent goal queries so we avoid back-to-back LSP `proof_goals` calls # when the proof state hasn't changed. self.__goal_cache_key = None self.__cached_goals = None self.__goal_cache_filled = False - + # Setup library paths if configured if self.auto_setup_coqproject and (self.library_paths or self.coqproject_extra_options): self._setup_coqproject() @@ -92,58 +99,60 @@ def _setup_coqproject(self): """Create or update _CoqProject file with library paths and extra options.""" try: coqproject_path = Path(self.workspace or Path(self.file_path).parent) / "_CoqProject" - + self.logger.debug(f"Setting up _CoqProject at {coqproject_path}") - + # Create _CoqProject content content_lines = [] - + # Add library mappings for lib_config in self.library_paths: lib_path = lib_config["path"] lib_name = lib_config["name"] - + # Ensure we use absolute paths for reliability lib_path = self._resolve_library_path(lib_path, self.workspace) self._validate_library_path(lib_path, lib_name) - + content_lines.append(f"-R {lib_path} {lib_name}") self.logger.debug(f" Added library mapping: {lib_path} -> {lib_name}") - + # Add extra options for option in self.coqproject_extra_options: content_lines.append(option) self.logger.debug(f" Added extra option: {option}") - + # Write _CoqProject file with open(coqproject_path, 'w') as f: f.write('\n'.join(content_lines) + '\n') - + self.logger.info("✅ _CoqProject file created successfully") - + except Exception as e: self.logger.error(f"Failed to setup _CoqProject: {e}") raise - def load(self): + def load(self, theorem_name: Optional[str] = None): """Open the Coq file, run it, and set up for proof replay.""" try: self.close() self._invalidate_cached_goal_state() self.logger.info(f"Loading Coq file: {self.file_path}") - + # Create ProofFile with workspace and timeout self.proof_file = ProofFile( - self.file_path, + self.file_path, workspace=self.workspace, # Use the workspace if set + cache_workspace=self.cache_workspace, timeout=self.timeout, - use_disk_cache=True + use_disk_cache=True, + index_imported_libraries=self.index_imported_libraries, ) self.proof_file.run() - + # Always get the first admitted proof (there should be one) - self.proof = self.get_unproven_proof() - + self.proof = self.get_unproven_proof(theorem_name) + if not self.proof: self.last_error = ( "No unproven proof found in file — every proof is already " @@ -151,40 +160,51 @@ def load(self): ) self.logger.warning(self.last_error) return False - + # Pop 'Admitted.' to open the proof for tactic replay if self.proof and self.proof.steps and self.proof.steps[-1].text.strip() == "Admitted.": self.proof_file.pop_step(self.proof) self.logger.debug("Removed 'Admitted.' to open proof for replay") self._invalidate_goal_caches() - + self._initial_step_count = len(self.proof.steps) + self.logger.info(f"Successfully loaded proof with {len(self.proof.steps)} initial steps") - + # Log library loading status if self.library_paths: self.logger.info(f"Loaded with {len(self.library_paths)} custom library paths") for lib_config in self.library_paths: self.logger.info(f" - {lib_config['name']}: {lib_config['path']}") - + return True - + except Exception as e: self.close() self.last_error = f"Failed to load file: {str(e)}" self.logger.error(self.last_error) return False - def get_unproven_proof(self): - """Return the first unproven/admitted proof.""" + def get_unproven_proof(self, theorem_name: Optional[str] = None): + """Return the named unproven proof, or the first when omitted.""" try: if self.proof_file and self.proof_file.unproven_proofs: - return self.proof_file.unproven_proofs[0] + proofs = self.proof_file.unproven_proofs + if theorem_name is None: + return proofs[0] + declaration = re.compile( + rf"^\s*(?:Lemma|Theorem|Fact|Remark|Corollary|Proposition)\s+" + rf"{re.escape(theorem_name)}\b" + ) + return next( + (proof for proof in proofs if declaration.search(proof.text)), + None, + ) return None except Exception as e: self.logger.error(f"Error getting unproven proof: {e}") return None - + def get_context_terms(self): """Return all terms currently in the context.""" try: @@ -262,7 +282,7 @@ def _get_current_goals_cached(self): self.__cached_goals = current_goals self.__goal_cache_filled = True return current_goals - + def get_raw_goal_str(self): """Return the string representation of the current goal.""" try: @@ -273,72 +293,72 @@ def get_raw_goal_str(self): current_goals = self._get_current_goals_cached() if current_goals: return str(current_goals) - + proof = self.get_unproven_proof() - + # If no unproven proof, check if this means we're done if not proof: # Check if there are any unproven proofs left if not self.proof_file or not self.proof_file.unproven_proofs: return "Proof finished" return "(no current goal)" - + if not proof.steps: return "(no current goal)" - + # Get the last step's goals last_step = proof.steps[-1] goals = getattr(last_step, "goals", "") - + # Check if the last step was Qed - if so, proof should be finished last_step_text = last_step.text.strip().lower() if last_step_text in ['qed.', 'qed', 'defined.', 'defined']: return "Proof finished" - + if not goals: # No goals might mean proof is finished return "Proof finished" - + # Handle different goal formats if isinstance(goals, list): if not goals: return "Proof finished" return "\n\n".join(str(g) for g in goals) - + goals_str = str(goals).strip() - + # Clean up redundant "Goals:" prefixes and formatting if goals_str.startswith("Goals:"): goals_str = goals_str[6:].strip() - + # Remove excessive newlines and clean up formatting lines = goals_str.split('\n') cleaned_lines = [] - + for line in lines: line = line.strip() if line: # Only keep non-empty lines cleaned_lines.append(line) - + # Rejoin with single newlines cleaned_goals = '\n'.join(cleaned_lines) - + return cleaned_goals if cleaned_goals else "Proof finished" - + except Exception as e: self.logger.error(f"Error getting goal string: {e}") return f"(error retrieving goals: {str(e)})" - + def get_raw_hypothesis(self): """Return the current hypotheses/context for the active proof state.""" try: proof = self.get_unproven_proof() if not proof or not proof.steps: return "" - + # Get the last step's context/hypotheses last_step = proof.steps[-1] - + # Try different ways to get hypotheses if hasattr(last_step, 'hypotheses'): hyp = last_step.hypotheses @@ -346,10 +366,10 @@ def get_raw_hypothesis(self): hyp = last_step.context else: return "" - + if not hyp: return "" - + # Handle different hypothesis formats if isinstance(hyp, dict): if not hyp: @@ -362,7 +382,7 @@ def get_raw_hypothesis(self): return "\n".join(str(h) for h in hyp) else: return str(hyp) - + except Exception as e: self.logger.error(f"Error getting hypotheses: {e}") return f"(error retrieving hypotheses: {str(e)})" @@ -381,54 +401,54 @@ def is_dangerous_tactic(self, tactic: str) -> bool: """Check if a tactic might cause timeout or infinite loops.""" import re tactic_lower = tactic.lower().strip() - + for pattern in self.dangerous_tactic_patterns: if re.search(pattern, tactic_lower): return True return False - + def sanitize_tactic(self, tactic: str) -> str: """Replace dangerous tactic patterns with safer alternatives.""" import re - + original = tactic - + # Replace dangerous repeat patterns tactic = re.sub(r'repeat\s*\([^)]*Z\.le_[^)]*\)', 'lia', tactic) tactic = re.sub(r'repeat\s*\([^)]*apply[^)]*-?\d{7,}[^)]*\)', 'lia', tactic) - + # Replace complex arithmetic with lia if re.search(r'apply.*Z\.le.*with.*-?\d{7,}', tactic): tactic = 'lia' - + # Log replacements if tactic != original: self.logger.debug(f"🔧 Sanitized tactic: '{original}' → '{tactic}'") - + return tactic - + def apply_tactic(self, tactic: str) -> bool: """Apply tactic with danger checking.""" # Sanitize before applying tactic = self.sanitize_tactic(tactic) - + # Check if still dangerous after sanitization if self.is_dangerous_tactic(tactic): return False - + try: self.last_error = None - + if not self.proof: self.last_error = "No open proof available" self.logger.error(self.last_error) return False - + if not tactic or not tactic.strip(): self.last_error = "Empty tactic provided" self.logger.error(self.last_error) return False - + # Clean the tactic string tactic_clean = tactic.strip().replace('\n', '').replace('\r', '') @@ -436,21 +456,21 @@ def apply_tactic(self, tactic: str) -> bool: # Note: } should be applied as ' }' (with leading space, no period) if not tactic_clean.endswith('.') and tactic_clean.strip() not in ['{', '}']: tactic_clean += '.' - + # Special handling for closing brace: ensure it has a leading space if tactic_clean.strip() == '}': tactic_clean = ' }' - + try: # Apply the tactic with proper spacing self.logger.debug(f"➡️ Applying tactic: {tactic_clean}") formatted_tactic = f"\n {tactic_clean}" - + # Get current step count before adding steps_before = len(self.proof.steps) self.proof_file.append_step(self.proof, formatted_tactic) - + steps_after = len(self.proof.steps) if steps_before + 1 != steps_after: msg = ( @@ -459,10 +479,10 @@ def apply_tactic(self, tactic: str) -> bool: ) self.logger.error(msg) raise CoqSessionDesync(msg) - + # CRITICAL: Force refresh of the current goals after applying tactic self._invalidate_goal_caches() - + # After applying tactic, check if this completed the proof if tactic_clean.lower() in ['qed.', 'qed']: self.logger.info("✅ Applied Qed - proof should be complete") @@ -475,34 +495,34 @@ def apply_tactic(self, tactic: str) -> bool: self.logger.info(f"✅ Tactic '{tactic_clean}' solved all goals!") else: self.logger.debug(f"✅ Tactic applied: '{tactic_clean}'") - + return True - + except InvalidChangeException as e: # Try to get detailed error from the exception itself and other sources detailed_error = self._collect_error_from_multiple_sources(tactic_clean, e) - + if detailed_error: reduced_error = reduce_error_verbosity(detailed_error) self.last_error = f"Invalid tactic '{tactic_clean}':\n{reduced_error}" - + else: # Fallback to basic error error_clean = reduce_error_verbosity(str(e).replace('\n', ' ')) self.last_error = f"Invalid tactic '{tactic_clean}': {error_clean}" - + return False - + except Exception as e: error_clean = str(e).replace('\n', ' ') self.last_error = f"Error applying tactic '{tactic}': {error_clean}" self.logger.error(self.last_error) - + # Abort if Coq server quit # (, 'Server quit') if 'quit' in error_clean.lower(): raise CoqSessionDesync("Coq server quit unexpectedly.") - + return False def _collect_error_from_multiple_sources(self, tactic: str, exception: Exception) -> str: @@ -510,7 +530,7 @@ def _collect_error_from_multiple_sources(self, tactic: str, exception: Exception Collect detailed error information from multiple sources including the exception itself. """ error_parts = [] - + # Method 1: Check if the exception has detailed error information try: exception_str = str(exception) @@ -525,7 +545,7 @@ def _collect_error_from_multiple_sources(self, tactic: str, exception: Exception error_parts.append(exception_str) except Exception as e: self.logger.warning(f"Error extracting from exception: {e}") - + # Method 2: Check proof file diagnostics try: if hasattr(self.proof_file, 'diagnostics') and self.proof_file.diagnostics: @@ -539,8 +559,8 @@ def _collect_error_from_multiple_sources(self, tactic: str, exception: Exception error_parts.append(msg) except Exception as e: self.logger.warning(f"Error checking proof file diagnostics: {e}") - - # Method 3: Check proof file errors + + # Method 3: Check proof file errors try: if hasattr(self.proof_file, 'errors') and self.proof_file.errors: for error in self.proof_file.errors: @@ -548,7 +568,7 @@ def _collect_error_from_multiple_sources(self, tactic: str, exception: Exception error_parts.append(error.message) except Exception as e: self.logger.warning(f"Error checking proof file errors: {e}") - + # Combine and deduplicate error parts if error_parts: # Remove duplicates while preserving order @@ -560,12 +580,12 @@ def _collect_error_from_multiple_sources(self, tactic: str, exception: Exception if cleaned_part and cleaned_part not in seen: unique_parts.append(cleaned_part) seen.add(cleaned_part) - + if unique_parts: return '\n'.join(unique_parts) - + return None - + def get_last_error(self) -> str: """Get the last error message from Coq operations.""" if self.last_error: @@ -578,11 +598,11 @@ def print_steps(self): if not self.proof: self.logger.error("No open proof!") return - + self.logger.info("== Current proof steps ==") for i, step in enumerate(self.proof.steps): self.logger.debug(f"Step {i+1}: {step.text.strip()}") - + except Exception as e: self.logger.error(f"Error printing steps: {e}") @@ -602,33 +622,33 @@ def is_proof_complete(self) -> bool: if not proof: self.logger.warning("No unproven proof found") return False - + if not proof.steps: return False - + # Check if last step is Qed/Defined last_step_text = proof.steps[-1].text.strip().lower() if last_step_text in ['qed.', 'qed', 'defined.', 'defined']: self.logger.debug(f"Found Qed/Defined step: {last_step_text}") return True - + # Get current goals goals = self.get_goal_str() - + # Check for "Proof finished" specifically if goals and "proof finished" in goals.lower(): self.logger.debug("Found 'Proof finished' indicator") return True - + if goals and "no more goals, but there are some goals you gave up" in goals.lower(): self.logger.debug("Found incomplete proof with given up goals") return False - + # Check various indicators of completion if not goals or goals.strip() in ["", "(no current goal)", "no more goals", "proof completed"]: self.logger.debug("No goals remaining - proof complete") return True - + # Check if goals string indicates completion goals_lower = goals.lower().strip() completion_indicators = [ @@ -638,30 +658,30 @@ def is_proof_complete(self) -> bool: "proof is completed", "no more goals" ] - + for indicator in completion_indicators: if indicator in goals_lower: self.logger.debug(f"Found completion indicator: {indicator}") return True - + # Check the proof file's internal state try: # After Qed is applied, the proof might not be in unproven_proofs anymore if not self.proof_file.unproven_proofs: self.logger.debug("No unproven proofs remaining - all complete") return True - + # Check if our current proof is still unproven if proof not in self.proof_file.unproven_proofs: self.logger.debug("Current proof no longer in unproven list - complete") return True - + except Exception as e: self.logger.debug(f"Error checking proof file state: {e}") - + # Goal still remaining return False - + except Exception as e: self.logger.error(f"Error checking proof completion: {e}") # Conservative approach: if we can't check, assume incomplete @@ -727,10 +747,10 @@ def reset_by_step(self, target_step: int) -> bool: """ Reset proof state to a specific step by popping steps back. Much faster than reload+replay since it just removes steps. - + Args: target_step: Target step number (1-based) to reset to - + Returns: True if reset was successful, False otherwise """ @@ -739,51 +759,42 @@ def reset_by_step(self, target_step: int) -> bool: self.last_error = "No open proof available for step reset" self.logger.error(self.last_error) return False - + current_steps = len(self.proof.steps) - - # Find the index of the "Proof." step (should not be popped) - proof_step_index = -1 - for i, step in enumerate(self.proof.steps): - if step.text.strip() == "Proof.": - proof_step_index = i - break - - if proof_step_index == -1: - self.last_error = "No 'Proof.' step found; cannot safely reset" - self.logger.error(self.last_error) - return False - - min_steps = proof_step_index + 1 # Don't pop below this - + + # NTP4VC-generated files may open the proof body without a + # `Proof.` command, so the floor is the number of steps present + # when the file was loaded rather than a marker step. + min_steps = self._initial_step_count + # Validate target step if target_step < min_steps: - self.last_error = f"Invalid target step {target_step} - must be >= {min_steps} (the 'Proof.' step)" + self.last_error = f"Invalid target step {target_step} - must be >= {min_steps} (the source's own steps)" self.logger.error(self.last_error) return False - + if target_step > current_steps: self.last_error = f"Invalid target step {target_step} - current proof has {current_steps} steps" self.logger.error(self.last_error) return False - + if target_step == current_steps: self.logger.info(f"Already at target step {target_step} - no reset needed") self._invalidate_cached_goal_state() return True - + # Calculate steps to remove steps_to_remove = current_steps - target_step - + self.logger.info(f"Resetting proof from step {current_steps} to step {target_step}") - self.logger.debug(f"Will pop {steps_to_remove} steps (but never below 'Proof.')") - # Pop steps back to target, but never pop "Proof." + self.logger.debug(f"Will pop {steps_to_remove} steps (but never below the source's own steps)") + # Pop steps back to target, but never pop the source's own steps successful_pops = 0 failed_pops = 0 - + for i in range(steps_to_remove): if len(self.proof.steps) <= min_steps: - self.logger.debug("Reached 'Proof.' step; will not pop further.") + self.logger.debug("Reached the source's own steps; will not pop further.") break try: if self.proof.steps: @@ -798,7 +809,7 @@ def reset_by_step(self, target_step: int) -> bool: if failed_pops > 3: self.logger.error(f"Too many pop failures ({failed_pops}) - stopping reset") break - + # Verify final state final_steps = len(self.proof.steps) #print(f"final steps after reset: {final_steps}, target was {target_step}") @@ -817,7 +828,7 @@ def reset_by_step(self, target_step: int) -> bool: self.logger.error(self.last_error) self._invalidate_goal_caches() return False - + except Exception as e: self.last_error = f"Error during step reset: {str(e)}" self.logger.error(self.last_error) @@ -849,10 +860,10 @@ def can_reset_to_step(self, target_step: int) -> bool: try: if not self.proof: return False - + current_steps = len(self.proof.steps) return 1 <= target_step <= current_steps - + except Exception as e: self.logger.error(f"Error checking if can reset to step: {e}") return False @@ -862,12 +873,12 @@ def get_step_info(self, step_number: int) -> Dict[str, Any]: try: if not self.proof or not self.proof.steps: return {"error": "No proof available"} - + if step_number < 1 or step_number > len(self.proof.steps): return {"error": f"Invalid step number {step_number}"} - + step = self.proof.steps[step_number - 1] # Convert to 0-based - + return { "step_number": step_number, "text": step.text.strip(), @@ -875,7 +886,7 @@ def get_step_info(self, step_number: int) -> Dict[str, Any]: "goals_after": getattr(step, 'goals', 'Unknown'), "success": True } - + except Exception as e: return {"error": f"Error getting step info: {str(e)}"} @@ -893,38 +904,38 @@ def clear_unproven_proof_steps(self) -> bool: self._invalidate_goal_caches() self.ensure_admitted(self.file_path) proof = self.get_unproven_proof() - + if not proof: self.logger.debug("No unproven proof found") # Try to create an unproven version from the complete proof return self._convert_complete_to_unproven() - + # Check if the proof is already complete (ends with Qed) if proof.steps: last_step_text = proof.steps[-1].text.strip().lower() if last_step_text in ['qed.', 'qed', 'defined.', 'defined']: self.logger.info("Found complete proof with Qed - converting to unproven") return self._convert_complete_to_unproven() - + # If we have steps but no Qed, clear them normally if proof.steps: self.logger.info(f"Clearing {len(proof.steps)} existing proof steps") - + # Find the initial "Proof." step proof_step_index = -1 for i, step in enumerate(proof.steps): if step.text.strip() == "Proof.": proof_step_index = i break - + if proof_step_index == -1: self.logger.warning("No 'Proof.' step found") return False - + # Keep popping steps until we only have steps up to and including "Proof." initial_steps = proof_step_index + 1 # Include the "Proof." step steps_removed = 0 - + while len(proof.steps) > initial_steps: try: self.proof_file.pop_step(proof) @@ -932,13 +943,13 @@ def clear_unproven_proof_steps(self) -> bool: except Exception as e: self.logger.warning(f"Error removing step: {e}") break - + self.logger.info(f"Cleared {steps_removed} proof steps, {len(proof.steps)} steps remaining") return True else: self.logger.debug("No proof steps to clear") return True - + except Exception as e: self.last_error = f"Error clearing proof steps: {str(e)}" self.logger.error(self.last_error) @@ -948,49 +959,49 @@ def _convert_complete_to_unproven(self) -> bool: """Convert a complete proof (with Qed) to an unproven proof.""" try: self.logger.info("Converting complete proof to unproven format") - + # Step 1: Use the clear_all_proof_scripts method to modify the file if not self.clear_all_proof_scripts(): self.logger.error("Failed to clear proof scripts from file") return False - + # Step 2: Reload the file with the cleared scripts self.close() # Close current session - + if not self.load(): # Reload with cleared scripts self.logger.error("Failed to reload file after clearing scripts") return False - + self.logger.info("✅ Successfully converted complete proof to unproven format") return True - + except Exception as e: self.logger.error(f"Error converting complete proof to unproven: {e}") return False def clear_all_proof_scripts(self) -> bool: """ - Overwrite the .v file to remove all proof scripts, leaving only the statement + Overwrite the .v file to remove all proof scripts, leaving only the statement and 'Proof.' for each proof, plus 'Admitted.' so Coq can parse. """ try: # Ensure file_path is a Path object file_path = Path(self.file_path) if isinstance(self.file_path, str) else self.file_path - + # Read the original file with open(file_path, 'r', encoding='utf-8') as f: content = f.read() - + # Create backup backup_path = file_path.with_suffix('.v.backup') with open(backup_path, 'w', encoding='utf-8') as f: f.write(content) self.logger.debug(f"Created backup at {backup_path}") - + lines = content.split('\n') output = [] in_proof = False - + for line in lines: # Check if we're starting a proof if re.match(r'^\s*Proof\s*\.', line): @@ -998,26 +1009,26 @@ def clear_all_proof_scripts(self) -> bool: output.append(line) # Keep the Proof. line output.append(' Admitted.') # Add Admitted immediately after with proper indentation continue - + # Check if we're ending a proof if in_proof and re.match(r'^\s*(Qed\s*\.|Admitted\s*\.|Defined\s*\.)', line): in_proof = False continue # Skip the original ending - + # Skip all lines inside proof except Proof. itself if in_proof: continue - + # Keep all non-proof lines output.append(line) - + # Write the modified content with open(file_path, 'w', encoding='utf-8') as f: f.write('\n'.join(output)) - + self.logger.info("✅ Successfully cleared all proof scripts from file") return True - + except Exception as e: self.last_error = f"Error clearing proof scripts: {str(e)}" self.logger.error(self.last_error) @@ -1037,22 +1048,22 @@ def close(self): self.force_close() # force kill finally: self.proof_file = None - + if hasattr(self, 'proof') and self.proof: self.proof = None self._invalidate_goal_caches() - + except Exception as e: - self.logger.warning(f"Error during CoqInterface close: {e}") + self.logger.warning(f"Error during CoqPytSession close: {e}") # Don't raise - just log and continue - + def force_close(self): try: self.logger.info("Forcing coq-lsp shutdown...") self.proof_file.coq_lsp_client.lsp_endpoint.stop() except Exception as e: self.logger.warning(f"Error during forceful close: {e}") - + def in_proof(self) -> bool: """Check if we're currently in an active proof.""" try: @@ -1072,22 +1083,22 @@ def search(self, query: str) -> str: """Execute any Coq query command (Search, Print, Locate, About, Check, Print Assumptions) using aux_file.""" try: self.last_error = None - + # Clean and normalize the query query = query.strip() if not query.endswith('.'): query += '.' - + # Ensure we have aux_file access if not hasattr(self.proof_file, "_ProofFile__aux_file"): self.last_error = "aux_file not accessible" return "aux_file not accessible" - + aux_file = self.proof_file._ProofFile__aux_file - + # Save the line count before adding the query line_before = len(aux_file.read().split("\n")) - + # Append the query to aux_file aux_file.append(f"\n{query}") aux_file.didChange() @@ -1100,7 +1111,7 @@ def search(self, query: str) -> str: aux_file.didChange() except Exception as trunc_err: self.logger.debug(f"aux_file truncate failed: {trunc_err}") - + except Exception as e: self.last_error = f"Query error: {str(e)}" self.logger.error(self.last_error) @@ -1238,20 +1249,20 @@ def ensure_admitted(filename): try: with open(filename, 'r') as f: lines = f.readlines() - + modified = False for i in reversed(range(len(lines))): if lines[i].strip() == "Qed.": lines[i] = "Admitted.\n" modified = True break - + if modified: with open(filename, "w") as f: f.writelines(lines) - + return modified - + except Exception: return False @@ -1285,29 +1296,29 @@ def is_ready_for_qed(self) -> bool: proof = self.get_unproven_proof() if not proof or not proof.steps: return False - + # Check if Qed is already applied last_step_text = proof.steps[-1].text.strip().lower() if last_step_text in ['qed.', 'qed', 'defined.', 'defined']: self.logger.debug("Qed already applied") return True # Already has Qed, so it was ready - + # Save the current step count so we can revert if needed original_step_count = len(proof.steps) - + try: # Try to apply Qed formatted_qed = "\n Qed." self.proof_file.append_step(self.proof, formatted_qed) - + # If we get here, Qed was successfully applied self.logger.info("✅ Qed applied successfully - proof is complete! Keeping Qed in file.") self._invalidate_goal_caches() - + return True - + except Exception: - + # Make sure we didn't accidentally add a step due to the failed attempt if len(proof.steps) > original_step_count: try: @@ -1316,9 +1327,9 @@ def is_ready_for_qed(self) -> bool: except Exception as cleanup_error: self.logger.warning(f"Error cleaning up failed Qed: {cleanup_error}") self._invalidate_goal_caches() - + return False - + except Exception as e: self.logger.error(f"Error checking if ready for Qed: {e}") return False @@ -1331,7 +1342,7 @@ def get_proof_completion_status(self) -> dict: try: proof = self.get_unproven_proof() goals = self.get_goal_str() - + status = { 'has_proof': proof is not None, 'step_count': len(proof.steps) if proof else 0, @@ -1340,16 +1351,16 @@ def get_proof_completion_status(self) -> dict: 'ready_for_qed': self.is_ready_for_qed(), 'qed_already_applied': False } - + if proof and proof.steps: last_step_text = proof.steps[-1].text.strip().lower() status['qed_already_applied'] = last_step_text in ['qed.', 'qed', 'defined.', 'defined'] - + # Qed should have been applied if is_ready_for_qed() assert status['qed_already_applied'] == status['ready_for_qed'] - + return status - + except Exception as e: self.logger.error(f"Error getting proof completion status: {e}") return { @@ -1366,14 +1377,14 @@ def get_proof_completion_status(self) -> dict: def get_proof_status_with_libraries(self) -> Dict[str, Any]: """Get comprehensive status including library information.""" status = self.get_proof_status() # Use existing method - + # Add library information status.update({ "library_paths": self.library_paths, "workspace": self.workspace, "auto_setup_coqproject": self.auto_setup_coqproject }) - + return status def get_proof_file_content(self) -> str: @@ -1385,10 +1396,10 @@ def get_proof_file_content(self) -> str: if not self.proof_file: self.logger.error("No proof file loaded") return "" - + # Get the raw content from the proof file raw_content = "" - + # Method 1: Try to get content from the proof file object if hasattr(self.proof_file, 'content'): raw_content = str(self.proof_file.content) @@ -1405,22 +1416,22 @@ def get_proof_file_content(self) -> str: except Exception as file_error: self.logger.warning(f"Failed to read from file path: {file_error}") return f"Error: Cannot access proof file content - {str(file_error)}" - + # Clean ANSI codes from the content clean_content = clean_ansi_codes(raw_content) - + # Log successful content retrieval content_lines = len(clean_content.split('\n')) content_chars = len(clean_content) self.logger.debug(f"Retrieved proof file content: {content_lines} lines, {content_chars} characters") - + return clean_content - + except Exception as e: error_msg = f"Error getting proof file content: {str(e)}" self.logger.error(error_msg) return f"Error: {error_msg}" - + # Add proper context manager support def __enter__(self): """Enter the context manager - return self for use in with statement.""" @@ -1430,19 +1441,19 @@ def __exit__(self, exc_type, exc_val, exc_tb): """Exit the context manager - clean up resources.""" self.close() return False # Don't suppress exceptions - + def get_subgoals(self) -> list: """ Return a list of current subgoals with their hypotheses using the official coqpyt API. Returns ALL goals including both focused goals and stack goals. Each goal includes its hypotheses which are crucial for understanding the proof context. - + Following the structure from coqpyt: current_goals -> GoalAnswer object current_goals.goals -> GoalConfig object current_goals.goals.goals -> List[Goal] (focused goals) current_goals.goals.stack -> List[Tuple[List[Goal], List[Goal]]] (backgrounded goals) - + Each Goal object has: - ty: the goal conclusion (string) - hyps: List[Hyp] where each Hyp has names (List[str]) and ty (string) @@ -1451,34 +1462,34 @@ def get_subgoals(self) -> list: if not self.proof_file: self.logger.debug("No proof file available") return [] - + # Get CURRENT goals directly from proof_file (not from cached step.goals) current_goals = self._get_current_goals_cached() - + if not current_goals: self.logger.debug("No current goals available") return [] - + # current_goals is a GoalAnswer object goal_answer = current_goals - + # GoalAnswer has a 'goals' attribute which is a GoalConfig object if not hasattr(goal_answer, 'goals') or not goal_answer.goals: self.logger.debug("No goals in GoalAnswer") return [] - + goal_config = goal_answer.goals - + # GoalConfig has: # - goals: List[Goal] (focused goals) # - stack: List[Tuple[List[Goal], List[Goal]]] (backgrounded goals) all_goals = [] - + # Get focused goals if hasattr(goal_config, 'goals') and isinstance(goal_config.goals, list): all_goals.extend(goal_config.goals) self.logger.debug(f"Found {len(goal_config.goals)} focused goals") - + # Get stack goals (goals that were pushed to background) if hasattr(goal_config, 'stack') and isinstance(goal_config.stack, list): for stack_entry in goal_config.stack: @@ -1491,7 +1502,7 @@ def get_subgoals(self) -> list: if isinstance(after_goals, list): all_goals.extend(after_goals) self.logger.debug(f"Found {len(goal_config.stack)} stack entries") - + # Log goal details including hypotheses for i, goal in enumerate(all_goals): if hasattr(goal, 'hyps'): @@ -1499,10 +1510,10 @@ def get_subgoals(self) -> list: self.logger.debug(f"Goal {i+1}: has {hyps_count} hypotheses") else: self.logger.debug(f"Goal {i+1}: no hypotheses attribute") - + self.logger.debug(f"Total subgoals (focused + stack): {len(all_goals)}") return all_goals - + except Exception as e: self.logger.error(f"Error getting subgoals: {e}") import traceback @@ -1516,23 +1527,23 @@ def restart_coq_server(self): """ try: self.logger.info("🔄 Restarting Coq server to clear memory...") - + # Save current configuration current_file = self.file_path current_workspace = getattr(self, 'workspace', None) - + # Close existing proof file (NOT self.proof which is a ProofTerm) if self.proof_file: self.proof_file.close() self.proof_file = None - + # Clear the proof reference self.proof = None - + # Small delay to ensure cleanup import time time.sleep(0.5) - + # Reinitialize with same configuration if current_workspace: self.proof_file = ProofFile( @@ -1547,16 +1558,16 @@ def restart_coq_server(self): timeout=self.timeout, use_disk_cache=False ) - + # Run the proof file to restore to initial state self.proof_file.run() - + # Get the unproven proof again self.proof = self.get_unproven_proof() - + self.logger.info("✅ Coq server restarted successfully") return True - + except Exception as e: self.logger.error(f"❌ Failed to restart Coq server: {e}") import traceback @@ -1568,29 +1579,29 @@ def timeout_protection(self, seconds=30): """Context manager to timeout long-running operations.""" def timeout_handler(signum, frame): raise TimeoutError(f"Operation timed out after {seconds} seconds") - + # Set the signal handler old_handler = signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(seconds) - + try: yield finally: # Restore the old signal handler signal.alarm(0) signal.signal(signal.SIGALRM, old_handler) - + def reduce_error_verbosity(error: str) -> str: """Reduce the verbosity of the of common error patterns.""" - + if "Unable to unify" in error: return "Unable to unify" + error.split("Unable to unify")[1] - + elif "In environment" in error and "The term" in error: prefix = error.split("In environment")[0] suffix = error.split("The term")[1] return prefix + "\nThe term " + suffix - + else: return error diff --git a/proof-search/configs/default_config.json b/proof-search/configs/default_config.json index 428d678..bb7c4d9 100644 --- a/proof-search/configs/default_config.json +++ b/proof-search/configs/default_config.json @@ -1,4 +1,5 @@ { + "backend": {"name": "rocq"}, "llm": { "model": "openai/gpt-5.2-2025-12-11", "temperature": 0.0, @@ -6,7 +7,8 @@ "timeout": 30, "api_key": null, "api_base": null, - "enable_caching": true + "enable_caching": true, + "max_cost_usd": null }, "coq": { "timeout": 60, @@ -21,6 +23,19 @@ "auto_setup_coqproject": true, "coqproject_extra_options": [] }, + "lean": { + "repl_path": null, + "project_root": null, + "use_lake": true, + "open_timeout": null, + "max_heartbeats": 200000, + "automation": "aesop" + }, + "isabelle": { + "logic": "HOL", + "session_dirs": [], + "automation": "apply auto" + }, "interactive": { "enabled": false }, @@ -33,7 +48,8 @@ "enable_context_search": true, "enable_helper_lemma": true, "max_context_search": 3, - "max_errors": 3 + "max_errors": 3, + "max_state_chars": 12000 }, "log_level": "INFO", "output_dir": null diff --git a/proof-search/configs/minimal.json b/proof-search/configs/minimal.json index 68acb65..d54ce3c 100644 --- a/proof-search/configs/minimal.json +++ b/proof-search/configs/minimal.json @@ -1,4 +1,5 @@ { + "backend": {"name": "rocq"}, "llm": { "model": "openai/gpt-4.1", "temperature": 0.0, @@ -6,7 +7,8 @@ "timeout": 30, "api_key": null, "api_base": null, - "enable_caching": true + "enable_caching": true, + "max_cost_usd": null }, "coq": { "timeout": 60, @@ -22,7 +24,8 @@ "enable_context_search": true, "enable_helper_lemma": false, "max_context_search": 3, - "max_errors": 3 + "max_errors": 3, + "max_state_chars": 12000 }, "log_level": "INFO", "output_dir": null diff --git a/proof-search/configs/readme.md b/proof-search/configs/readme.md index 430a1c2..d010ccc 100644 --- a/proof-search/configs/readme.md +++ b/proof-search/configs/readme.md @@ -52,6 +52,7 @@ The JSON config file has three top-level sections: `llm`, `coq`, `ablation`, plu |---|---|---|---| | `model` | string | `openai/gpt-4.1` | LLM model name with provider prefix. See [here](https://docs.litellm.ai/docs/providers). | | `temperature` | float | `0.1` | Sampling temperature. | +| `reasoning_effort` | string or null | `null` | Optional model reasoning effort (for example, `none`, `low`, or `medium`). | | `max_tokens` | int | `512` | Maximum tokens per LLM response. | | `api_key` | string | `null` | API key. Can also be set via env var based on the provider (e.g. `OPENAI_API_KEY`). | | `api_base` | string | `null` | Optional custom API base URL for LiteLLM providers. | diff --git a/proof-search/coqpyt/UPSTREAM.toml b/proof-search/coqpyt/UPSTREAM.toml new file mode 100644 index 0000000..7545737 --- /dev/null +++ b/proof-search/coqpyt/UPSTREAM.toml @@ -0,0 +1,15 @@ +[upstream] +name = "CoqPyt" +repository = "https://github.com/sr-lab/coqpyt.git" +tag = "v1.1.0" +commit = "f47237a3cdb8d0d9d6d3195971d529f5750fdf02" + +[vendoring] +version = "1.1.0+lemmanet.2" +production_source = "coqpyt/" +local_patches = [ + "stable project cache identity for isolated backend workspaces", + "versioned cache namespace and legacy cache removal", + "goal-state cache invalidation from LemmaNet upstream d563746", + "defensive Rocq indexing and LSP parsing from LemmaNet upstream d563746", +] diff --git a/proof-search/coqpyt/__init__.py b/proof-search/coqpyt/__init__.py index e69de29..0c5597e 100644 --- a/proof-search/coqpyt/__init__.py +++ b/proof-search/coqpyt/__init__.py @@ -0,0 +1,5 @@ +"""Vendored CoqPyt with LemmaNet integration patches.""" + +__upstream_version__ = "1.1.0" +__upstream_commit__ = "f47237a3cdb8d0d9d6d3195971d529f5750fdf02" +__version__ = "1.1.0+lemmanet.2" diff --git a/proof-search/coqpyt/coq/base_file.py b/proof-search/coqpyt/coq/base_file.py index b52721e..bae4012 100644 --- a/proof-search/coqpyt/coq/base_file.py +++ b/proof-search/coqpyt/coq/base_file.py @@ -14,8 +14,8 @@ ErrorCodes, Diagnostic, ) -from coqpyt.lsp.structs import Position, RangedSpan, Range -from coqpyt.lsp.client import CoqLspClient +from coqpyt.coq.lsp.structs import Position, RangedSpan, Range +from coqpyt.coq.lsp.client import CoqLspClient from coqpyt.coq.exceptions import * from coqpyt.coq.changes import * from coqpyt.coq.structs import Step @@ -97,7 +97,7 @@ def __exit__(self, exc_type, exc_value, traceback): def __init_path(self, file_path, library): self.file_module = [] if library is None else library.split(".") - self.__from_lib = self.file_module[:2] == ["Coq", "Init"] + self.__from_lib = self.file_module[:2] in [["Coq", "Init"], ["Corelib", "Init"]] self.path = file_path if not self.__from_lib: self._path = file_path diff --git a/proof-search/coqpyt/coq/context.py b/proof-search/coqpyt/coq/context.py index 83c063c..1e37014 100644 --- a/proof-search/coqpyt/coq/context.py +++ b/proof-search/coqpyt/coq/context.py @@ -40,7 +40,48 @@ def __init_coq_version(self, coqtop): # from ProofFile to here. self.ext_index = lambda e: e["ext_index"] if post18 else e[1] - # We only tested versions 8.17/8.18/8.19, so we provide no claims about + # For versions 9.0+, the AST structure for fixpoints changed and obligation + # commands have less tags + rocq = version.parse(coq_version) >= version.parse("9.0") + self.__fixpoint_notations = lambda e: ( + [] + if len(e) < 3 or not isinstance(e[2], list) + else ( + e[2][1][0]["notations"] + if ( + rocq + and len(e[2]) > 1 + and isinstance(e[2][1], list) + and len(e[2][1]) > 0 + and isinstance(e[2][1][0], dict) + and "notations" in e[2][1][0] + and isinstance(e[2][1][0]["notations"], list) + ) + else ( + e[2][0]["notations"] + if ( + not rocq + and len(e[2]) > 0 + and isinstance(e[2][0], dict) + and "notations" in e[2][0] + and isinstance(e[2][0]["notations"], list) + ) + else [] + ) + ) + ) + # FIXME: This should be made private once [__get_program_context] is extracted + # from ProofFile to here. + # Tags pre-Rocq: Tags post-Rocq: + # 0 - Obligation N of id : type + # 1 - Obligation N of id 0 - Obligation N of id + # 2 - Obligation N : type + # 3 - Obligation N 1 - Obligation N + # 4 - Next Obligation of id 2 - Next Obligation of id + # 5 - Next Obligation 3 - Next Obligation + self.obligation_tag_with_id = lambda t: t in ([0, 2] if rocq else [0, 1, 4]) + + # We only tested versions 8.17 to 9.0, so we provide no claims about # versions prior to that. def __init_context(self, terms: Optional[Dict[str, List[Term]]] = None): @@ -188,17 +229,8 @@ def __handle_where_notations(self, step: Step, expr: List, term_type: TermType): and len(expr[2][0][1]) > 0 ): spans = expr[2][0][1] - elif ( - term_type == TermType.FIXPOINT - and len(expr) > 2 - and isinstance(expr[2], list) - and len(expr[2]) > 0 - and isinstance(expr[2][0], dict) - and "notations" in expr[2][0] - and isinstance(expr[2][0]["notations"], list) - and len(expr[2][0]["notations"]) > 0 - ): - spans = expr[2][0]["notations"] + elif term_type == TermType.FIXPOINT: + spans = self.__fixpoint_notations(expr) # handles when multiple notations are defined for span in spans: diff --git a/proof-search/coqpyt/coq/lsp/__init__.py b/proof-search/coqpyt/coq/lsp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/proof-search/coqpyt/coq/lsp/client.py b/proof-search/coqpyt/coq/lsp/client.py new file mode 100644 index 0000000..1251bd6 --- /dev/null +++ b/proof-search/coqpyt/coq/lsp/client.py @@ -0,0 +1,203 @@ +import sys +import threading +import subprocess + +from coqpyt.lsp.structs import * +from coqpyt.lsp.json_rpc_endpoint import JsonRpcEndpoint +from coqpyt.lsp.endpoint import LspEndpoint +from coqpyt.lsp.client import LspClient +from coqpyt.coq.lsp.structs import * + + +class CoqLspClient(LspClient): + """Abstraction to interact with coq-lsp + + Attributes: + file_progress (Dict[str, List[CoqFileProgressParams]]): Contains all + the `$/coq/fileProgress` notifications sent by the server. The + keys are the URIs of the files and the values are the list of + notifications. + """ + + __DEFAULT_INIT_OPTIONS = { + "max_errors": 120000000, + "goal_after_tactic": False, + "show_coq_info_messages": True, + } + + def __init__( + self, + root_uri: str, + timeout: int = 30, + memory_limit: int = 2097152, + coq_lsp: str = "coq-lsp", + coq_lsp_options: Tuple[str] = None, + init_options: Dict = __DEFAULT_INIT_OPTIONS, + ): + """Creates a CoqLspClient + + Args: + root_uri (str): URI to the workspace where coq-lsp will run + The URI can be either a file or a folder. + timeout (int, optional): Timeout used for the coq-lsp operations. + Defaults to 2. + memory_limit (int, optional): RAM limit for the coq-lsp process + in kbytes. It only works for Linux systems. Defaults to 2097152. + coq_lsp(str, optional): Path to the coq-lsp binary. Defaults to "coq-lsp". + init_options (Dict, optional): Initialization options for coq-lsp server. + Available options are: + max_errors (int): Maximum number of errors per file, after that, + coq-lsp will stop checking the file. Defaults to 120000000. + show_coq_info_messages (bool): Show Coq's info messages as diagnostics. + Defaults to false. + show_notices_as_diagnostics (bool): Show Coq's notice messages + as diagnostics, such as `About` and `Search` operations. + Defaults to false. + debug (bool): Enable Debug in Coq Server. Defaults to false. + pp_type (int): Method to print Coq Terms. + 0 = Print to string + 1 = Use jsCoq's Pp rich layout printer + 2 = Coq Layout Engine + Defaults to 1. + """ + self.file_progress: Dict[str, List[CoqFileProgressParams]] = {} + + if sys.platform.startswith("linux"): + command = f"ulimit -v {memory_limit}; {coq_lsp}" + else: + command = f"{coq_lsp}" + + if coq_lsp_options is None: + command += " -D 0" + else: + hasDOption = False + for option in coq_lsp_options: + if option.startswith("-D"): + hasDOption = True + break + if not hasDOption: + command += " -D 0" + command += " " + " ".join(coq_lsp_options) + + proc = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + shell=True, + ) + json_rpc_endpoint = JsonRpcEndpoint(proc.stdin, proc.stdout) + lsp_endpoint = LspEndpoint(json_rpc_endpoint, timeout=timeout) + lsp_endpoint.notify_callbacks = { + "$/coq/fileProgress": self.__handle_file_progress, + "textDocument/publishDiagnostics": self.__handle_publish_diagnostics, + } + super().__init__(lsp_endpoint) + workspaces = [{"name": "coq-lsp", "uri": root_uri}] + # This is required to be False since we use it to know if operations + # such as didOpen and didChange already finished. + init_options["eager_diagnostics"] = False + self.initialize( + proc.pid, + "", + root_uri, + init_options, + {}, + "off", + workspaces, + ) + self.initialized() + # Used to check if didOpen and didChange already finished + self.__completed_operation = threading.Event() + + def __handle_publish_diagnostics(self, params: Dict): + self.__completed_operation.set() + + def __handle_file_progress(self, params: Dict): + coq_file_progress = CoqFileProgressParams.parse(params) + if coq_file_progress is None: + return + uri = coq_file_progress.textDocument.uri + if uri not in self.file_progress: + self.file_progress[uri] = [coq_file_progress] + else: + self.file_progress[uri].append(coq_file_progress) + + def __wait_for_operation(self): + timeout = not self.__completed_operation.wait(self.lsp_endpoint.timeout) + self.__completed_operation.clear() + if self.lsp_endpoint.shutdown_flag: + raise ResponseError(ErrorCodes.ServerQuit, "Server quit") + if timeout: + self.shutdown() + self.exit() + raise ResponseError(ErrorCodes.ServerTimeout, "Server timeout") + + def didOpen(self, textDocument: TextDocumentItem): + """Open a text document in the server. + + Args: + textDocument (TextDocumentItem): Text document to open + """ + self.lsp_endpoint.diagnostics[textDocument.uri] = [] + super().didOpen(textDocument) + self.__wait_for_operation() + + def didChange( + self, + textDocument: VersionedTextDocumentIdentifier, + contentChanges: list[TextDocumentContentChangeEvent], + ): + """Submit changes on a text document already open on the server. + + Args: + textDocument (VersionedTextDocumentIdentifier): Text document changed. + contentChanges (list[TextDocumentContentChangeEvent]): Changes made. + """ + self.lsp_endpoint.diagnostics[textDocument.uri] = [] + super().didChange(textDocument, contentChanges) + self.__wait_for_operation() + + def proof_goals( + self, textDocument: TextDocumentIdentifier, position: Position + ) -> Optional[GoalAnswer]: + """Get proof goals and relevant information at a position. + + Args: + textDocument (TextDocumentIdentifier): Text document to consider. + position (Position): Position used to get the proof goals. + + Returns: + GoalAnswer: Contains the goals at a position, messages associated + to the position and if errors exist, the top error at the position. + """ + result_dict = self.lsp_endpoint.call_method( + "proof/goals", textDocument=textDocument, position=position + ) + return GoalAnswer.parse(result_dict) + + def get_document( + self, textDocument: TextDocumentIdentifier + ) -> Optional[FlecheDocument]: + """Get the AST of a text document. + + Args: + textDocument (TextDocumentIdentifier): Text document + + Returns: + Optional[FlecheDocument]: Serialized version of Fleche's document + """ + result_dict = self.lsp_endpoint.call_method( + "coq/getDocument", textDocument=textDocument + ) + return FlecheDocument.parse(result_dict) + + def save_vo(self, textDocument: TextDocumentIdentifier): + """Save a compiled file to disk. + + Args: + textDocument (TextDocumentIdentifier): File to be saved. + The uri in the textDocument should contain an absolute path. + """ + self.lsp_endpoint.call_method("coq/saveVo", textDocument=textDocument) + + # TODO: handle performance data notification? diff --git a/proof-search/coqpyt/coq/lsp/structs.py b/proof-search/coqpyt/coq/lsp/structs.py new file mode 100644 index 0000000..d9795e9 --- /dev/null +++ b/proof-search/coqpyt/coq/lsp/structs.py @@ -0,0 +1,252 @@ +from enum import Enum +from typing import Any, Optional, Tuple, List, Dict + +from coqpyt.lsp.structs import Range, VersionedTextDocumentIdentifier, Position + + +class Hyp(object): + def __init__(self, names: List[str], ty: str, definition: Optional[str] = None): + self.names = names + self.ty = ty + self.definition = definition + + def __repr__(self) -> str: + return ", ".join(self.names) + f": {self.ty}" + + +class Goal(object): + def __init__(self, hyps: List[Hyp], ty: str): + self.hyps = hyps + self.ty = ty + + @staticmethod + def parse(goal: Dict) -> Optional["Goal"]: + if "hyps" not in goal: + return None + for hyp in goal["hyps"]: + if "def" in hyp: + hyp["definition"] = hyp["def"] + hyp.pop("def") + hyps = [Hyp(**hyp) for hyp in goal["hyps"]] + ty = "" if "ty" not in goal else goal["ty"] + return Goal(hyps, ty) + + def __repr__(self) -> str: + hyps = list(map(lambda hyp: repr(hyp), self.hyps)) + if len(hyps) > 0: + return "\n".join(hyps) + f"\n\n{self.ty}" + else: + return self.ty + + +class GoalConfig(object): + def __init__( + self, + goals: List[Goal], + stack: List[Tuple[List[Goal], List[Goal]]], + shelf: List[Goal], + given_up: List[Goal], + bullet: Any = None, + ): + self.goals = goals + self.stack = stack + self.shelf = shelf + self.given_up = given_up + self.bullet = bullet + + def __repr__(self) -> str: + bold = lambda text: "\033[1m\033[93m" + text + "\033[0m" + if len(self.goals) > 0: + res = bold("Goals:\n") + for goal in self.goals: + res += "\n" + "-" * 50 + "\n" + repr(goal) + "\n" + "-" * 50 + else: + res = "No more goals." + + if any(map(lambda stack: len(stack[0]) > 0 or len(stack[1]) > 0, self.stack)): + res += bold("\n\nStack:") + for stack in self.stack: + for goal in stack[0] + stack[1]: + res += "\n" + "-" * 50 + "\n" + repr(goal) + "\n" + "-" * 50 + + if len(self.shelf) > 0: + res += bold("\n\nShelf:") + for goal in self.shelf: + res += "\n" + "-" * 50 + "\n" + repr(goal) + "\n" + "-" * 50 + + if len(self.given_up) > 0: + res += bold("\n\nGiven up:") + for goal in self.given_up: + res += "\n" + "-" * 50 + "\n" + repr(goal) + "\n" + "-" * 50 + + res += bold("\n\nBullet: ") + repr(self.bullet) + return res + + @staticmethod + def parse(goal_config: Dict) -> Optional["GoalConfig"]: + parse_goals = lambda goals: [ + parsed + for parsed in (Goal.parse(goal) for goal in goals) + if parsed is not None + ] + goals = parse_goals(goal_config["goals"]) + stack = [(parse_goals(t[0]), parse_goals(t[1])) for t in goal_config["stack"]] + bullet = None if "bullet" not in goal_config else goal_config["bullet"] + shelf = parse_goals(goal_config["shelf"]) + given_up = parse_goals(goal_config["given_up"]) + return GoalConfig(goals, stack, shelf, given_up, bullet=bullet) + + +class Message(object): + def __init__(self, level, text, range: Optional[Range] = None): + self.level = level + self.text = text + self.range = range + + +class GoalAnswer(object): + def __init__( + self, + textDocument: VersionedTextDocumentIdentifier, + position: Position, + messages: List[Message], + goals: Optional[GoalConfig] = None, + error: Any = None, + program: Optional[List] = None, + range: Optional[Range] = None, + ): + self.textDocument = textDocument + self.position = position + self.messages = messages + self.goals = goals + self.error = error + self.program = [] if program is None else program + self.range = range + + def __repr__(self): + res = "\n" + + if len(self.messages) > 0: + res += "Messages:\n" + for message in self.messages: + res += f"{message.level}: {message.text}\n" + + if self.goals is not None: + res += repr(self.goals) + + if self.error is not None: + res += "\nError: " + repr(self.error) + + return res + + @staticmethod + def parse(goal_answer) -> Optional["GoalAnswer"]: + goal_answer["textDocument"] = VersionedTextDocumentIdentifier( + **goal_answer["textDocument"] + ) + goal_answer["position"] = Position( + goal_answer["position"]["line"], goal_answer["position"]["character"] + ) + + if "goals" in goal_answer: + goal_answer["goals"] = GoalConfig.parse(goal_answer["goals"]) + + for i, message in enumerate(goal_answer["messages"]): + if not isinstance(message, str): + if message["range"]: + message["range"] = Range(**message["range"]) + goal_answer["messages"][i] = Message(**message) + + return GoalAnswer(**goal_answer) + + +class Result(object): + def __init__(self, range, message): + self.range = range + self.message = message + + +class Query(object): + def __init__(self, query, results): + self.query = query + self.results = results + + +class RangedSpan(object): + def __init__(self, range: Range, span: Any): + self.range = range + self.span = span + + +class CompletionStatus(object): + def __init__(self, status: str, range: Range): + self.status = status + self.range = range + + +class FlecheDocument(object): + def __init__(self, spans: List[RangedSpan], completed: CompletionStatus): + self.spans = spans + self.completed = completed + + @staticmethod + def parse(fleche_document: Dict) -> Optional["FlecheDocument"]: + if "spans" not in fleche_document or "completed" not in fleche_document: + return None + spans: List[RangedSpan] = [] + for span in fleche_document["spans"]: + range = Range(**span["range"]) + spans.append( + RangedSpan(range, None if "span" not in span else span["span"]) + ) + completion_status = CompletionStatus( + fleche_document["completed"]["status"], + Range(**fleche_document["completed"]["range"]), + ) + return FlecheDocument(spans, completion_status) + + +class CoqFileProgressKind(Enum): + Processing = 1 + FatalError = 2 + + +class CoqFileProgressProcessingInfo(object): + def __init__(self, range: Range, kind: Optional[CoqFileProgressKind]): + self.range = range + self.kind = kind + + +class CoqFileProgressParams(object): + def __init__( + self, + textDocument: VersionedTextDocumentIdentifier, + processing: List[CoqFileProgressProcessingInfo], + ): + self.textDocument = textDocument + self.processing = processing + + @staticmethod + def parse(coqFileProgressParams: Dict) -> Optional["CoqFileProgressParams"]: + if ( + "textDocument" not in coqFileProgressParams + or "processing" not in coqFileProgressParams + ): + return None + textDocument = VersionedTextDocumentIdentifier( + coqFileProgressParams["textDocument"]["uri"], + coqFileProgressParams["textDocument"]["version"], + ) + processing = [] + for progress in coqFileProgressParams["processing"]: + processing.append( + CoqFileProgressProcessingInfo( + Range(**progress["range"]), + ( + None + if "kind" not in progress + else CoqFileProgressKind(progress["kind"]) + ), + ) + ) + return CoqFileProgressParams(textDocument, processing) diff --git a/proof-search/coqpyt/coq/proof_file.py b/proof-search/coqpyt/coq/proof_file.py index d30df7c..eb8730f 100644 --- a/proof-search/coqpyt/coq/proof_file.py +++ b/proof-search/coqpyt/coq/proof_file.py @@ -16,17 +16,22 @@ ResponseError, ErrorCodes, ) -from coqpyt.lsp.structs import Result, Query, Range, GoalAnswer, Position -from coqpyt.lsp.client import CoqLspClient +from coqpyt.coq.lsp.structs import Result, Query, Range, GoalAnswer, Position +from coqpyt.coq.lsp.client import CoqLspClient from coqpyt.coq.structs import TermType, Step, Term, ProofStep, ProofTerm from coqpyt.coq.exceptions import * from coqpyt.coq.changes import * +from coqpyt import __upstream_version__ from coqpyt.coq.context import FileContext from coqpyt.coq.base_file import CoqFile class _AuxFile(object): - CACHE_NAME = "coqpyt_cache" + CACHE_FORMAT_VERSION = 4 + CACHE_NAME = ( + f"coqpyt_cache_{__upstream_version__}_v{CACHE_FORMAT_VERSION}" + ) + LEGACY_CACHE_NAMES = ("coqpyt_cache",) def __init__( self, @@ -200,7 +205,12 @@ def get_coqpyt_disk_cache_loc(cls) -> Optional[str]: home_dir = os.environ["USERPROFILE"] else: return None - cache_loc = os.path.join(home_dir, ".cache", cls.CACHE_NAME) + cache_root = os.path.join(home_dir, ".cache") + cache_loc = os.path.join(cache_root, cls.CACHE_NAME) + for legacy_name in cls.LEGACY_CACHE_NAMES: + legacy = os.path.join(cache_root, legacy_name) + if legacy != cache_loc and os.path.exists(legacy): + shutil.rmtree(legacy, ignore_errors=True) return cache_loc @classmethod @@ -232,10 +242,16 @@ def get_library( timeout: int, coq_lsp_options: Optional[Tuple[str, ...]] = None, workspace: Optional[str] = None, + cache_workspace: Optional[str] = None, use_disk_cache: bool = False, ) -> Dict[str, Term]: with open(library_file, "r") as f: - contents_to_hash = library_name + library_file + str(workspace) + f.read() + contents_to_hash = ( + library_name + + library_file + + str(cache_workspace if cache_workspace is not None else workspace) + + f.read() + ) library_hash = hashlib.md5(contents_to_hash.encode("utf-8")).hexdigest() if use_disk_cache: cached_library = cls.get_from_disk_cache(library_hash) @@ -277,6 +293,7 @@ def get_libraries(aux_file: "_AuxFile") -> List[str]: def get_coq_context( timeout: int, workspace: Optional[str] = None, + cache_workspace: Optional[str] = None, use_disk_cache: bool = False, coq_lsp_options: Optional[Tuple[str, ...]] = None, ) -> FileContext: @@ -303,6 +320,7 @@ def get_coq_context( v_file, timeout, workspace=workspace, + cache_workspace=cache_workspace, use_disk_cache=use_disk_cache, coq_lsp_options=coq_lsp_options, ) @@ -329,6 +347,8 @@ def __init__( coqtop: str = "coqtop", error_mode: str = "strict", use_disk_cache: bool = False, + cache_workspace: Optional[str] = None, + index_imported_libraries: bool = True, ): """Creates a ProofFile. @@ -352,6 +372,8 @@ def __init__( loaded from the cache if their corresponing library (file) has the same text. Note that caching only depends on the text of the file, so if the Coq version changes, or the version of coqpyt changes, the cache should be deleted. + index_imported_libraries (bool, optional): If False, skip building a symbol + index for imported libraries. Defaults to True. """ if not os.path.isabs(file_path): file_path = os.path.abspath(file_path) @@ -373,19 +395,23 @@ def __init__( ) self.__error_mode = error_mode self.__use_disk_cache = use_disk_cache + self.__cache_workspace = cache_workspace + self.__index_imported_libraries = index_imported_libraries self.__aux_file.didOpen() self.__coq_lsp_options = coq_lsp_options try: # We need to update the context already defined in the CoqFile - self.context.update( - _AuxFile.get_coq_context( - self.timeout, - workspace=self.workspace, - coq_lsp_options=coq_lsp_options, - use_disk_cache=self.__use_disk_cache, + if self.__index_imported_libraries: + self.context.update( + _AuxFile.get_coq_context( + self.timeout, + workspace=self.workspace, + cache_workspace=self.__cache_workspace, + coq_lsp_options=coq_lsp_options, + use_disk_cache=self.__use_disk_cache, + ) ) - ) except Exception as e: self.close() raise e @@ -426,6 +452,8 @@ def __step_context(self, step: Step) -> List[Term]: if term is not None and term not in res: res.append(term) elif FileContext.is_notation(el): + if not self.__index_imported_libraries: + continue stack.append(el[1:]) notation_name = el[2][1] @@ -463,15 +491,8 @@ def __step_context(self, step: Step) -> List[Term]: def __get_program_context(self) -> Tuple[Term, List[Term]]: expr = self.context.expr(self.prev_step) - # Tags: - # 0 - Obligation N of id : type - # 1 - Obligation N of id - # 2 - Obligation N : type - # 3 - Obligation N - # 4 - Next Obligation of id - # 5 - Next Obligation tag = self.context.ext_index(expr[1]) - if tag in [0, 1, 4]: + if self.context.obligation_tag_with_id(tag): stack = expr[:0:-1] while len(stack) > 0: el = stack.pop() @@ -723,6 +744,7 @@ def __update_libraries(self): self.timeout, self.__coq_lsp_options, workspace=self.workspace, + cache_workspace=self.__cache_workspace, use_disk_cache=self.__use_disk_cache, ) self.context.add_library(library, library_terms) @@ -1014,7 +1036,11 @@ def exec(self, nsteps=1) -> List[Step]: self.__step(step(), sign == -1) import_step = self.prev_step if sign == 1 else self.curr_step - if self.context.expr(import_step)[0] in ["VernacRequire", "VernacImport"]: + if ( + self.__index_imported_libraries + and self.context.expr(import_step)[0] + in ["VernacRequire", "VernacImport"] + ): self.__update_libraries() last, slice = sign == 1, (initial_steps_taken, self.steps_taken) @@ -1129,3 +1155,9 @@ def change_steps(self, changes: List[CoqChange]): def close(self): super().close() self.__aux_file.close() + + @staticmethod + def clear_disk_cache(): + cache_loc = _AuxFile.get_coqpyt_disk_cache_loc() + if cache_loc is not None and os.path.exists(cache_loc): + shutil.rmtree(cache_loc, ignore_errors=True) diff --git a/proof-search/coqpyt/coq/structs.py b/proof-search/coqpyt/coq/structs.py index c3136fc..c8ae587 100644 --- a/proof-search/coqpyt/coq/structs.py +++ b/proof-search/coqpyt/coq/structs.py @@ -2,7 +2,7 @@ from typing import Any, Optional, List, Union, Callable from coqpyt.lsp.structs import Diagnostic, Position -from coqpyt.lsp.structs import RangedSpan, GoalAnswer +from coqpyt.coq.lsp.structs import RangedSpan, GoalAnswer class SegmentType(Enum): diff --git a/proof-search/coqpyt/lsp/__init__.py b/proof-search/coqpyt/lsp/__init__.py index d684040..d385c45 100644 --- a/proof-search/coqpyt/lsp/__init__.py +++ b/proof-search/coqpyt/lsp/__init__.py @@ -3,11 +3,6 @@ __all__ = [] from coqpyt.lsp.json_rpc_endpoint import JsonRpcEndpoint -from coqpyt.lsp.client import LspClient, CoqLspClient +from coqpyt.lsp.client import LspClient from coqpyt.lsp.endpoint import LspEndpoint from coqpyt.lsp import structs -from coqpyt.lsp.structs import ( - Hyp, Goal, GoalConfig, Message, GoalAnswer, Result, Query, RangedSpan, - CompletionStatus, FlecheDocument, CoqFileProgressKind, CoqFileProgressProcessingInfo, - CoqFileProgressParams -) diff --git a/proof-search/coqpyt/lsp/client.py b/proof-search/coqpyt/lsp/client.py index 4ccbd61..a1f5c57 100644 --- a/proof-search/coqpyt/lsp/client.py +++ b/proof-search/coqpyt/lsp/client.py @@ -147,8 +147,10 @@ def definition(self, textDocument, position): ) if isinstance(result_dict, list): return [ - structs.Location(**l) if "uri" in l else structs.LinkLocation(**l) - for l in result_dict + structs.Location(**item) + if "uri" in item + else structs.LinkLocation(**item) + for item in result_dict ] if "uri" in result_dict: return [structs.Location(**result_dict)] @@ -164,13 +166,16 @@ def typeDefinition(self, textDocument, position): result_dict = self.lsp_endpoint.call_method( "textDocument/typeDefinition", textDocument=textDocument, position=position ) + if isinstance(result_dict, list): + return [ + structs.Location(**item) + if "uri" in item + else structs.LinkLocation(**item) + for item in result_dict + ] if "uri" in result_dict: return structs.Location(**result_dict) - - return [ - structs.Location(**l) if "uri" in l else structs.LinkLocation(**l) - for l in result_dict - ] + return structs.LinkLocation(**result_dict) def signatureHelp(self, textDocument, position): """ @@ -225,205 +230,3 @@ def declaration(self, textDocument, position): structs.Location(**l) if "uri" in l else structs.LinkLocation(**l) for l in result_dict ] - -# Coq-specific LSP client -import sys -import threading -import subprocess -from typing import Tuple, Dict, List, Optional -from coqpyt.lsp.structs import * -from coqpyt.lsp.json_rpc_endpoint import JsonRpcEndpoint - - -class CoqLspClient(LspClient): - """Abstraction to interact with coq-lsp - - Attributes: - file_progress (Dict[str, List[CoqFileProgressParams]]): Contains all - the `$/coq/fileProgress` notifications sent by the server. The - keys are the URIs of the files and the values are the list of - notifications. - """ - - __DEFAULT_INIT_OPTIONS = { - "max_errors": 120000000, - "goal_after_tactic": False, - "show_coq_info_messages": True, - } - - def __init__( - self, - root_uri: str, - timeout: int = 30, - memory_limit: int = 2097152, - coq_lsp: str = "coq-lsp", - coq_lsp_options: Optional[Tuple[str, ...]] = None, - init_options: Dict = __DEFAULT_INIT_OPTIONS, - ): - """Creates a CoqLspClient - - Args: - root_uri (str): URI to the workspace where coq-lsp will run - The URI can be either a file or a folder. - timeout (int, optional): Timeout used for the coq-lsp operations. - Defaults to 2. - memory_limit (int, optional): RAM limit for the coq-lsp process - in kbytes. It only works for Linux systems. Defaults to 2097152. - coq_lsp(str, optional): Path to the coq-lsp binary. Defaults to "coq-lsp". - init_options (Dict, optional): Initialization options for coq-lsp server. - Available options are: - max_errors (int): Maximum number of errors per file, after that, - coq-lsp will stop checking the file. Defaults to 120000000. - show_coq_info_messages (bool): Show Coq's info messages as diagnostics. - Defaults to false. - show_notices_as_diagnostics (bool): Show Coq's notice messages - as diagnostics, such as `About` and `Search` operations. - Defaults to false. - debug (bool): Enable Debug in Coq Server. Defaults to false. - pp_type (int): Method to print Coq Terms. - 0 = Print to string - 1 = Use jsCoq's Pp rich layout printer - 2 = Coq Layout Engine - Defaults to 1. - """ - self.file_progress: Dict[str, List[CoqFileProgressParams]] = {} - - if sys.platform.startswith("linux"): - command = f"ulimit -v {memory_limit}; {coq_lsp}" - else: - command = f"{coq_lsp}" - - if coq_lsp_options is None: - command += " -D 0" - else: - hasDOption = False - for option in coq_lsp_options: - if option.startswith("-D"): - hasDOption = True - break - if not hasDOption: - command += " -D 0" - command += " " + " ".join(coq_lsp_options) - - proc = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE, - shell=True, - ) - json_rpc_endpoint = JsonRpcEndpoint(proc.stdin, proc.stdout) - lsp_endpoint = LspEndpoint(json_rpc_endpoint, timeout=timeout) - lsp_endpoint.notify_callbacks = { - "$/coq/fileProgress": self.__handle_file_progress, - "textDocument/publishDiagnostics": self.__handle_publish_diagnostics, - } - super().__init__(lsp_endpoint) - workspaces = [{"name": "coq-lsp", "uri": root_uri}] - # This is required to be False since we use it to know if operations - # such as didOpen and didChange already finished. - init_options["eager_diagnostics"] = False - self.initialize( - proc.pid, - "", - root_uri, - init_options, - {}, - "off", - workspaces, - ) - self.initialized() - # Used to check if didOpen and didChange already finished - self.__completed_operation = threading.Event() - - def __handle_publish_diagnostics(self, params: Dict): - self.__completed_operation.set() - - def __handle_file_progress(self, params: Dict): - coq_file_progress = CoqFileProgressParams.parse(params) - if coq_file_progress is None: - return - - uri = coq_file_progress.textDocument.uri - if uri not in self.file_progress: - self.file_progress[uri] = [coq_file_progress] - else: - self.file_progress[uri].append(coq_file_progress) - - def __wait_for_operation(self): - timeout = not self.__completed_operation.wait(self.lsp_endpoint.timeout) - self.__completed_operation.clear() - if self.lsp_endpoint.shutdown_flag: - raise ResponseError(ErrorCodes.ServerQuit, "Server quit") - if timeout: - self.shutdown() - self.exit() - raise ResponseError(ErrorCodes.ServerTimeout, "Server timeout") - - def didOpen(self, textDocument: TextDocumentItem): - """Open a text document in the server. - - Args: - textDocument (TextDocumentItem): Text document to open - """ - self.lsp_endpoint.diagnostics[textDocument.uri] = [] - super().didOpen(textDocument) - self.__wait_for_operation() - - def didChange( - self, - textDocument: VersionedTextDocumentIdentifier, - contentChanges: list[TextDocumentContentChangeEvent], - ): - """Submit changes on a text document already open on the server. - - Args: - textDocument (VersionedTextDocumentIdentifier): Text document changed. - contentChanges (list[TextDocumentContentChangeEvent]): Changes made. - """ - self.lsp_endpoint.diagnostics[textDocument.uri] = [] - super().didChange(textDocument, contentChanges) - self.__wait_for_operation() - - def proof_goals( - self, textDocument: TextDocumentIdentifier, position: Position - ) -> Optional[GoalAnswer]: - """Get proof goals and relevant information at a position. - - Args: - textDocument (TextDocumentIdentifier): Text document to consider. - position (Position): Position used to get the proof goals. - - Returns: - GoalAnswer: Contains the goals at a position, messages associated - to the position and if errors exist, the top error at the position. - """ - result_dict = self.lsp_endpoint.call_method( - "proof/goals", textDocument=textDocument, position=position - ) - return GoalAnswer.parse(result_dict) - - def get_document( - self, textDocument: TextDocumentIdentifier - ) -> Optional[FlecheDocument]: - """Get the AST of a text document. - - Args: - textDocument (TextDocumentIdentifier): Text document - - Returns: - Optional[FlecheDocument]: Serialized version of Fleche's document - """ - result_dict = self.lsp_endpoint.call_method( - "coq/getDocument", textDocument=textDocument - ) - return FlecheDocument.parse(result_dict) - - def save_vo(self, textDocument: TextDocumentIdentifier): - """Save a compiled file to disk. - - Args: - textDocument (TextDocumentIdentifier): File to be saved. - The uri in the textDocument should contain an absolute path. - """ - self.lsp_endpoint.call_method("coq/saveVo", textDocument=textDocument) - # TODO: handle performance data notification? diff --git a/proof-search/coqpyt/lsp/endpoint.py b/proof-search/coqpyt/lsp/endpoint.py index b031f39..9c6743e 100644 --- a/proof-search/coqpyt/lsp/endpoint.py +++ b/proof-search/coqpyt/lsp/endpoint.py @@ -31,15 +31,6 @@ def handle_result(self, rpc_id, result, error): def stop(self): self.shutdown_flag = True - - # Unblock all waiting cond var - for cond in list(self.event_dict.values()): - try: - cond.acquire() - cond.notify_all() - cond.release() - except: - pass def run(self): while not self.shutdown_flag: diff --git a/proof-search/coqpyt/lsp/structs.py b/proof-search/coqpyt/lsp/structs.py index 47875f2..3a7620b 100644 --- a/proof-search/coqpyt/lsp/structs.py +++ b/proof-search/coqpyt/lsp/structs.py @@ -666,254 +666,3 @@ def __init__(self, code, message, data=None): self.message = message if data: self.data = data - - -# Coq-specific LSP structs -from enum import Enum -from typing import Any, Optional, Tuple, List, Dict - - -class Hyp(object): - def __init__(self, names: List[str], ty: str, definition: Optional[str] = None): - self.names = names - self.ty = ty - self.definition = definition - - def __repr__(self) -> str: - return ", ".join(self.names) + f": {self.ty}" - - -class Goal(object): - def __init__(self, hyps: List[Hyp], ty: str): - self.hyps = hyps - self.ty = ty - - @staticmethod - def parse(goal: Dict) -> Optional["Goal"]: - if "hyps" not in goal: - return None - for hyp in goal["hyps"]: - if "def" in hyp: - hyp["definition"] = hyp["def"] - hyp.pop("def") - hyps = [Hyp(**hyp) for hyp in goal["hyps"]] - ty = "" if "ty" not in goal else goal["ty"] - return Goal(hyps, ty) - - def __repr__(self) -> str: - hyps = list(map(lambda hyp: repr(hyp), self.hyps)) - if len(hyps) > 0: - return "\n".join(hyps) + f"\n\n{self.ty}" - else: - return self.ty - - -class GoalConfig(object): - def __init__( - self, - goals: List[Goal], - stack: List[Tuple[List[Goal], List[Goal]]], - shelf: List[Goal], - given_up: List[Goal], - bullet: Any = None, - ): - self.goals = goals - self.stack = stack - self.shelf = shelf - self.given_up = given_up - self.bullet = bullet - - def __repr__(self) -> str: - bold = lambda text: "\033[1m\033[93m" + text + "\033[0m" - if len(self.goals) > 0: - res = bold("Goals:\n") - for goal in self.goals: - res += "\n" + "-" * 50 + "\n" + repr(goal) + "\n" + "-" * 50 - else: - res = "No more goals." - - if any(map(lambda stack: len(stack[0]) > 0 or len(stack[1]) > 0, self.stack)): - res += bold("\n\nStack:") - for stack in self.stack: - for goal in stack[0] + stack[1]: - res += "\n" + "-" * 50 + "\n" + repr(goal) + "\n" + "-" * 50 - - if len(self.shelf) > 0: - res += bold("\n\nShelf:") - for goal in self.shelf: - res += "\n" + "-" * 50 + "\n" + repr(goal) + "\n" + "-" * 50 - - if len(self.given_up) > 0: - res += bold("\n\nGiven up:") - for goal in self.given_up: - res += "\n" + "-" * 50 + "\n" + repr(goal) + "\n" + "-" * 50 - - res += bold("\n\nBullet: ") + repr(self.bullet) - return res - - @staticmethod - def parse(goal_config: Dict) -> Optional["GoalConfig"]: - parse_goals = lambda goals: [ - goal for goal in (Goal.parse(goal) for goal in goals) if goal is not None - ] - goals = parse_goals(goal_config["goals"]) - stack = [(parse_goals(t[0]), parse_goals(t[1])) for t in goal_config["stack"]] - bullet = None if "bullet" not in goal_config else goal_config["bullet"] - shelf = parse_goals(goal_config["shelf"]) - given_up = parse_goals(goal_config["given_up"]) - return GoalConfig(goals, stack, shelf, given_up, bullet=bullet) - - -class Message(object): - def __init__(self, level, text, range: Optional[Range] = None): - self.level = level - self.text = text - self.range = range - - -class GoalAnswer(object): - def __init__( - self, - textDocument: VersionedTextDocumentIdentifier, - position: Position, - messages: List[Message], - goals: Optional[GoalConfig] = None, - error: Any = None, - program: Optional[List] = None, - range: Optional[Range] = None, - ): - self.textDocument = textDocument - self.position = position - self.messages = messages - self.goals = goals - self.error = error - self.program = [] if program is None else program - self.range = range - - def __repr__(self): - res = "\n" - - if len(self.messages) > 0: - res += "Messages:\n" - for message in self.messages: - res += f"{message.level}: {message.text}\n" - - if self.goals is not None: - res += repr(self.goals) - - if self.error is not None: - res += "\nError: " + repr(self.error) - - return res - - @staticmethod - def parse(goal_answer) -> Optional["GoalAnswer"]: - goal_answer["textDocument"] = VersionedTextDocumentIdentifier( - **goal_answer["textDocument"] - ) - goal_answer["position"] = Position( - goal_answer["position"]["line"], goal_answer["position"]["character"] - ) - - if "goals" in goal_answer: - goal_answer["goals"] = GoalConfig.parse(goal_answer["goals"]) - - for i, message in enumerate(goal_answer["messages"]): - if not isinstance(message, str): - if message["range"]: - message["range"] = Range(**message["range"]) - goal_answer["messages"][i] = Message(**message) - - return GoalAnswer(**goal_answer) - - -class Result(object): - def __init__(self, range, message): - self.range = range - self.message = message - - -class Query(object): - def __init__(self, query, results): - self.query = query - self.results = results - - -class RangedSpan(object): - def __init__(self, range: Range, span: Any): - self.range = range - self.span = span - - -class CompletionStatus(object): - def __init__(self, status: str, range: Range): - self.status = status - self.range = range - - -class FlecheDocument(object): - def __init__(self, spans: List[RangedSpan], completed: CompletionStatus): - self.spans = spans - self.completed = completed - - @staticmethod - def parse(fleche_document: Dict) -> Optional["FlecheDocument"]: - if "spans" not in fleche_document or "completed" not in fleche_document: - return None - spans: List[RangedSpan] = [] - for span in fleche_document["spans"]: - range = Range(**span["range"]) - spans.append( - RangedSpan(range, None if "span" not in span else span["span"]) - ) - completion_status = CompletionStatus( - fleche_document["completed"]["status"], - Range(**fleche_document["completed"]["range"]), - ) - return FlecheDocument(spans, completion_status) - - -class CoqFileProgressKind(Enum): - Processing = 1 - FatalError = 2 - - -class CoqFileProgressProcessingInfo(object): - def __init__(self, range: Range, kind: Optional[CoqFileProgressKind]): - self.range = range - self.kind = kind - - -class CoqFileProgressParams(object): - def __init__( - self, - textDocument: VersionedTextDocumentIdentifier, - processing: List[CoqFileProgressProcessingInfo], - ): - self.textDocument = textDocument - self.processing = processing - - @staticmethod - def parse(coqFileProgressParams: Dict) -> Optional["CoqFileProgressParams"]: - if ( - "textDocument" not in coqFileProgressParams - or "processing" not in coqFileProgressParams - ): - return None - textDocument = VersionedTextDocumentIdentifier( - coqFileProgressParams["textDocument"]["uri"], - coqFileProgressParams["textDocument"]["version"], - ) - processing = [] - for progress in coqFileProgressParams["processing"]: - processing.append( - CoqFileProgressProcessingInfo( - Range(**progress["range"]), - ( - None - if "kind" not in progress - else CoqFileProgressKind(progress["kind"]) - ), - ) - ) - return CoqFileProgressParams(textDocument, processing) diff --git a/proof-search/coqpyt/tests/test_rocq9_compat.py b/proof-search/coqpyt/tests/test_rocq9_compat.py new file mode 100644 index 0000000..2bc51ae --- /dev/null +++ b/proof-search/coqpyt/tests/test_rocq9_compat.py @@ -0,0 +1,38 @@ +"""Regression tests for CoqPyt's Rocq 9 AST and core-library handling.""" + +from pathlib import Path + +from coqpyt.coq.base_file import CoqFile +from coqpyt.coq.context import FileContext + + +def test_corelib_init_is_treated_as_a_core_library(tmp_path: Path): + source = tmp_path / "Logic.v" + source.write_text("(* core library fixture *)\n", encoding="utf-8") + coq_file = object.__new__(CoqFile) + + coq_file._CoqFile__init_path(str(source), "Corelib.Init.Logic") + + copied_path = Path(coq_file._path) + try: + assert copied_path != source + assert copied_path.read_text(encoding="utf-8") == source.read_text( + encoding="utf-8" + ) + finally: + if copied_path != source: + copied_path.unlink(missing_ok=True) + + +def test_rocq9_obligation_tags_with_identifiers(monkeypatch): + monkeypatch.setattr( + "coqpyt.coq.context.subprocess.check_output", + lambda *_args, **_kwargs: b"The Rocq Prover, version 9.0.0\n", + ) + + context = FileContext("fixture.v") + + assert context.obligation_tag_with_id(0) + assert context.obligation_tag_with_id(2) + assert not context.obligation_tag_with_id(1) + assert not context.obligation_tag_with_id(3) diff --git a/proof-search/main.py b/proof-search/main.py index a70f780..376b3f1 100644 --- a/proof-search/main.py +++ b/proof-search/main.py @@ -1,276 +1,193 @@ #!/usr/bin/env python3 -""" -Main entry point for the proof agent. -Orchestrates the proof search process using LLM-generated tactics. -""" +"""Command-line entry point for the proof agent.""" + +from __future__ import annotations -import sys import argparse +import asyncio import logging -import signal +import sys from datetime import datetime from pathlib import Path -from typing import Optional, Dict, Any -from contextlib import contextmanager +from typing import Any, Callable, Dict, Sequence -# Add project root to path sys.path.insert(0, str(Path(__file__).parent)) -from backend.coq_interface import CoqInterface from agent.context_manager import ContextManager -from agent.proof_controller import ProofController from agent.interactive_session import InteractiveSessionManager - -from utils.config import load_config, ProofAgentConfig -from utils.logger import setup_logger, global_logger - - -def parse_arguments(): - """Parse command line arguments.""" +from agent.proof_controller import ProofController +from backend.rocq import discover_theorem_name +from backend.factory import SUPPORTED_BACKENDS, create_backend +from backend.prover_backend import ( + ProverBackend, + SourceLocation, + TheoremIdentity, +) +from utils.config import ProofAgentConfig, load_config +from utils.logger import global_logger, setup_logger + + +def parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Automated Coq proof agent using LLM-generated tactics", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python main.py # Uses default config with default proof file - python main.py examples/example.v # Uses specific proof file with default config - python main.py examples/example.v --theorem mult_0_plus - python main.py examples/example.v --config config.json --max-steps 100 - """ - ) - - parser.add_argument( - "proof_file", - nargs='?', - help="Path to the Coq proof file (.v). If not specified, uses proof_file_path from config." - ) - - parser.add_argument( - "--theorem", "-t", - help="Specific theorem name to prove (if not specified, proves current theorem in file)" + description="Automated proof agent using typed prover backends" ) - + parser.add_argument("proof_file", nargs="?") + parser.add_argument("--theorem", "-t") + parser.add_argument("--config", "-c") + parser.add_argument("--backend", choices=SUPPORTED_BACKENDS) + parser.add_argument("--plan", "-p") + parser.add_argument("--max-steps", type=int) parser.add_argument( - "--config", "-c", - help="Path to configuration file (JSON format)" + "--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"] ) - parser.add_argument( - "--plan", "-p", - help="Path to proof plan file (default: none)" - ) - - parser.add_argument( - "--max-steps", - type=int, - default=None, - help="Maximum number of proof steps to attempt (default: from config or 50)" - ) - - parser.add_argument( - "--log-level", - choices=["DEBUG", "INFO", "WARNING", "ERROR"], - default=None, - help="Logging level (default: from config or INFO)" - ) - - parser.add_argument( - "--library-path", - action="append", + "--library-path", + action="append", nargs=2, metavar=("PATH", "NAME"), - help="Add custom library path mapping: --library-path /path/to/lib libname" - ) - - parser.add_argument( - "--coqproject-option", - action="append", - help="Add extra option to _CoqProject file" ) - + parser.add_argument("--coqproject-option", action="append") + parser.add_argument("--workspace") + parser.add_argument("--local-session-caching", action="store_true") + parser.add_argument("--interactive", "-i", action="store_true") parser.add_argument( - "--workspace", - help="Set workspace directory" + "--save-proof", + type=Path, + help="Write the completed proof certificate to this path", ) - parser.add_argument( - "--local-session-caching", + "--overwrite", action="store_true", - help="Use local session caching (stored to a local file)" + help="Allow --save-proof to replace an existing file", ) + return parser.parse_args(argv) - parser.add_argument( - "--interactive", "-i", - action="store_true", - help="Start in interactive mode (human + agent REPL)" - ) - - return parser.parse_args() - -def validate_arguments(args, config: ProofAgentConfig) -> bool: - """Validate command line arguments with config fallback.""" - # Determine which proof file to use - proof_file = args.proof_file - if not proof_file: - # Use proof file from config - if hasattr(config.coq, 'proof_file_path') and config.coq.proof_file_path: - proof_file = config.coq.proof_file_path - print(f"Using proof file from config: {proof_file}") - else: - print("Error: No proof file specified and none found in config") - return False - - # Update args with the determined proof file - args.proof_file = proof_file - - # Check if proof file exists - if not Path(proof_file).exists(): - print(f"Error: Proof file '{proof_file}' not found") +def validate_arguments( + args: argparse.Namespace, config: ProofAgentConfig +) -> bool: + if not args.proof_file: + args.proof_file = config.coq.proof_file_path + if not args.proof_file: + print("Error: no proof file was specified") return False - - # Check if proof file has .v extension - if not proof_file.endswith('.v'): - print(f"Warning: Proof file '{proof_file}' does not have .v extension") - - # Check if config file exists (if specified) - if args.config and not Path(args.config).exists(): - print(f"Error: Config file '{args.config}' not found") + source = Path(args.proof_file) + if not source.is_file(): + print(f"Error: proof file not found: {source}") return False - - # Validate plan file - if args.plan and not Path(args.plan).exists(): - print(f"Error: Plan file '{args.plan}' not found") + if args.config and not Path(args.config).is_file(): + print(f"Error: configuration file not found: {args.config}") + return False + if args.plan and not Path(args.plan).is_file(): + print(f"Error: proof plan not found: {args.plan}") return False - - # Validate max_steps if args.max_steps is not None and args.max_steps <= 0: - print(f"Error: max-steps must be positive, got {args.max_steps}") + print("Error: --max-steps must be positive") + return False + if args.overwrite and args.save_proof is None: + print("Error: --overwrite requires --save-proof") return False - return True -def setup_output_directory(output_dir: Optional[str]) -> Path: - """Setup output directory for logs, visualizations, etc.""" - if output_dir: - output_path = Path(output_dir) - else: - # Default: create output directory next to proof file - proof_file_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") - output_path = proof_file_path.parent / f"autorocq-{datetime.now().strftime('%Y%m%d-%H%M%S')}" - - output_path.mkdir(exist_ok=True) - return output_path +def _configured_library_paths( + args: argparse.Namespace, config: ProofAgentConfig +) -> list[dict[str, str]]: + mappings = [dict(item) for item in config.coq.library_paths] + for path, name in args.library_path or []: + mappings.append({"path": path, "name": name}) + return mappings -def initialize_components(args, config: ProofAgentConfig, logger) -> Dict[str, Any]: - """Initialize all proof agent components.""" - logger.info("Initializing proof agent components...") +def _configured_options( + args: argparse.Namespace, config: ProofAgentConfig +) -> list[str]: + return [ + *config.coq.coqproject_extra_options, + *(args.coqproject_option or []), + ] - coq_interface = None +def _backend_options( + args: argparse.Namespace, config: ProofAgentConfig, backend_name: str +) -> dict[str, Any]: + """Return backend-specific options for the selected adapter.""" + if backend_name == "rocq": + return { + "index_imported_libraries": config.enable_context_search, + } + return {} + + +def _theorem_name( + backend_name: str, source: Path, requested: str | None +) -> str: + if requested: + return requested + if backend_name == "rocq": + return discover_theorem_name(source) + raise ValueError(f"--theorem is required for backend {backend_name}") + + +async def initialize_components( + args: argparse.Namespace, + config: ProofAgentConfig, + logger, + *, + backend_builder: Callable[..., ProverBackend] = create_backend, + context_manager_factory=ContextManager, +) -> Dict[str, Any]: + """Construct and open all components, closing on partial failure.""" + source = Path(args.proof_file).resolve() + backend_name = args.backend or config.backend.name + default_workspace = source.parent + workspace = Path( + args.workspace or config.coq.workspace or default_workspace + ).resolve() + backend = backend_builder( + backend_name, + timeout=config.coq.timeout, + library_paths=_configured_library_paths(args, config), + coqproject_extra_options=_configured_options(args, config), + workspace=workspace, + options=_backend_options(args, config, backend_name), + ) try: - # Process library paths from command line - library_paths = getattr(config.coq, 'library_paths', []) - if args.library_path: - for path, name in args.library_path: - library_paths.append({"path": path, "name": name}) - logger.info(f" Added library path from CLI: {path} -> {name}") - - # Process extra CoqProject options from command line - coqproject_extra_options = getattr(config.coq, 'coqproject_extra_options', []) - if args.coqproject_option: - coqproject_extra_options.extend(args.coqproject_option) - logger.info(f" Added CoqProject options from CLI: {args.coqproject_option}") - - # Set workspace - use file directory if not specified - workspace = args.workspace or getattr(config.coq, 'workspace', None) - if not workspace: - workspace = str(Path(args.proof_file).parent) - logger.info(f" Using file directory as workspace: {workspace}") - - # Initialize Coq interface with library support - if len(library_paths) > 0: - logger.info(f"📚 Configuring libraries:") - for lib in library_paths: - logger.info(f" - {lib['name']}: {lib['path']}") - logger.info(f"🔧 Auto setup CoqProject: {getattr(config.coq, 'auto_setup_coqproject', True)}") - - # If hammer is enabled, add hammer library import to proof file - if config.enable_hammer: - logger.info("🔧 Hammer enabled. Importing hammer library...") - with open(args.proof_file, 'r', encoding='utf-8') as f: - content = f.read() - if "From Hammer Require Import Hammer." not in content: - with open(args.proof_file, 'w', encoding='utf-8') as f: - f.write("From Hammer Require Import Hammer.\nFrom Hammer Require Import Tactics.\n\n" + content) - else: - logger.debug("🔧 Hammer already imported - skipping") - - coq_interface = CoqInterface( - file_path=args.proof_file, - workspace=workspace, - library_paths=library_paths, - auto_setup_coqproject=getattr(config.coq, 'auto_setup_coqproject', True), - coqproject_extra_options=coqproject_extra_options, - timeout=getattr(config.coq, 'timeout', 60) + theorem_name = _theorem_name(backend_name, source, args.theorem) + theorem = TheoremIdentity( + SourceLocation(source, workspace), theorem_name ) - - # Load the file using proper method - success = coq_interface.load() - if not success: - error_msg = coq_interface.get_last_error() or "unknown error" - logger.error(f"❌ {error_msg}") - raise Exception(f"Failed to load Coq file: {error_msg}") - - logger.info("✅ File loaded successfully") - - # Get proof status to verify loading - status = coq_interface.get_proof_status() - logger.info(f"📊 Proof status: loaded={status.get('has_proof')}, steps={status.get('proof_steps')}") - - if not status.get("has_proof", False): - logger.warning("⚠️ No proof found in file - this may be expected for some files") - else: - logger.info(f"✅ Found proof with {status['proof_steps']} initial steps") - - # Setup history file path + initial_state = await backend.open(theorem) history_file = Path("data") / "tactic_history.json" history_file.parent.mkdir(exist_ok=True) - - # Setup plan file path - plan_file = Path(args.plan) if args.plan else None - proof_plan = None - if plan_file and plan_file.exists(): - logger.info(f"📄 Using proof plan from: {plan_file}") - with open(plan_file, 'r', encoding='utf-8') as f: - proof_plan = f.read() - - # Initialize tactic generator with history - logger.info("Initializing tactic generator...") - context_manager = ContextManager( - coq_interface=coq_interface, + proof_plan = ( + Path(args.plan).read_text(encoding="utf-8") if args.plan else None + ) + context_manager = context_manager_factory( + backend=backend, + initial_state=initial_state, model=config.llm.model, temperature=config.llm.temperature, - api_key=getattr(config.llm, 'api_key', None), - api_base=getattr(config.llm, 'api_base', None), - max_tokens=getattr(config.llm, 'max_tokens', 2000), - timeout=getattr(config.llm, 'timeout', 30), + reasoning_effort=config.llm.reasoning_effort, + api_key=config.llm.api_key, + api_base=config.llm.api_base, + max_tokens=config.llm.max_tokens, + timeout=config.llm.timeout, history_file=str(history_file), enable_history_context=config.enable_history_context, enable_context_search=config.enable_context_search, enable_rollback=config.enable_rollback, enable_helper_lemma=config.enable_helper_lemma, - enable_caching=getattr(config.llm, 'enable_caching', True), + enable_caching=config.llm.enable_caching, proof_plan=proof_plan, - enable_local_session_caching=args.local_session_caching + enable_local_session_caching=args.local_session_caching, + backend_name=backend_name, + max_state_chars=config.max_state_chars, + max_cost_usd=config.llm.max_cost_usd, ) - - # Initialize controller with history - logger.info("Initializing proof controller...") controller = ProofController( - coq_interface=coq_interface, + backend=backend, + initial_state=initial_state, context_manager=context_manager, max_steps=config.coq.max_steps, max_errors=config.max_errors, @@ -279,523 +196,219 @@ def initialize_components(args, config: ProofAgentConfig, logger) -> Dict[str, A enable_hammer=config.enable_hammer, max_context_search=config.max_context_search, history_file=str(history_file), - interactive=config.interactive + interactive=config.interactive, + ) + logger.info( + "Opened %s with backend %s", theorem_name, backend_name ) - return { - "coq_interface": coq_interface, + "backend": backend, "context_manager": context_manager, - "coq_chat_session": context_manager.chat_session, - "controller": controller + "chat_session": context_manager.chat_session, + "controller": controller, + "initial_state": initial_state, } - - except Exception as e: - logger.error(f"Failed to initialize components: {e}") - import traceback - traceback.print_exc() - # main()'s cleanup only sees the components dict we never returned, so - # close the coq-lsp subprocess here or the process hangs. - if coq_interface is not None: - logger.info("Closing Coq interface after failed initialization...") - try: - coq_interface.close() - except Exception as close_error: - logger.warning(f"Error closing Coq interface: {close_error}") - coq_interface.force_close() + except BaseException: + await backend.close() raise -def print_initial_state(components: Dict[str, Any], logger): - """Print initial proof state information.""" - coq = components["coq_interface"] - - logger.info("=== Initial Proof State ===") - - # Get proof status first - status = coq.get_proof_status() - logger.info(f"📊 Proof status: loaded={status.get('has_proof')}, steps={status.get('proof_steps')}") - - if not status.get("has_proof", False): - logger.info("No proof available in file") - return - - # Check if we have an active proof - unproven_proof = coq.get_unproven_proof() - if not unproven_proof: - logger.info("No unproven proof available") - return - - logger.info(f"Unproven proof found with {len(unproven_proof.steps)} steps") - - # Print hypotheses - try: - hypotheses = coq.get_hypothesis() - if hypotheses: - logger.info(f"Hypotheses: {hypotheses}") - else: - logger.info("No hypotheses") - except Exception as e: - logger.warning(f"Could not retrieve hypotheses: {e}") - - # Print proof steps so far - logger.info("Current proof steps:") - try: - if coq.proof and coq.proof.steps: - for i, step in enumerate(coq.proof.steps): - logger.info(f" {i+1}: {step.text.strip()}") - else: - logger.info(" No steps available") - except Exception as e: - logger.warning(f"Could not print proof steps: {e}") - - # Print context information - logger.info("Available context terms:") - try: - terms = coq.get_context_terms() - logger.info(f"Found {len(terms)} context terms") - except Exception as e: - logger.warning(f"Could not retrieve context terms: {e}") - -@contextmanager -def timeout_context(seconds): - """Context manager for timeout operations.""" - def timeout_handler(signum, frame): - raise TimeoutError(f"Operation timed out after {seconds} seconds") - - old_handler = signal.signal(signal.SIGALRM, timeout_handler) - signal.alarm(seconds) - - try: - yield - finally: - signal.alarm(0) - signal.signal(signal.SIGALRM, old_handler) - -def cleanup_components(components: Dict[str, Any], logger): - """Clean up resources with timeout protection and forceful termination.""" - try: - logger.info("Cleaning up resources...") - - # Save tactic history first (most important) with timeout - controller = components.get("controller") - if controller and hasattr(controller, 'tactic_history'): - try: - logger.info("Saving tactic history...") - with timeout_context(3): # 3 second timeout - controller.tactic_history.save_history() - logger.info("Tactic history saved successfully") - except TimeoutError: - logger.warning("Tactic history save timed out") - except Exception as e: - logger.warning(f"Failed to save tactic history: {e}") - - # Close Coq interface with timeout - coq = components.get("coq_interface") - if coq and hasattr(coq, 'close'): - try: - logger.info("Closing Coq interface...") - with timeout_context(2): # 2 second timeout - coq.close() - logger.info("Coq interface closed successfully") - except TimeoutError: - coq.force_close() - logger.warning("Coq interface close timed out; force closing") - except Exception as e: - logger.warning(f"Error closing Coq interface: {e}") - - # Reset other components quickly - for name, component in components.items(): - if name in ['coq_interface', 'controller']: - continue - if hasattr(component, 'reset'): - try: - with timeout_context(1): - component.reset() - logger.debug(f"Reset component: {name}") - except: - logger.warning(f"Failed to reset {name}") - - except Exception as e: - logger.error(f"Critical error during cleanup: {e}") - finally: - logger.info("Cleanup completed") - - -def ensure_proof_admitted(file_path: str, logger) -> bool: - """ - In interactive mode: if the last proof block has no Admitted./Qed., - append Admitted. so CoqPyt can register it as an unproven proof. - Does NOT remove any existing tactics. - """ - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - import re - proof_matches = list(re.finditer(r'Proof\s*\.', content, re.IGNORECASE)) - if not proof_matches: - logger.warning("⚠️ No 'Proof.' found in file") - return False - - last_proof_end = proof_matches[-1].end() - tail = content[last_proof_end:] - - if re.search(r'\b(Qed|Admitted)\s*\.', tail, re.IGNORECASE): - logger.debug("Proof already closed — no changes needed") - return True - - logger.info("📝 Open proof detected — appending 'Admitted.' for CoqPyt") - with open(file_path, 'a', encoding='utf-8') as f: - f.write("\nAdmitted.") - return True - - except Exception as e: - logger.error(f"❌ ensure_proof_admitted failed: {e}") - return False +def log_startup_configuration( + args: argparse.Namespace, + config: ProofAgentConfig, + output_directory: Path, + logger, +) -> None: + """Log the effective run configuration without exposing credentials.""" + source = Path(args.proof_file).resolve() + backend_name = args.backend or config.backend.name + logger.info("=== Proof Agent Starting ===") + logger.info("Backend: %s", backend_name) + logger.info("Model: %s", config.llm.model) + logger.info("Temperature: %s", config.llm.temperature) + logger.info("LLM response caching: %s", config.llm.enable_caching) + logger.info( + "LLM cost ceiling (USD): %s", + config.llm.max_cost_usd + if config.llm.max_cost_usd is not None + else "off", + ) + logger.info("Proof file: %s", source.name) + logger.info("Full path: %s", source) + logger.info("Target theorem: %s", args.theorem or "auto-detect") + logger.info("Maximum proof steps: %s", config.coq.max_steps) + logger.info("Maximum rendered state characters: %s", config.max_state_chars) + logger.info("Context search: %s", config.enable_context_search) + logger.info("Helper lemmas: %s", config.enable_helper_lemma) + logger.info("Rollback: %s", config.enable_rollback) + logger.info("Interactive mode: %s", config.interactive.enabled) + logger.info("Output directory: %s", output_directory) + if config.coq.library_paths: + logger.info("Configured prover libraries:") + for mapping in config.coq.library_paths: + logger.info(" %s: %s", mapping["name"], mapping["path"]) + if config.coq.coqproject_extra_options: + logger.info("Additional _CoqProject options:") + for option in config.coq.coqproject_extra_options: + logger.info(" %s", option) -def clean_proof_file(file_path: str, logger) -> bool: - """ - Clean proof file by removing tactics between Proof. and Qed./Admitted. - Changes Qed to Admitted to make proof unproven. - If multiple theorems exist, cleans the last one. - """ +def log_token_statistics(components: Dict[str, Any], logger) -> None: + """Log cumulative live-model token use and estimated cost.""" + chat_session = components.get("chat_session") + if chat_session is None: + context_manager = components.get("context_manager") + chat_session = getattr(context_manager, "chat_session", None) + if chat_session is None or not hasattr(chat_session, "get_token_statistics"): + return try: - logger.info("🧹 Attempting Python-based proof file cleaning...") - - # Read the file content - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - logger.info(f"📄 Read file: {len(content)} characters") - - # Count theorems - import re - theorem_pattern = r'\b(Theorem|Lemma|Corollary|Proposition)\b' - theorems = list(re.finditer(theorem_pattern, content, re.IGNORECASE)) - - if len(theorems) == 0: - logger.warning("⚠️ No theorems found in file") - return False - - logger.info(f"✅ Found {len(theorems)} lemmas/theorems") - - # Find all Proof. statements - proof_matches = list(re.finditer(r'Proof\s*\.', content, re.IGNORECASE)) - - if not proof_matches: - logger.warning("⚠️ Could not find any 'Proof.' in file") - return False - - # Use the last Proof. (corresponding to the last theorem) - last_proof_match = proof_matches[-1] - proof_start_pos = last_proof_match.end() - logger.info(f"📋 Found last 'Proof.' at position {last_proof_match.start()}") - - # Find the ending (Qed. or Admitted.) after the last Proof. - ending_pattern = r'(Qed|Admitted)\s*\.' - ending_match = re.search(ending_pattern, content[proof_start_pos:], re.IGNORECASE) - - if ending_match: - # Found complete proof - ending_pos = proof_start_pos + ending_match.start() - tactics_content = content[proof_start_pos:ending_pos] - ending_text = ending_match.group(0) - - logger.info(f"📋 Found proof ending: '{ending_text}' at position {ending_pos}") - logger.info(f" Tactics content: {len(tactics_content.strip())} characters") - - if tactics_content.strip(): - logger.info(f" Preview: {tactics_content.strip()[:150]}{'...' if len(tactics_content.strip()) > 150 else ''}") - - # Replace everything between Proof. and ending with just newline + Admitted. - new_content = content[:proof_start_pos] + "\nAdmitted." - - # Check if there's content after the ending - preserve it - content_after_ending = content[proof_start_pos + ending_match.end():] - if content_after_ending.strip(): - logger.info(f" Preserving {len(content_after_ending.strip())} characters after ending") - new_content += content_after_ending - - ending_change = f"{ending_text} -> Admitted." - - else: - # No ending found - incomplete proof - logger.info("📋 No proof ending found (incomplete proof)") - tactics_content = content[proof_start_pos:] - - logger.info(f" Tactics content: {len(tactics_content.strip())} characters") - if tactics_content.strip(): - logger.info(f" Preview: {tactics_content.strip()[:150]}{'...' if len(tactics_content.strip()) > 150 else ''}") - - # Replace from Proof. to end with Admitted. - new_content = content[:proof_start_pos] + "\nAdmitted." - ending_change = "(incomplete) -> Admitted." - - # Create backup first - backup_path = file_path + '.backup' - with open(backup_path, 'w', encoding='utf-8') as f: - f.write(content) - logger.info(f"💾 Created backup: {backup_path}") - - # Write the cleaned content - with open(file_path, 'w', encoding='utf-8') as f: - f.write(new_content) - - logger.info("✅ Successfully cleaned proof file") - if tactics_content.strip(): - logger.info(f" Removed: {len(tactics_content.strip())} characters of tactics") - logger.info(f" Changed: {ending_change}") - logger.info(f" Result: Proof. -> Admitted.") - - return True - - except Exception as e: - logger.error(f"❌ Failed to clean proof file: {e}") - import traceback - traceback.print_exc() - return False + statistics = chat_session.get_token_statistics() + prompt_tokens = int(statistics.get("total_prompt_tokens", 0)) + completion_tokens = int(statistics.get("total_completion_tokens", 0)) + cached_tokens = int(statistics.get("total_cached_tokens", 0)) + cache_creation_tokens = int( + statistics.get("total_cache_creation_tokens", 0) + ) + total_tokens = int( + statistics.get( + "total_tokens", prompt_tokens + completion_tokens + ) + ) + logger.info("=== Token Usage Statistics ===") + logger.info("API calls: %s", statistics.get("api_calls", 0)) + logger.info("Prompt tokens: %s", f"{prompt_tokens:,}") + logger.info("Completion tokens: %s", f"{completion_tokens:,}") + logger.info("Cached tokens: %s", f"{cached_tokens:,}") + logger.info("Cache-write tokens: %s", f"{cache_creation_tokens:,}") + logger.info("Total tokens: %s", f"{total_tokens:,}") + logger.info( + "Estimated cost (USD): $%.4f", + float(statistics.get("total_cost_usd", 0.0)), + ) + if prompt_tokens: + logger.info( + "Cache hit rate: %.1f%%", + cached_tokens / prompt_tokens * 100, + ) + context_manager = components.get("context_manager") + mitigation_stats = getattr( + context_manager, "mitigation_statistics", lambda: {} + )() + if mitigation_stats: + logger.info( + "MITIGATION R1 summary elisions=%d elided_chars=%d " + "queries_after_elision=%d", + mitigation_stats.get("state_elisions", 0), + mitigation_stats.get("state_elided_chars", 0), + mitigation_stats.get("queries_after_elision", 0), + ) + except Exception as error: + logger.warning("Could not retrieve token statistics: %s", error) + + +def print_initial_state(components: Dict[str, Any], logger) -> None: + controller = components["controller"] + logger.info("Initial goals:\n%s", controller.goals() or "(complete)") + hypotheses = controller.hypotheses() + if hypotheses: + logger.info("Initial hypotheses:\n%s", hypotheses) + + +async def cleanup_components( + components: Dict[str, Any], logger +) -> None: + """Save agent history and release backend resources.""" + controller = components.get("controller") + if controller and controller.tactic_history: + try: + controller.tactic_history.save_history() + except Exception as error: + logger.warning("Could not save tactic history: %s", error) + backend = components.get("backend") + if backend is not None: + try: + await backend.close() + except Exception as error: + logger.warning("Could not close backend: %s", error) + + +def _setup_output_directory( + configured: str | None, source: Path +) -> Path: + output = ( + Path(configured) + if configured + else source.parent + / f"autorocq-{datetime.now().strftime('%Y%m%d-%H%M%S')}" + ) + output.mkdir(parents=True, exist_ok=True) + return output -def main(): - """Main entry point with history management.""" - - global components, logger, exit_code - components = {} - logger = None - exit_code = 1 - - def signal_handler(signum, frame): - sig_name = signal.Signals(signum).name - print(f"\n⚠️ Received {sig_name} signal - initiating cleanup...") - - if components and logger: - cleanup_components(components, logger) - - sys.exit(128 + signum) - - # Register signal handlers - signal.signal(signal.SIGINT, signal_handler) # Ctrl+C - signal.signal(signal.SIGTERM, signal_handler) # kill command - - # Parse arguments - args = parse_arguments() - - # Load configuration FIRST (before validation) - print(f"🔧 Loading configuration...") - if args.config: - print(f" Using config file: {args.config}") - config = load_config(args.config) - else: - print(f" Using default configuration") - config = load_config() - - # NOW validate arguments with config context - if not validate_arguments(args, config): - sys.exit(1) - - # Setup output directory - output_dir = setup_output_directory(config.output_dir) - - # Use absolute path - args.proof_file = str(Path(args.proof_file).resolve()) - - # Update proof file path in config for logging/tracking - config.coq.proof_file_path = args.proof_file - - # Override config with command line arguments ONLY if they were explicitly provided +async def run_cli( + args: argparse.Namespace, + config: ProofAgentConfig, +) -> int: + source = Path(args.proof_file).resolve() + output_directory = _setup_output_directory(config.output_dir, source) + config.coq.proof_file_path = str(source) config.coq.max_steps = args.max_steps or config.coq.max_steps config.log_level = args.log_level or config.log_level - if not config.log_file: - config.log_file = str(output_dir / "autorocq.log") - - # Clear existing log file - if config.log_file: - log_path = Path(config.log_file) - if log_path.exists(): - log_path.unlink() - - # Setup logging - global_logger(config.log_level, config.log_file, True) - logger = setup_logger(name="Main") - - logger.info(f"🔧 Log level: {config.log_level}") - logger.info(f"🔧 Logging to: {config.log_file}") - logger.info("=== Proof Agent Starting ===") - logger.info("🗑️ Log file cleared - starting fresh session") - - # ADD MODEL LOGGING HERE - right after the starting message - llm_model = getattr(config.llm, 'model', 'unknown') - llm_temperature = getattr(config.llm, 'temperature', 'unknown') - logger.info(f"🤖 LLM Configuration:") - logger.info(f" Model: {llm_model}") - logger.info(f" Temperature: {llm_temperature}") - logger.info(f" Caching: {config.llm.enable_caching}") - - # Enhanced proof file logging with full path info - proof_file_path = Path(config.coq.proof_file_path) - logger.info(f"📄 Proof File Information:") - logger.info(f" File: {proof_file_path.name}") - logger.info(f" Full Path: {config.coq.proof_file_path}") - logger.info(f" Directory: {proof_file_path.parent}") - - logger.info(f"🎯 Target Theorem: {args.theorem or 'auto-detect'}") - logger.info(f"🔧 Configuration:") - logger.info(f" Max steps: {config.coq.max_steps}") - logger.info(f" Context search: {config.enable_context_search}") - logger.info(f" Helper lemmas: {config.enable_helper_lemma}") - logger.info(f" Output directory: {output_dir}") - - # Log library configuration if present - if hasattr(config.coq, 'library_paths') and config.coq.library_paths: - logger.info("📚 Custom libraries configured:") - for lib_config in config.coq.library_paths: - logger.info(f" - {lib_config['name']}: {lib_config['path']}") - - if hasattr(config.coq, 'coqproject_extra_options') and config.coq.coqproject_extra_options: - logger.info("⚙️ Extra CoqProject options:") - for option in config.coq.coqproject_extra_options: - logger.info(f" - {option}") - - # --interactive flag overrides config + if args.backend: + config.backend.name = args.backend if args.interactive: config.interactive.enabled = True - - # Clean proof by removing existing tactics. Skip in interactive mode - if config.interactive.enabled: - logger.debug("🤝 Interactive mode enabled - preserving existing proof tactics") - clean_success = ensure_proof_admitted(args.proof_file, logger) - else: - logger.debug("🧹 Pre-cleaning proof file to ensure unproven state...") - clean_success = clean_proof_file(args.proof_file, logger) - if not clean_success: - logger.warning("⚠️ Could not clean proof file - will try CoqInterface methods later") - - # Initialize components + if not config.log_file: + config.log_file = str(output_directory / "autorocq.log") + global_logger(config.log_level, config.log_file, True) + logger = setup_logger("Main") + components: Dict[str, Any] = {} + exit_code = 1 + log_startup_configuration(args, config, output_directory, logger) try: - components = initialize_components(args, config, logger) # Pass both args and config - logger.info("✅ Components initialized successfully") - - coq_chat_session = components["coq_chat_session"] - logger.info(f"✅ Coq chat session initialized: {coq_chat_session.model}") - - # Log final proof file verification - coq_interface = components["coq_interface"] - logger.info(f"✅ Coq interface loaded: {coq_interface.file_path}") - - if not clean_success and not config.interactive.enabled: - logger.info("🧹 Attempting CoqInterface-based clearing as backup...") - try: - proof_status = coq_interface.get_proof_completion_status() - if proof_status.get('is_complete', False): - success = coq_interface.clear_proof_tactics() - if success: - logger.info("✅ CoqInterface clearing successful") - else: - logger.warning("⚠️ CoqInterface clearing failed") - else: - success = coq_interface.clear_unproven_proof_steps() - if success: - logger.info("✅ Cleared unproven steps") - except Exception as backup_error: - logger.warning(f"⚠️ Backup clearing failed: {backup_error}") - - # Verify we now have an unproven proof to work with - logger.info("🔍 Verifying proof state after cleaning...") - proof_ready = False - try: - unproven_proof = coq_interface.get_unproven_proof() - if unproven_proof: - logger.info(f"✅ Found unproven proof with {len(unproven_proof.steps)} steps") - - # Check if we have goals to prove - goals = coq_interface.get_goal_str() - if goals and goals != "No current goals" and goals.strip(): - logger.info(f"✅ Found goals to prove") - proof_ready = True - else: - logger.warning("⚠️ No current goals found") - else: - logger.error("❌ No unproven proof available after cleaning") - - except Exception as verify_error: - logger.error(f"❌ Error verifying proof state: {verify_error}") - - # Exit gracefully if no proof is ready - if not proof_ready: - logger.error("❌ Cannot proceed - no unproven proof with goals available") - logger.error(" Please check the .v file structure") - logger.error(" File should contain: Theorem name : statement. Proof. Qed.") - exit_code = 1 - return # Exit gracefully - + components = await initialize_components(args, config, logger) print_initial_state(components, logger) - - # Run proof attempt - logger.info("Starting proof attempt...") - logger.info("=== Starting Proof Attempt ===") - logger.info(f"Maximum steps allowed: {config.coq.max_steps}") - + controller = components["controller"] if config.interactive.enabled: - logger.info("🤝 Interactive mode enabled — entering REPL") - session = InteractiveSessionManager(components["controller"]) - result = session.start(args.theorem) + result = await InteractiveSessionManager(controller).start( + args.theorem + ) else: - result = components["controller"].prove_theorem(args.theorem) - + result = await controller.prove_theorem(args.theorem) + if result and args.save_proof is not None: + certificate = await controller.save_proof( + args.save_proof, overwrite=args.overwrite + ) + logger.info("Saved proof certificate to %s", certificate.destination) + exit_code = 0 if result else 1 if result: - logger.info("🎉 Proof completed successfully!") - exit_code = 0 + logger.info("Proof completed successfully") + elif controller.cost_budget_exhausted: + logger.warning("Cost budget exhausted") else: - logger.warning("❌ Proof incomplete") - exit_code = 1 - - # Success logs at INFO, failure at WARNING, so a quiet level showed only - # failures. Mirror the outcome when the logger stayed silent. - if logger.getEffectiveLevel() > (logging.INFO if result else logging.WARNING): - print("🎉 Proof completed successfully!" if result else "❌ Proof incomplete") - - except Exception as e: - logger.error(f"❌ Error during proof attempt: {e}") - logger.error(f"Exception details: {type(e).__name__}: {str(e)}") - import traceback - traceback.print_exc() - exit_code = 1 - + logger.warning("Proof incomplete") + return exit_code + except asyncio.CancelledError: + logger.warning("Proof run cancelled") + raise + except Exception as error: + logger.exception("Proof run failed: %s", error) + return 1 finally: + log_token_statistics(components, logger) + logger.info("Proof file processed: %s", source) + logger.info("Exit code: %s", exit_code) logger.info("=== Proof Agent Finished ===") - logger.info(f"📄 Proof file processed: {config.coq.proof_file_path}") - logger.info(f"🏁 Exit code: {exit_code}") - - # Print token statistics - try: - logger.info("=== Token Usage Statistics ===") - stats = components["coq_chat_session"].get_token_statistics() - - logger.info(f"📊 API calls: {stats['api_calls']}") - logger.info(f"📊 Total prompt tokens: {stats['total_prompt_tokens']:,}") - logger.info(f"📊 Total completion tokens: {stats['total_completion_tokens']:,}") - logger.info(f"📊 Total cached tokens (read): {stats['total_cached_tokens']:,}") - logger.info(f"📊 Total cache-write tokens: {stats.get('total_cache_creation_tokens', 0):,}") - logger.info(f"📊 Total tokens: {stats['total_tokens']:,}") - logger.info(f"📊 Estimated cost (USD): ${stats.get('total_cost_usd', 0.0):.4f}") - - # Calculate cache hit rate - if stats['total_prompt_tokens'] > 0: - cache_hit_rate = (stats['total_cached_tokens'] / stats['total_prompt_tokens']) * 100 - logger.info(f"📊 Cache hit rate: {cache_hit_rate:.1f}%") - - # Print cost to terminal if not logged - if logger.getEffectiveLevel() > logging.INFO: - print(f"📊 API calls: {stats['api_calls']}") - print(f"📊 Estimated cost (USD): ${stats.get('total_cost_usd', 0.0):.4f}") - - except Exception as e: - logger.warning(f"Could not retrieve token statistics: {e}") - - cleanup_components(components, logger) - - sys.exit(exit_code) + await cleanup_components(components, logger) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_arguments(argv) + config = load_config(args.config) if args.config else load_config() + if not validate_arguments(args, config): + return 1 + return asyncio.run(run_cli(args, config)) + if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/proof-search/tests/test_coqpyt.py b/proof-search/tests/diagnostic_coqpyt.py similarity index 99% rename from proof-search/tests/test_coqpyt.py rename to proof-search/tests/diagnostic_coqpyt.py index 5738cf1..e07b2bd 100644 --- a/proof-search/tests/test_coqpyt.py +++ b/proof-search/tests/diagnostic_coqpyt.py @@ -68,4 +68,4 @@ def clean_proof_file(file_path): [proof_file.pop_step(unproven) for _ in range(i)] proof_file.append_step(unproven, "\nAdmitted.") print("Proof attempt not valid.") - break \ No newline at end of file + break diff --git a/proof-search/tests/test_coq_interface.py b/proof-search/tests/diagnostic_coqpyt_session.py similarity index 92% rename from proof-search/tests/test_coq_interface.py rename to proof-search/tests/diagnostic_coqpyt_session.py index 8375aab..aee2b06 100644 --- a/proof-search/tests/test_coq_interface.py +++ b/proof-search/tests/diagnostic_coqpyt_session.py @@ -5,7 +5,7 @@ # Add the parent directory to Python path so we can import backend modules sys.path.insert(0, str(Path(__file__).parent.parent)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession if __name__ == "__main__": @@ -17,7 +17,7 @@ print(f"Error: Example file not found at {file_path}") sys.exit(1) - coq = CoqInterface(str(file_path)) + coq = CoqPytSession(str(file_path)) coq.load() #coq.clear_all_proof_scripts() # <--- This will clear all tactics, leaving just "Proof." #coq.proof_file.run() # Re-parse the file @@ -59,4 +59,4 @@ print("\n❌ Proof not completed.") coq.close() - print("Test completed successfully!") \ No newline at end of file + print("Test completed successfully!") diff --git a/proof-search/tests/test_coqpyt_svcomp_clean.py b/proof-search/tests/diagnostic_coqpyt_svcomp_clean.py similarity index 100% rename from proof-search/tests/test_coqpyt_svcomp_clean.py rename to proof-search/tests/diagnostic_coqpyt_svcomp_clean.py diff --git a/proof-search/tests/stub_backend.py b/proof-search/tests/stub_backend.py new file mode 100644 index 0000000..f960be2 --- /dev/null +++ b/proof-search/tests/stub_backend.py @@ -0,0 +1,139 @@ +"""Deterministic backend stub used by backend and agent unit tests.""" + +from pathlib import Path + +from backend.prover_backend import ( + BackendCapabilities, Checkpoint, CommandKind, CommandRejectedError, CommandResult, + ContextEntry, FeedbackSeverity, Goal, GoalId, InvalidCheckpointError, InvalidLifecycleError, + HelperLemmaCommands, HelperLemmaSpec, LifecycleState, ProofState, ProverBackend, ProverBackendError, + ProverFeedback, ProvingContext, QueryResult, SavedProofCertificate, + TheoremIdentity, +) + + +class StubProverBackend(ProverBackend): + """A scripted prover for one theorem with predictable branching.""" + + _capabilities = BackendCapabilities() + + def __init__(self) -> None: + self._lifecycle = LifecycleState.CREATED + self._backend_token = object() + self._session_token: object | None = None + self._state: ProofState | None = None + self._commands: list[str] = [] + self._snapshots: dict[int, tuple[ProofState, tuple[str, ...]]] = {} + + @property + def lifecycle(self) -> LifecycleState: + return self._lifecycle + + @property + def capabilities(self) -> BackendCapabilities: + return self._capabilities + + def _require(self, operation: str, *expected: LifecycleState) -> None: + if self._lifecycle not in expected: + raise InvalidLifecycleError(operation, self._lifecycle, expected) + + async def open(self, theorem: TheoremIdentity) -> ProofState: + self._require("open", LifecycleState.CREATED) + if theorem.name != "demo_theorem": + raise ProverBackendError(f"unknown theorem: {theorem.name}") + context = ProvingContext( + global_entries=(ContextEntry("and_comm", "A /\\ B -> B /\\ A"),), + local_entries=(ContextEntry("P", "Prop"), ContextEntry("Q", "Prop")), + ) + self._state = ProofState( + theorem, (Goal(GoalId("g0"), "P /\\ Q -> Q /\\ P"),), context, 0 + ) + self._session_token = object() + self._lifecycle = LifecycleState.OPEN + return self._state + + async def state(self) -> ProofState: + self._require("state", LifecycleState.OPEN, LifecycleState.COMPLETE) + assert self._state is not None + return self._state + + async def apply(self, command: str) -> CommandResult: + self._require("apply", LifecycleState.OPEN) + before = await self.state() + transitions = { + (0, "intro H."): (Goal(GoalId("g1"), "Q /\\ P", (ContextEntry("H", "P /\\ Q"),)),), + (1, "split."): (Goal(GoalId("g2"), "Q"), Goal(GoalId("g3"), "P")), + (2, "exact H.2."): (Goal(GoalId("g3"), "P"),), + (3, "exact H.1."): (), + } + goals = transitions.get((before.revision, command)) + if goals is None: + feedback = (ProverFeedback("scripted tactic does not apply", FeedbackSeverity.ERROR, "STUB001"),) + raise CommandRejectedError(command, before, feedback) + self._commands.append(command) + self._state = ProofState(before.theorem, goals, before.context, before.revision + 1) + if self._state.is_complete: + self._lifecycle = LifecycleState.COMPLETE + return CommandResult(command, self._state) + + def classify_command(self, command: str) -> CommandKind: + normalized = command.strip().lower().removesuffix(".") + if normalized == "abort": + return CommandKind.ABORT + if normalized in {"admit", "admitted"}: + return CommandKind.UNSOUND_COMPLETION + if normalized in {"{", "}"}: + return CommandKind.STRUCTURAL + return CommandKind.PROOF_STEP + + def automation_command(self) -> str | None: + return None + + async def checkpoint(self) -> Checkpoint: + self._require("checkpoint", LifecycleState.OPEN, LifecycleState.COMPLETE) + state = await self.state() + self._snapshots[state.revision] = (state, tuple(self._commands)) + return Checkpoint(self._backend_token, self._session_token, state.revision) + + async def rollback(self, checkpoint: Checkpoint) -> ProofState: + self._require("rollback", LifecycleState.OPEN, LifecycleState.COMPLETE) + if (checkpoint._backend_token is not self._backend_token or + checkpoint._session_token is not self._session_token or + checkpoint._payload not in self._snapshots): + raise InvalidCheckpointError("checkpoint is not valid for this session") + self._state, commands = self._snapshots[checkpoint._payload] + self._commands = list(commands) + self._lifecycle = LifecycleState.OPEN if self._state.goals else LifecycleState.COMPLETE + return self._state + + def helper_lemma_commands( + self, spec: HelperLemmaSpec + ) -> HelperLemmaCommands: + return HelperLemmaCommands( + f"assert ({spec.name}: {spec.statement})", "{", "}" + ) + + async def query(self, command: str) -> QueryResult: + self._require("query", LifecycleState.OPEN, LifecycleState.COMPLETE) + outputs = {"Check and_comm.": "and_comm : A /\\ B -> B /\\ A"} + if command not in outputs: + state = await self.state() + raise CommandRejectedError(command, state, (ProverFeedback("unknown query", FeedbackSeverity.ERROR),)) + return QueryResult(command, outputs[command]) + + async def save_proof(self, destination: Path, *, overwrite: bool = False + ) -> SavedProofCertificate: + self._require("save_proof", LifecycleState.COMPLETE) + if destination.exists() and not overwrite: + raise FileExistsError(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(f".{destination.name}.tmp") + temporary.write_text("\n".join(self._commands) + "\n", encoding="utf-8") + temporary.replace(destination) + assert self._state is not None + return SavedProofCertificate(self._state.theorem, destination, "stub-script", tuple(self._commands)) + + async def close(self) -> None: + self._state = None + self._snapshots.clear() + self._session_token = None + self._lifecycle = LifecycleState.CLOSED diff --git a/proof-search/tests/test_agent_ntp4vc_smoke.py b/proof-search/tests/test_agent_ntp4vc_smoke.py new file mode 100644 index 0000000..e897478 --- /dev/null +++ b/proof-search/tests/test_agent_ntp4vc_smoke.py @@ -0,0 +1,200 @@ +"""Deterministic NTP4VC Rocq smoke test through the proof agent.""" + +import asyncio +import os +import shutil +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from agent.context_manager import ContextManager +from agent.proof_controller import ProofController +from backend.rocq.backend import CoqLibraryPath, CoqPytBackend +from backend.prover_backend import SourceLocation, TheoremIdentity + +pytestmark = pytest.mark.integration + + +def run(coroutine): + return asyncio.run(coroutine) + + +class HistoryDouble: + def add_successful_tactic(self, **_record): + return None + + def get_similar_history(self, _goal, n=5): + return [] + + def get_similar_helper_lemmas(self, _goal, n=3): + return [] + + def find_helper_lemma_proof(self, _statement): + return None + + +class ExactIContext: + def __init__(self, backend, state): + self.backend = backend + self.backend_state = state + self.chat_session = SimpleNamespace(messages=[], current_plan="") + self.tactic_history = HistoryDouble() + self.enable_history_context = False + self.enable_context_search = False + + def build_initial_prompt(self, _tree): + return "prove True deterministically" + + def get_action(self, *_args, **_kwargs): + return {"type": "tactic", "content": "exact I."}, "deterministic" + + def get_tactic(self, content, _tool_call_id): + return content + + def get_similar_history(self, _goal, n=5): + return [] + + +def test_ntp4vc_g4_runs_through_agent_without_model_call(tmp_path): + if shutil.which("coq-lsp") is None or shutil.which("coqc") is None: + pytest.skip("coq-lsp and coqc are required") + root = Path("/workspace/NTP4VC") + source_directory = ( + root / "data/why3/pearl/tests/func_literals_vcg/rocq" + ) + source = source_directory / "func_literals_Top_g4.v" + why3_library = root / "_build/default/generation/rocq/Why3" + if not source.is_file() or not (why3_library / "Base.vo").is_file(): + pytest.skip("local NTP4VC case and built Why3 library are required") + + original = source.read_text(encoding="utf-8") + theorem = TheoremIdentity( + SourceLocation(source, source_directory), "g4" + ) + mappings = ( + CoqLibraryPath(why3_library, "Why3"), + CoqLibraryPath(source_directory, "tests.func_literals_vcg"), + ) + backend = CoqPytBackend(timeout=60, library_paths=mappings) + try: + state = run(backend.open(theorem)) + context = ExactIContext(backend, state) + controller = ProofController( + backend, + state, + context, + max_steps=2, + enable_recording=False, + history_file=str(tmp_path / "history.json"), + ) + controller._finish_proof = lambda _states: None + + assert run(controller.prove_theorem("g4")) + assert controller.successful_tactics == ["exact I."] + + certificate = run(controller.save_proof(tmp_path / source.name)) + subprocess.run( + [ + "coqc", + "-R", + str(why3_library), + "Why3", + "-R", + str(source_directory), + "tests.func_literals_vcg", + certificate.destination.name, + ], + check=True, + cwd=certificate.destination.parent, + ) + assert source.read_text(encoding="utf-8") == original + finally: + run(backend.close()) + + +@pytest.mark.live_api +def test_ntp4vc_g4_with_single_live_model_request(tmp_path): + if os.environ.get("LEMMANET_RUN_LIVE_API") != "1": + pytest.skip("set LEMMANET_RUN_LIVE_API=1 to authorize a paid API request") + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + pytest.skip("OPENAI_API_KEY is required for the live model test") + if shutil.which("coq-lsp") is None or shutil.which("coqc") is None: + pytest.skip("coq-lsp and coqc are required") + + root = Path("/workspace/NTP4VC") + source_directory = ( + root / "data/why3/pearl/tests/func_literals_vcg/rocq" + ) + source = source_directory / "func_literals_Top_g4.v" + why3_library = root / "_build/default/generation/rocq/Why3" + if not source.is_file() or not (why3_library / "Base.vo").is_file(): + pytest.skip("local NTP4VC case and built Why3 library are required") + + original = source.read_text(encoding="utf-8") + theorem = TheoremIdentity( + SourceLocation(source, source_directory), "g4" + ) + mappings = ( + CoqLibraryPath(why3_library, "Why3"), + CoqLibraryPath(source_directory, "tests.func_literals_vcg"), + ) + backend = CoqPytBackend(timeout=60, library_paths=mappings) + try: + state = run(backend.open(theorem)) + context = ContextManager( + backend=backend, + initial_state=state, + model=os.environ.get( + "LEMMANET_LIVE_MODEL", "openai/gpt-5.2-2025-12-11" + ), + temperature=0, + api_key=api_key, + max_tokens=256, + timeout=120, + history_file=str(tmp_path / "live_history.json"), + enable_history_context=False, + enable_context_search=False, + enable_rollback=False, + enable_helper_lemma=False, + enable_caching=False, + proof_plan=( + "The current theorem is True. Immediately call the tactic " + "tool with exact I. Do not plan or explain." + ), + ) + context.chat_session.tools = [context.chat_session.TACTIC_TOOL] + controller = ProofController( + backend, + state, + context, + max_steps=1, + enable_recording=False, + history_file=str(tmp_path / "live_history.json"), + ) + controller._finish_proof = lambda _states: None + + assert run(controller.prove_theorem("g4")) + assert context.chat_session.api_call_count == 1 + assert controller.successful_tactics + + certificate = run(controller.save_proof(tmp_path / source.name)) + subprocess.run( + [ + "coqc", + "-R", + str(why3_library), + "Why3", + "-R", + str(source_directory), + "tests.func_literals_vcg", + certificate.destination.name, + ], + check=True, + cwd=certificate.destination.parent, + ) + assert source.read_text(encoding="utf-8") == original + finally: + run(backend.close()) diff --git a/proof-search/tests/test_agent_rocq_workflow.py b/proof-search/tests/test_agent_rocq_workflow.py new file mode 100644 index 0000000..7b4e703 --- /dev/null +++ b/proof-search/tests/test_agent_rocq_workflow.py @@ -0,0 +1,96 @@ +"""Real Rocq workflow through the migrated proof-agent entry point.""" + +import asyncio +import shutil +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from agent.proof_controller import ProofController +from backend.rocq.backend import CoqPytBackend +from backend.prover_backend import SourceLocation, TheoremIdentity + +pytestmark = pytest.mark.integration + + +def run(coroutine): + return asyncio.run(coroutine) + + +class HistoryDouble: + def add_successful_tactic(self, **_record): + return None + + def get_similar_history(self, _goal, n=5): + return [] + + def get_similar_helper_lemmas(self, _goal, n=3): + return [] + + def find_helper_lemma_proof(self, _statement): + return None + + +class DeterministicContext: + def __init__(self, backend, state): + self.backend = backend + self.backend_state = state + self.chat_session = SimpleNamespace(messages=[], current_plan="") + self.tactic_history = HistoryDouble() + self.enable_history_context = False + self.enable_context_search = False + self._commands = iter(("intros b; destruct b; reflexivity.",)) + + def build_initial_prompt(self, _tree): + return "prove the existing workflow" + + def get_action(self, *_args, **_kwargs): + return {"type": "tactic", "content": next(self._commands)}, "call" + + def get_tactic(self, content, _tool_call_id): + return content + + def get_similar_history(self, _goal, n=5): + return [] + + +def test_existing_rocq_workflow_runs_through_agent(tmp_path): + if shutil.which("coq-lsp") is None or shutil.which("coqc") is None: + pytest.skip("coq-lsp and coqc are required") + source = ( + Path(__file__).parent.parent + / "examples" + / "example.v" + ).resolve() + original = source.read_text(encoding="utf-8") + theorem = TheoremIdentity( + SourceLocation(source, source.parent), "orb_true_l" + ) + backend = CoqPytBackend(timeout=60) + try: + state = run(backend.open(theorem)) + context = DeterministicContext(backend, state) + controller = ProofController( + backend, + state, + context, + max_steps=4, + enable_recording=False, + history_file=str(tmp_path / "history.json"), + ) + controller._finish_proof = lambda _states: None + + assert run(controller.prove_theorem("orb_true_l")) + assert controller.successful_tactics == ["intros b; destruct b; reflexivity."] + certificate = run(controller.save_proof(tmp_path / source.name)) + assert certificate.commands[-1] == "Qed." + subprocess.run( + ["coqc", certificate.destination.name], + check=True, + cwd=certificate.destination.parent, + ) + assert source.read_text(encoding="utf-8") == original + finally: + run(backend.close()) diff --git a/proof-search/tests/test_cli_backend.py b/proof-search/tests/test_cli_backend.py new file mode 100644 index 0000000..ea0b463 --- /dev/null +++ b/proof-search/tests/test_cli_backend.py @@ -0,0 +1,305 @@ +"""CLI backend selection and cleanup tests.""" + +import argparse +import asyncio +import logging +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import main +from backend.rocq.backend import CoqPytBackend +from backend.factory import UnknownBackendError, create_backend +from tests.stub_backend import StubProverBackend +from backend.prover_backend import LifecycleState +from utils.config import ProofAgentConfig + + +def run(coroutine): + return asyncio.run(coroutine) + + +def make_args(source: Path): + return argparse.Namespace( + proof_file=str(source), + theorem="demo_theorem", + backend=None, + library_path=None, + coqproject_option=None, + lean_repl=None, + lean_project=None, + isabelle_logic=None, + isabelle_session_dir=None, + workspace=None, + plan=None, + local_session_caching=False, + interactive=False, + save_proof=None, + overwrite=False, + max_steps=None, + log_level=None, + config=None, + ) + + +class HistoryDouble: + def save_history(self): + return None + + +class ContextDouble: + def __init__(self, **options): + self.backend = options["backend"] + self.backend_state = options["initial_state"] + self.chat_session = SimpleNamespace(messages=[], current_plan="") + self.tactic_history = HistoryDouble() + self.enable_history_context = False + self.enable_context_search = False + + +def source_file(tmp_path: Path) -> Path: + source = tmp_path / "demo.v" + source.write_text( + "Lemma demo_theorem : True.\nProof. Admitted.\n", + encoding="utf-8", + ) + return source + + +def test_factory_keeps_rocq_as_default_and_forwards_project_options(tmp_path): + library = tmp_path / "library" + library.mkdir() + backend = create_backend( + "rocq", + timeout=17, + library_paths=({"path": "library", "name": "Demo"},), + coqproject_extra_options=("-arg -w",), + workspace=tmp_path, + ) + assert isinstance(backend, CoqPytBackend) + assert backend._timeout == 17 + assert backend._library_paths[0].path == library.resolve() + assert backend._coqproject_extra_options == ("-arg -w",) + + +def test_factory_can_disable_imported_library_indexing(): + backend = create_backend( + "rocq", options={"index_imported_libraries": False} + ) + + assert backend._index_imported_libraries is False + + +def test_factory_rejects_unknown_backends(): + with pytest.raises(UnknownBackendError): + create_backend("unknown") + + +def test_initialize_and_cleanup_use_selected_backend(tmp_path): + source = source_file(tmp_path) + config = ProofAgentConfig.default() + config.enable_recording = False + captured = {} + backend = StubProverBackend() + + def builder(name, **options): + captured["name"] = name + captured.update(options) + return backend + + components = run( + main.initialize_components( + make_args(source), + config, + SimpleNamespace(info=lambda *_args: None), + backend_builder=builder, + context_manager_factory=ContextDouble, + ) + ) + assert captured["name"] == "rocq" + assert components["controller"].backend is backend + assert backend.lifecycle is LifecycleState.OPEN + + logger = SimpleNamespace(warning=lambda *_args: None) + run(main.cleanup_components(components, logger)) + assert backend.lifecycle is LifecycleState.CLOSED + + +def test_partial_construction_failure_closes_backend(tmp_path): + source = source_file(tmp_path) + config = ProofAgentConfig.default() + backend = StubProverBackend() + + def builder(_name, **_options): + return backend + + def fail_context(**_options): + raise RuntimeError("construction failed") + + with pytest.raises(RuntimeError): + run( + main.initialize_components( + make_args(source), + config, + SimpleNamespace(info=lambda *_args: None), + backend_builder=builder, + context_manager_factory=fail_context, + ) + ) + assert backend.lifecycle is LifecycleState.CLOSED + + +def test_cli_exposes_only_implemented_backend_choices(): + assert main.parse_arguments(["demo.v", "--backend", "rocq"]).backend == "rocq" + for unavailable in ("lean", "isabelle", "nonsense"): + with pytest.raises(SystemExit): + main.parse_arguments(["demo.v", "--backend", unavailable]) + + +def test_cancellation_releases_backend(tmp_path, monkeypatch): + source = source_file(tmp_path) + config = ProofAgentConfig.default() + config.enable_recording = False + config.output_dir = str(tmp_path / "output") + config.log_file = str(tmp_path / "cancel.log") + backend = StubProverBackend() + identity = main.TheoremIdentity( + main.SourceLocation(source, tmp_path), "demo_theorem" + ) + run(backend.open(identity)) + + class ControllerDouble: + tactic_history = HistoryDouble() + + async def prove_theorem(self, _name): + await asyncio.Event().wait() + + async def initialize(*_args, **_kwargs): + return { + "backend": backend, + "controller": ControllerDouble(), + "chat_session": SimpleNamespace(), + } + + monkeypatch.setattr(main, "initialize_components", initialize) + monkeypatch.setattr(main, "print_initial_state", lambda *_args: None) + summary_calls = [] + monkeypatch.setattr( + main, + "log_token_statistics", + lambda components, _logger: summary_calls.append(components), + ) + + async def scenario(): + task = asyncio.create_task( + main.run_cli(make_args(source), config) + ) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + run(scenario()) + assert backend.lifecycle is LifecycleState.CLOSED + assert summary_calls + + +class RecordingLogger: + def __init__(self): + self.records = [] + + def info(self, message, *args): + self.records.append(message % args if args else message) + + def warning(self, message, *args): + self.records.append(message % args if args else message) + + +class StatisticsDouble: + def get_token_statistics(self): + return { + "api_calls": 2, + "total_prompt_tokens": 120, + "total_completion_tokens": 30, + "total_cached_tokens": 40, + "total_cache_creation_tokens": 10, + "total_tokens": 150, + "total_cost_usd": 0.125, + } + + +def test_cli_logs_verbose_startup_without_credentials(tmp_path): + source = source_file(tmp_path) + args = make_args(source) + config = ProofAgentConfig.default() + config.llm.api_key = "must-not-be-logged" + config.coq.library_paths = [ + {"path": str(tmp_path / "library"), "name": "Demo"} + ] + logger = RecordingLogger() + + main.log_startup_configuration( + args, config, tmp_path / "output", logger + ) + + output = "\n".join(logger.records) + assert "Proof Agent Starting" in output + assert "Backend: rocq" in output + assert f"Full path: {source}" in output + assert "Model: openai/gpt-4.1" in output + assert "Demo" in output + assert "must-not-be-logged" not in output + + +def test_cli_logs_detailed_token_and_cost_summary(): + logger = RecordingLogger() + + main.log_token_statistics( + {"chat_session": StatisticsDouble()}, logger + ) + + output = "\n".join(logger.records) + assert "API calls: 2" in output + assert "Prompt tokens: 120" in output + assert "Completion tokens: 30" in output + assert "Cached tokens: 40" in output + assert "Cache-write tokens: 10" in output + assert "Total tokens: 150" in output + assert "Estimated cost (USD): $0.1250" in output + assert "Cache hit rate: 33.3%" in output + + +def test_backend_name_reaches_the_real_context_manager(tmp_path): + """main.py must thread the selected backend into ContextManager's prompts. + + Uses the real ContextManager (not ContextDouble) so this exercises the + exact wiring initialize_components performs, closing the loop on the + backend-aware prompting fix recorded in handoff.md. + """ + from agent.context_manager import ContextManager + + source = source_file(tmp_path) + args = make_args(source) + config = ProofAgentConfig.default() + config.enable_recording = False + backend = StubProverBackend() + + components = run( + main.initialize_components( + args, config, SimpleNamespace(info=lambda *_args: None), + backend_builder=lambda _name, **_options: backend, + context_manager_factory=ContextManager, + ) + ) + context_manager = components["controller"].context_manager + assert context_manager.chat_session.backend_name == "rocq" + assert "Rocq 9.0.0" in context_manager.chat_session.messages[0]["content"] + + prompt = context_manager.build_initial_prompt(proof_tree_str="") + assert "demo_theorem" in prompt + assert "P /\\ Q -> Q /\\ P" in prompt + + run(main.cleanup_components( + components, SimpleNamespace(warning=lambda *_args: None) + )) diff --git a/proof-search/tests/test_context_manager_backend_profiles.py b/proof-search/tests/test_context_manager_backend_profiles.py new file mode 100644 index 0000000..7311156 --- /dev/null +++ b/proof-search/tests/test_context_manager_backend_profiles.py @@ -0,0 +1,272 @@ +"""Backend-aware prompting: system prompt, tool schemas, and the initial prompt. + +Covers the live-run gap recorded in handoff.md's 2026-08-17 decisions: prompts +and tool descriptions were hardcoded to Coq regardless of the selected +backend, and the initial prompt was built by re-parsing the source file with a +Rocq-only extractor, so Lean and Isabelle runs saw "(current theorem not +found)". These tests are deterministic and make no model requests. +""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from agent.context_manager import ( + BACKEND_PROMPT_PROFILES, ContextManager, CoqChatSession, backend_prompt_profile, +) +from backend.prover_backend import ( + ContextEntry, Goal, GoalId, ProofState, ProvingContext, SourceLocation, + TheoremIdentity, +) +from tests.stub_backend import StubProverBackend + + +def tool(session: CoqChatSession, name: str) -> dict: + return next(t for t in session.tools if t["function"]["name"] == name) + + +def command_description(session: CoqChatSession, name: str) -> str: + """The nested description shown for a tool's `command` argument.""" + return tool(session, name)["function"]["parameters"]["properties"]["command"]["description"] + + +@pytest.mark.parametrize("backend_name", ["rocq", "lean", "isabelle"]) +def test_every_backend_has_a_distinct_profile(backend_name): + profile = backend_prompt_profile(backend_name) + assert profile.display_name + assert profile.tactic_examples + assert profile.query_help + assert profile.unsound_examples + assert profile.helper_lemma_native + + +def test_chat_session_stops_before_calling_model_at_cost_ceiling(monkeypatch): + session = CoqChatSession( + max_cost_usd=1.0, enable_local_session_caching=False + ) + session.total_cost = 1.01 + + def unexpected_call(**_kwargs): + raise AssertionError("model call crossed the configured cost ceiling") + + monkeypatch.setattr("agent.context_manager.litellm.completion", unexpected_call) + result = session.send_message("continue") + + assert result["error"] == "cost budget exhausted" + assert session.cost_budget_exhausted + + +def test_chat_session_logs_usage_for_each_live_model_call(monkeypatch): + session = CoqChatSession( + reasoning_effort="none", enable_local_session_caching=False + ) + usage_logs = [] + captured = {} + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace( + content=None, + tool_calls=[SimpleNamespace( + id="call-1", + function=SimpleNamespace(name="plan", arguments='{"plan": "p"}'), + )], + ))], + usage=SimpleNamespace( + prompt_tokens=123, + completion_tokens=17, + prompt_tokens_details=SimpleNamespace(cached_tokens=23), + ), + ) + def completion(**kwargs): + captured.update(kwargs) + return response + + monkeypatch.setattr("agent.context_manager.litellm.completion", completion) + monkeypatch.setattr("agent.context_manager.litellm.completion_cost", lambda **_: 0.0123) + monkeypatch.setattr(session.logger, "info", lambda message, *args: usage_logs.append(message % args)) + + result = session.send_message("continue") + + assert result["prompt_tokens"] == 123 + assert captured["reasoning_effort"] == "none" + assert usage_logs == [ + "LLM_USAGE call=1 prompt_tokens=123 completion_tokens=17 " + "cached_tokens=23 cost_usd=0.0123 cumulative_cost_usd=0.0123" + ] + + +def test_profiles_are_actually_different_across_backends(): + names = {profile.display_name for profile in BACKEND_PROMPT_PROFILES.values()} + assert len(names) == len(BACKEND_PROMPT_PROFILES) + + +def test_unknown_backend_name_falls_back_to_rocq_rather_than_raising(): + assert backend_prompt_profile("nonexistent") is backend_prompt_profile("rocq") + + +@pytest.mark.parametrize("backend_name, expected, forbidden", [ + ("rocq", "Rocq 9.0.0", ("Lean", "Isabelle")), + ("lean", "Lean 4 (4.21.0)", ("Coq", "Isabelle")), + ("isabelle", "Isabelle/HOL (Isabelle2025-2)", ("Coq", "Lean 4")), +]) +def test_system_prompt_and_tool_descriptions_name_the_right_prover( + backend_name, expected, forbidden +): + session = CoqChatSession(backend_name=backend_name, enable_local_session_caching=False) + system_prompt = session.messages[0]["content"] + tactic_description = tool(session, "tactic")["function"]["description"] + query_description = tool(session, "query")["function"]["description"] + + for text in (system_prompt, tactic_description, query_description): + assert expected in text + for other in forbidden: + assert other not in text + + +def test_query_tool_offers_the_prover_native_syntax(): + lean_query = command_description( + CoqChatSession(backend_name="lean", enable_local_session_caching=False), + "query", + ) + assert "#find" in lean_query and "#check" in lean_query + assert "Search [identifier]" not in lean_query + + isabelle_query = command_description( + CoqChatSession(backend_name="isabelle", enable_local_session_caching=False), + "query", + ) + assert "find_theorems" in isabelle_query and "thm" in isabelle_query + assert "Search [identifier]" not in isabelle_query + + +def test_lean_profile_steers_away_from_open_ended_find(): + description = command_description( + CoqChatSession(backend_name="lean", enable_local_session_caching=False), + "query", + ) + assert "Prefer exact?, apply?, and #check" in description + assert "open-ended #find" in description + assert "may be rejected" in description + + +def test_helper_lemma_tool_names_the_native_construct(): + lean_tool = tool( + CoqChatSession(backend_name="lean", enable_local_session_caching=False), + "helper_lemma", + ) + assert "have" in lean_tool["function"]["description"] + + isabelle_tool = tool( + CoqChatSession(backend_name="isabelle", enable_local_session_caching=False), + "helper_lemma", + ) + assert "subgoal_tac" in isabelle_tool["function"]["description"] + + +def test_defaulting_to_rocq_reproduces_the_original_prompt_text(): + """Every existing caller that omits backend_name must see unchanged output.""" + default_session = CoqChatSession(enable_local_session_caching=False) + rocq_session = CoqChatSession(backend_name="rocq", enable_local_session_caching=False) + assert default_session.messages[0]["content"] == rocq_session.messages[0]["content"] + + +@pytest.fixture +def initial_state() -> ProofState: + theorem = TheoremIdentity( + SourceLocation(Path("demo_theorem.src"), None), "demo_theorem" + ) + hypotheses = (ContextEntry("h", "P /\\ Q"),) + return ProofState( + theorem, (Goal(GoalId("g0"), "Q /\\ P", hypotheses),), ProvingContext(), 0 + ) + + +def test_initial_prompt_renders_the_typed_goal_not_the_source_file( + tmp_path: Path, initial_state +): + """Reproduces the live-run failure deterministically. + + The source file holds text no Rocq-syntax extractor can parse (it could + just as well be a Lean or Isabelle obligation); the fixed + `build_initial_prompt` must still render the real goal because it reads + the typed `ProofState`, never the file. + """ + source = tmp_path / "demo_theorem.src" + source.write_text("this is not Rocq syntax at all\n", encoding="utf-8") + theorem = TheoremIdentity(SourceLocation(source, tmp_path), "demo_theorem") + state = ProofState( + theorem, initial_state.goals, initial_state.context, initial_state.revision + ) + + manager = ContextManager( + backend=StubProverBackend(), + initial_state=state, + backend_name="lean", + enable_caching=False, + ) + prompt = manager.build_initial_prompt(proof_tree_str="") + + assert "Q /\\ P" in prompt + assert "h : P /\\ Q" in prompt + assert "demo_theorem" in prompt + assert "not found" not in prompt + assert "this is not Rocq syntax" not in prompt + + +def test_context_manager_threads_backend_name_to_the_chat_session(initial_state): + manager = ContextManager( + backend=StubProverBackend(), + initial_state=initial_state, + backend_name="isabelle", + enable_caching=False, + ) + assert manager.chat_session.backend_name == "isabelle" + assert "Isabelle" in manager.chat_session.messages[0]["content"] + + +def test_context_manager_defaults_to_rocq_when_backend_name_is_omitted(initial_state): + manager = ContextManager( + backend=StubProverBackend(), initial_state=initial_state, enable_caching=False, + ) + assert manager.chat_session.backend_name == "rocq" + + +def test_context_manager_records_elisions_and_following_queries( + tmp_path, caplog +): + theorem = TheoremIdentity( + SourceLocation(tmp_path / "huge.v", tmp_path), "huge" + ) + state = ProofState( + theorem, + ( + Goal( + GoalId("g0"), + "G" * 400, + (ContextEntry("huge", "H" * 400),), + ), + ), + ProvingContext(), + 0, + ) + manager = ContextManager( + backend=StubProverBackend(), + initial_state=state, + enable_caching=False, + max_state_chars=120, + history_file=str(tmp_path / "history.json"), + ) + + with caplog.at_level("INFO"): + prompt = manager.build_initial_prompt("") + assert prompt.count("use 'query' for details") == 2 + assert manager.mitigation_statistics()["state_elisions"] == 2 + + async def search(_query): + return "result", True + + manager._execute_context_search = search + asyncio.run(manager.handle_query_call("Search x", "tool-1")) + stats = manager.mitigation_statistics() + assert stats["queries_after_elision"] == 1 diff --git a/proof-search/tests/test_context_search.py b/proof-search/tests/test_context_search.py index fa4539a..b650909 100644 --- a/proof-search/tests/test_context_search.py +++ b/proof-search/tests/test_context_search.py @@ -1,454 +1,55 @@ -""" -Test script for Context Search Module with Adaptive Result Reduction -Tests full Coq command search functionality: Search/Print/Check/About/Locate/Print Assumptions -with adaptive size reduction strategies. -""" +"""Context-search reduction and backend transport tests.""" -import sys -import os -import json +import asyncio from pathlib import Path -from datetime import datetime -# Add the parent directory to the path so we can import from agent -PROJECT_ROOT = Path(__file__).parent.parent -sys.path.insert(0, str(PROJECT_ROOT)) +from agent.context_search import ContextSearch, ResultReducer +from tests.stub_backend import StubProverBackend +from backend.prover_backend import SourceLocation, TheoremIdentity -try: - from agent.context_search import ContextSearch, CoqCommandSearch, SearchResult - print("✅ Successfully imported context search modules") -except ImportError as e: - print(f"❌ Failed to import context search modules: {e}") - sys.exit(1) -try: - from backend.coq_interface import CoqInterface - REAL_COQ_AVAILABLE = True - print("✅ Real CoqInterface available") -except ImportError as e: - print(f"❌ Real CoqInterface not available: {e}") - sys.exit(1) +def run(coroutine): + return asyncio.run(coroutine) -def check_coq_setup(): - """Check if CoqInterface can be properly initialized.""" - print("\n🔧 Checking CoqInterface Setup...") - - # Check if files exist - workspace = PROJECT_ROOT / "examples" - proof_file = PROJECT_ROOT / "examples" / "main_loop_invariant_2_established_Coq.v" - lib_path = PROJECT_ROOT / "lib-sv-comp" - - print(f"📁 Workspace exists: {Path(workspace).exists()}") - print(f"📄 Proof file exists: {Path(proof_file).exists()}") - print(f"📚 Library path exists: {Path(lib_path).exists()}") - - # Check proof file content - if Path(proof_file).exists(): - with open(proof_file, 'r') as f: - content = f.read() - print(f"📄 Proof file size: {len(content)} characters") - - # Check if proof is complete - if content.strip().endswith('Proof.'): - print("✅ Proof file ends with 'Proof.' - perfect for testing!") - print(" This means we can enter proving mode for search commands") - elif 'Qed.' in content or 'Defined.' in content: - print("✅ Proof file contains completed proofs") - else: - print("⚠️ Proof file status unclear") - - if Path(lib_path).exists(): - lib_files = list(Path(lib_path).glob("*.v")) - print(f"📚 Library files found: {len(lib_files)}") - for f in lib_files[:5]: # Show first 5 files - print(f" - {f.name}") - - return Path(workspace).exists() and Path(proof_file).exists() +def open_backend(tmp_path: Path): + source = tmp_path / "demo.stub" + source.write_text("demo", encoding="utf-8") + backend = StubProverBackend() + theorem = TheoremIdentity( + SourceLocation(source, tmp_path), "demo_theorem" + ) + run(backend.open(theorem)) + return backend -def test_coq_interface_initialization(): - """Test CoqInterface initialization with full query command testing.""" - print("\n🔬 Testing CoqInterface Initialization (Full Query Commands)") - print("=" * 50) - - # Use the correct initialization pattern from LLM test - proof_file_path = PROJECT_ROOT / "examples" / "main_loop_invariant_2_established_Coq.v" - try: - # Initialize CoqInterface with just file path (like in LLM test) - coq = CoqInterface(proof_file_path) - print("✅ CoqInterface object created") - - # Load the file (essential step!) - coq.load() - print("✅ CoqInterface loaded successfully") - - # Check what we have - attrs = [attr for attr in dir(coq) if not attr.startswith('_')] - print(f"Available methods: {attrs}") - - # Test basic functionality - print("\n🧪 Testing basic functionality:") - - # Check goals and hypotheses - try: - goals = coq.get_goal_str() - hypotheses = coq.get_hypothesis() - print(f"✅ Current goals: {goals}") - print(f"✅ Current hypotheses: {hypotheses}") - except Exception as e: - print(f"⚠️ Goal/hypothesis check: {e}") - - # Test all query command functionality (the key part!) - print("\n🔍 Testing full query command functionality:") - - search_commands = [ - # Search commands (these should trigger reduction) - "Search Z.abs.", - "Search (_ <= _).", - "Search (_ + _).", - "Search (forall _ : int, _ \/ _).", - # Print commands (small results) - "Print Z.abs.", - "Print nat.", - "Print bool.", - # Print Assumptions commands - "Print Assumptions.", - "Print Assumptions Z.abs.", - # Locate commands - "Locate le.", - "Locate mult.", - # About commands - "About Z.", - "About nat.", - # Check commands - "Check nat.", - "Check bool.", - ] - - successful_searches = 0 - command_results = {} - total_result_size = 0 - reduction_summary = {'none': 0, 'boundary_aware_truncation': 0, 'structured_summary': 0, 'simple_truncation': 0} - - for cmd in search_commands: - try: - print(f"\n--- {cmd} ---") - result = coq.search(cmd) - result_size = len(result) if result else 0 - total_result_size += result_size - - print(f"✅ Query result: {result[:200]}...") - print(f"📊 Raw result size: {result_size} characters") - successful_searches += 1 - - # Categorize results by command type - cmd_type = cmd.split()[0].lower() - if cmd_type not in command_results: - command_results[cmd_type] = {'count': 0, 'total_size': 0, 'raw_total': 0} - command_results[cmd_type]['count'] += 1 - command_results[cmd_type]['raw_total'] += result_size - - except Exception as e: - print(f"❌ Query failed: {e}") - - print(f"\n📊 Raw Results Summary:") - print(f"📊 Successful queries: {successful_searches}/{len(search_commands)}") - print(f"📊 Total raw result size: {total_result_size} characters") - print(f"📊 Average raw result size: {total_result_size / successful_searches:.1f} characters" if successful_searches > 0 else "") - print(f"📊 Raw results by command type:") - for cmd_type, data in command_results.items(): - avg_size = data['raw_total'] / data['count'] if data['count'] > 0 else 0 - print(f" - {cmd_type.upper()}: {data['count']} successful, {data['raw_total']} total chars, {avg_size:.1f} avg chars") - - # Clean up - coq.close() - return successful_searches > 0 - - except Exception as e: - print(f"❌ CoqInterface initialization failed: {e}") - import traceback - traceback.print_exc() - return False +def test_small_query_result_is_not_reduced(): + reducer = ResultReducer() + content, method = reducer.reduce_result("and_comm : A /\\ B -> B /\\ A", "check") + assert method == "none" + assert content.startswith("and_comm") -def test_coq_command_search_with_reduction(): - """Test CoqCommandSearch functionality with adaptive result reduction.""" - print("\n" + "=" * 60) - print("TESTING CoqCommandSearch with Adaptive Result Reduction") - print("=" * 60) - - # Check setup first - if not check_coq_setup(): - print("❌ CoqInterface setup check failed - skipping test") - return False - - # Test correct initialization first - if not test_coq_interface_initialization(): - print("❌ CoqInterface initialization failed - skipping CoqCommandSearch test") - return False - - # Initialize CoqInterface using correct pattern - proof_file_path = PROJECT_ROOT / "examples" / "main_loop_invariant_2_established_Coq.v" +def test_large_search_result_is_bounded(): + reducer = ResultReducer() + content = "\n".join(f"lemma_{index}: True" for index in range(200)) + reduced, method = reducer.reduce_result(content, "direct_search") + assert method == "structured_summary" + assert len(reduced) < len(content) + assert "SEARCH RESULTS SUMMARY" in reduced - try: - # Use the correct initialization pattern - coq_interface = CoqInterface(str(proof_file_path)) - coq_interface.load() # Essential! - print("✅ CoqInterface initialized and loaded for reduction testing") - - # Create CoqCommandSearch - coq_search = CoqCommandSearch(coq_interface) - - # Test cases specifically designed to test reduction strategies - test_cases = [ - # Large search results (should trigger structured summarization) - ("Search pattern (_ <= _) - Large Result", "search_pattern", None, "(_ <= _)", "forall x y z : Z, x <= y -> y <= z -> x <= z"), - ("Search pattern (_ + _) - Large Result", "search_pattern", None, "(_ + _)", "forall x y : Z, x + y = y + x"), - ("Search lemma Z.abs - Large Result", "search_lemma", "Z.abs", None, "forall x : Z, 0 <= Z.abs x"), - - # Medium results (should trigger boundary-aware truncation) - ("Search lemma mult", "search_lemma", "mult", None, "multiplication"), - - # Small results (should remain unchanged) - ("Print definition Z.abs", "print_definition", "Z.abs", None, ""), - ("Print definition nat", "print_definition", "nat", None, ""), - ("Check Z.abs", "check_term", "Z.abs", None, ""), - ("Check nat", "check_term", "nat", None, ""), - ("About Z.abs", "about_identifier", "Z.abs", None, ""), - ("Locate le", "locate_definition", "le", None, ""), - - # Auto search tests - ("Auto search - Search (_ * _)", "auto_search", "Search (_ * _).", None, "multiplication associativity"), - ("Auto search - Print bool", "auto_search", "Print bool.", None, ""), - ] - - successful_tests = 0 - results_by_reduction = {'none': [], 'boundary_aware_truncation': [], 'structured_summary': [], 'simple_truncation': []} - total_original_size = 0 - total_final_size = 0 - total_bytes_saved = 0 - - for test_name, method_name, identifier, pattern, goal_context in test_cases: - print(f"\n--- {test_name} ---") - print(f"Method: {method_name}") - if identifier: - print(f"Identifier: {identifier}") - if pattern: - print(f"Pattern: {pattern}") - if goal_context: - print(f"Goal context: {goal_context}") - - try: - # Execute the search with goal context for relevance ranking - if method_name == "search_lemma": - result = coq_search.search_lemma(identifier, goal_context) - elif method_name == "search_pattern": - result = coq_search.search_pattern(pattern, goal_context) - elif method_name == "print_definition": - result = coq_search.print_definition(identifier) - elif method_name == "print_assumptions": - result = coq_search.print_assumptions(identifier) - elif method_name == "locate_definition": - result = coq_search.locate_definition(identifier) - elif method_name == "about_identifier": - result = coq_search.about_identifier(identifier) - elif method_name == "check_term": - result = coq_search.check_term(identifier) - elif method_name == "auto_search": - result = coq_search.auto_search(identifier, goal_context) - else: - print(f"❌ Unknown method: {method_name}") - continue - - # Print reduction analysis - print(f"✅ Source: {result.source}") - print(f"✅ Relevance: {result.relevance_score}") - print(f"📊 Original size: {result.original_size} characters") - print(f"📊 Final size: {result.result_size} characters") - print(f"🔧 Reduction applied: {result.reduction_applied or 'none'}") - - if result.original_size > result.result_size: - bytes_saved = result.original_size - result.result_size - reduction_percent = (bytes_saved / result.original_size) * 100 - print(f"💾 Bytes saved: {bytes_saved} ({reduction_percent:.1f}% reduction)") - total_bytes_saved += bytes_saved - else: - print(f"💾 No reduction needed") - - # Print content preview (handle None case) - if result.content: - print(f"✅ Content preview: {result.content[:150]}...") - else: - print(f"⚠️ No content returned") - - if result.metadata: - print(f"✅ Metadata: {result.metadata}") - - # Track reduction statistics - reduction_method = result.reduction_applied or 'none' - results_by_reduction[reduction_method].append({ - 'test_name': test_name, - 'original_size': result.original_size, - 'final_size': result.result_size, - 'reduction_percent': ((result.original_size - result.result_size) / result.original_size * 100) if result.original_size > 0 else 0 - }) - - successful_tests += 1 - total_original_size += result.original_size - total_final_size += result.result_size - - except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - - # Print comprehensive reduction analysis - print(f"\n" + "=" * 60) - print(f"📊 REDUCTION ANALYSIS SUMMARY") - print(f"=" * 60) - - print(f"📊 Successful tests: {successful_tests}/{len(test_cases)}") - print(f"📊 Total original size: {total_original_size:,} characters") - print(f"📊 Total final size: {total_final_size:,} characters") - print(f"📊 Total bytes saved: {total_bytes_saved:,} characters") - - if total_original_size > 0: - overall_reduction = (total_bytes_saved / total_original_size) * 100 - print(f"📊 Overall reduction: {overall_reduction:.1f}%") - - print(f"\n🔧 Reduction Methods Used:") - for method, results in results_by_reduction.items(): - if results: - count = len(results) - avg_original = sum(r['original_size'] for r in results) / count - avg_final = sum(r['final_size'] for r in results) / count - avg_reduction = sum(r['reduction_percent'] for r in results) / count - - print(f" - {method}: {count} tests") - print(f" → Avg original: {avg_original:.0f} chars") - print(f" → Avg final: {avg_final:.0f} chars") - print(f" → Avg reduction: {avg_reduction:.1f}%") - - # Show examples - for result in results[:2]: # Show first 2 examples - print(f" → Example: {result['test_name']} ({result['original_size']} → {result['final_size']} chars)") - - # Test specific reduction scenarios - print(f"\n🧪 Testing Specific Reduction Scenarios:") - - # Test large search that should definitely be reduced - try: - print(f"\n--- Large Search Test: Search (_ * _) ---") - large_result = coq_search.search_pattern("(_ * _)", "multiplication commutative associative") - print(f"📊 Large search result: {large_result.original_size} → {large_result.result_size} chars") - print(f"🔧 Reduction method: {large_result.reduction_applied}") - - # Print content if available - if large_result.content: - preview = large_result.content[:200] if len(large_result.content) > 200 else large_result.content - print(f"📄 Content preview: {preview}...") - else: - print(f"⚠️ No content returned") - - if large_result.reduction_applied == 'structured_summary': - print(f"✅ Large result correctly summarized") - elif large_result.original_size > 1000: - print(f"⚠️ Large result ({large_result.original_size} chars) but reduction method: {large_result.reduction_applied}") - else: - print(f"✅ Result size acceptable ({large_result.original_size} chars)") - except Exception as e: - print(f"❌ Large search test failed: {e}") - - # Clean up - coq_interface.close() - - return successful_tests > 0 - - except Exception as e: - print(f"❌ Failed to initialize CoqInterface: {e}") - import traceback - traceback.print_exc() - return False +def test_native_query_runs_through_backend(tmp_path): + backend = open_backend(tmp_path) + result = run(ContextSearch(backend).search("Check and_comm.")) + assert "and_comm" in result.content + assert result.metadata["query"] == "Check and_comm." + run(backend.close()) -def run_all_tests(): - """Run all context search tests focusing on adaptive reduction.""" - print("🚀 Starting Context Search Tests with Adaptive Result Reduction") - print("=" * 90) - - results = [] - - try: - # Test: CoqCommandSearch with adaptive reduction - print("\n" + "🧪 TEST: CoqCommandSearch with Adaptive Result Reduction") - reduction_result = test_coq_command_search_with_reduction() - results.append(("CoqCommandSearch with Reduction", reduction_result)) - - # Summary - print("\n" + "=" * 90) - print("🏁 TEST RESULTS SUMMARY (ADAPTIVE REDUCTION)") - print("=" * 90) - - passed_tests = 0 - for test_name, result in results: - status = "✅ PASSED" if result else "❌ FAILED" - print(f"{test_name}: {status}") - if result: - passed_tests += 1 - - print(f"\nOverall: {passed_tests}/{len(results)} tests passed") - - if passed_tests == len(results): - print("🎉 ALL REDUCTION TESTS PASSED!") - print("✅ Adaptive result reduction working correctly") - print("✅ Large search results properly summarized") - print("✅ Medium results boundary-aware truncated") - print("✅ Small results preserved unchanged") - print("✅ Context-aware relevance ranking functional") - return True - else: - print("❌ REDUCTION TESTS FAILED") - print("🔧 Check result reduction implementation") - return False - - except Exception as e: - print(f"\n❌ Test suite failed with error: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - success = run_all_tests() - - print("\n" + "=" * 90) - print("🏁 CONTEXT SEARCH REDUCTION TEST SUMMARY") - print("=" * 90) - - if success: - print("🎉 Context search reduction testing completed successfully!") - print("✅ CoqCommandSearch: Working with adaptive reduction") - print("✅ Result reduction strategies: Working") - print(" - Small results (< 500): ✅ Preserved unchanged") - print(" - Medium results (500-1K): ✅ Boundary-aware truncation") - print(" - Large results (> 1K): ✅ Structured summarization") - print("✅ Context-aware ranking: Working") - print("✅ Size tracking and analysis: Working") - print("🚀 Ready for LLM integration with manageable result sizes") - else: - print("❌ Context search reduction testing failed") - print("🔧 Check reduction algorithm implementation") - print("🔧 Verify result parsing and ranking logic") - - print("\n💡 Adaptive reduction features:") - print(" - 📏 Size-based strategy selection") - print(" - 🎯 Goal context-aware relevance ranking") - print(" - ✂️ Boundary-aware truncation at theorem boundaries") - print(" - 📝 Structured summarization with categorization") - print(" - 📊 Comprehensive size and reduction tracking") - print(" - 🔍 Keyword extraction and matching") - print(" - 📚 Standard library preference") - print(" - 💾 Significant space savings for large results") - - sys.exit(0 if success else 1) \ No newline at end of file +def test_empty_query_has_no_results(tmp_path): + backend = open_backend(tmp_path) + result = run(ContextSearch(backend).search("")) + assert result.result_size > 0 + assert result.relevance_score == 0.0 + run(backend.close()) diff --git a/proof-search/tests/test_context_search_entries.py b/proof-search/tests/test_context_search_entries.py index 3b6dbd1..7d61824 100644 --- a/proof-search/tests/test_context_search_entries.py +++ b/proof-search/tests/test_context_search_entries.py @@ -1,108 +1,26 @@ -""" -Test script for Context Search on match_string_assert.v -Tests specific search commands with ranking and summarization -""" +"""Structured context-query entry tests.""" -import sys -import os -import json -from pathlib import Path -from datetime import datetime +from agent.context_search import ResultReducer -# Add the parent directory to the path so we can import from agent -PROJECT_ROOT = Path(__file__).parent.parent -sys.path.insert(0, str(PROJECT_ROOT)) -try: - from backend.coq_interface import CoqInterface - from agent.context_search import CoqCommandSearch - print("✅ CoqInterface and CoqCommandSearch available") -except ImportError as e: - print(f"❌ Import failed: {e}") - sys.exit(1) - - -def test_search_commands(): - """Test specific search commands with ranking and summarization""" - print("\n" + "=" * 70) - print("Testing Search Commands with Ranking and Summarization") - print("=" * 70) - - proof_file_path = PROJECT_ROOT / "examples" / "match_string_assert.v" - - if not proof_file_path.exists(): - print(f"❌ Proof file not found: {proof_file_path}") - return False - - print(f"📄 Proof file: {proof_file_path}") - - try: - # Initialize CoqInterface and CoqCommandSearch - coq = CoqInterface(str(proof_file_path)) - coq.load() - print("✅ CoqInterface loaded successfully") - - # Initialize CoqCommandSearch with ranking capabilities - coq_search = CoqCommandSearch(coq) - print("✅ CoqCommandSearch initialized\n") - - # Test commands with goal context for better ranking - test_cases = [ - { - "command": "About Q_real_len_not_nulls.", - "goal_context": "Q_real_len not nulls list" - }, - { - "command": "Search L_real_len.", - "goal_context": "L_real_len length list real" - } +def test_search_entries_keep_names_modules_and_signatures(): + reducer = ResultReducer() + entries = reducer._parse_search_entries( + [ + "Coq.Lists.List.app_nil_r: forall A (xs : list A), xs ++ nil = xs", + "Nat.add_comm: forall n m, n + m = m + n", ] - - for i, test_case in enumerate(test_cases, 1): - cmd = test_case["command"] - goal_context = test_case.get("goal_context", "") - - print(f"{'=' * 70}") - print(f"Command {i}: {cmd}") - if goal_context: - print(f"Goal Context: {goal_context}") - print(f"{'=' * 70}") - - try: - # Use auto_search which applies ranking and reduction - search_result = coq_search.auto_search(cmd, goal_context) - - print(f"\n📊 Result Metadata:") - print(f" - Original Size: {search_result.original_size} characters") - print(f" - Final Size: {search_result.result_size} characters") - print(f" - Reduction Applied: {search_result.reduction_applied}") - print(f" - Relevance Score: {search_result.relevance_score}") - if search_result.metadata.get('size_saved', 0) > 0: - print(f" - Size Saved: {search_result.metadata['size_saved']} characters") - - print(f"\n📄 Search Results:") - print("-" * 70) - print(search_result.content) - print("-" * 70) - print() - - except Exception as e: - print(f"❌ Query failed: {e}") - import traceback - traceback.print_exc() - print() - - # Clean up - coq.close() - return True - - except Exception as e: - print(f"❌ Test failed: {e}") - import traceback - traceback.print_exc() - return False + ) + assert entries[0]["module"] == "Coq.Lists.List" + assert entries[0]["name"] == "app_nil_r" + assert entries[1]["name"] == "add_comm" -if __name__ == "__main__": - success = test_search_commands() - sys.exit(0 if success else 1) +def test_goal_keywords_rank_matching_entry_first(): + reducer = ResultReducer() + entries = [ + {"name": "mul_zero", "signature": "n * 0 = 0", "module": "Nat"}, + {"name": "add_comm", "signature": "n + m = m + n", "module": "Nat"}, + ] + ranked = reducer._rank_entries(entries, "addition add operation") + assert ranked[0]["name"] == "add_comm" diff --git a/proof-search/tests/test_controller_prove.py b/proof-search/tests/test_controller_prove.py index b8fd009..9a7d21d 100644 --- a/proof-search/tests/test_controller_prove.py +++ b/proof-search/tests/test_controller_prove.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import os import sys from pathlib import Path @@ -6,7 +9,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from agent.context_manager import ContextManager from agent.proof_controller import ProofController from utils.config import ProofAgentConfig @@ -54,9 +57,9 @@ def test_prove_theorem(): config = ProofAgentConfig.from_file(str(config_file)) print(f"✅ Configuration loaded") - # Create CoqInterface - print("🔧 Step 3: Create CoqInterface") - coq_interface = CoqInterface( + # Create CoqPytSession + print("🔧 Step 3: Create CoqPytSession") + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=config.coq.workspace or str(coq_file.parent), library_paths=config.coq.library_paths, @@ -67,15 +70,15 @@ def test_prove_theorem(): try: # Load the file print("📂 Step 4: Load Coq file") - if not coq_interface.load(): - print(f"❌ Failed to load file: {coq_interface.get_last_error()}") + if not coqpyt_session.load(): + print(f"❌ Failed to load file: {coqpyt_session.get_last_error()}") return False print("✅ Coq file loaded") # Create ContextManager print("🤖 Step 5: Create ContextManager") context_manager = ContextManager( - coq_interface, + coqpyt_session, api_key=config.llm.api_key, enable_history_context=getattr(config, "enable_history_context", True) ) @@ -84,7 +87,7 @@ def test_prove_theorem(): # Create controller with updated parameters print("🎮 Step 6: Create proof controller") controller = ProofController( - coq_interface=coq_interface, + coqpyt_session=coqpyt_session, context_manager=context_manager, max_steps=15, # Enough steps for our sequence enable_context_search=False, # Disable for simple test @@ -93,7 +96,7 @@ def test_prove_theorem(): # Show initial state print("\n📊 Initial proof state:") - initial_goals = coq_interface.get_goal_str() + initial_goals = coqpyt_session.get_goal_str() print(f" Goals: {initial_goals[:100]}...") # Use prove_theorem API @@ -119,7 +122,7 @@ def test_prove_theorem(): print(f" {i}. {tactic}") # Verify proof completion - final_status = coq_interface.get_proof_completion_status() + final_status = coqpyt_session.get_proof_completion_status() print(f"\n🎯 Final proof status:") print(f" Complete: {final_status.get('is_complete', False)}") print(f" Ready for Qed: {final_status.get('ready_for_qed', False)}") @@ -127,7 +130,7 @@ def test_prove_theorem(): return success finally: - coq_interface.close() + coqpyt_session.close() except Exception as e: print(f"❌ Test failed: {e}") diff --git a/proof-search/tests/test_coqpyt_backend.py b/proof-search/tests/test_coqpyt_backend.py new file mode 100644 index 0000000..e1a47ff --- /dev/null +++ b/proof-search/tests/test_coqpyt_backend.py @@ -0,0 +1,115 @@ +"""Contract and integration tests for the Rocq backend.""" + +import asyncio +import shutil +import subprocess +from pathlib import Path + +import pytest + +from backend.rocq.backend import CoqPytBackend +from backend.prover_backend import ( + CommandRejectedError, LifecycleState, + SourceLocation, TheoremIdentity, +) + +pytestmark = pytest.mark.integration + + +def run(coroutine): + return asyncio.run(coroutine) + + +@pytest.fixture +def rocq_theorem(tmp_path: Path) -> TheoremIdentity: + if shutil.which("coq-lsp") is None or shutil.which("coqc") is None: + pytest.skip("coq-lsp and coqc are required for Rocq integration tests") + source = tmp_path / "contract.v" + source.write_text( + "Require Import Coq.Unicode.Utf8.\n" + "Require Import List.\n" + "Require Import ZArith.\n" + "From Coq Require Import ZArith Lia.\n\n" + "Lemma first_theorem : True.\nProof. exact I. Qed.\n\n" + "Lemma demo_theorem : forall P Q : Prop, P /\\ Q -> Q /\\ P.\n" + "Proof. Admitted.\n", + encoding="utf-8", + ) + return TheoremIdentity(SourceLocation(source, tmp_path), "demo_theorem") + + +def test_rollback_without_proof_marker(tmp_path: Path): + """NTP4VC-generated obligations state the theorem and immediately write + `Admitted.` with no `Proof.` command; rollback must still restore the + checkpoint instead of failing to find the marker.""" + if shutil.which("coq-lsp") is None: + pytest.skip("coq-lsp is required for Rocq integration tests") + source = tmp_path / "no_marker.v" + source.write_text( + "Theorem no_marker : True /\\ True.\nAdmitted.\n", encoding="utf-8" + ) + theorem = TheoremIdentity(SourceLocation(source, tmp_path), "no_marker") + backend = CoqPytBackend(timeout=30) + try: + state = run(backend.open(theorem)) + assert len(state.goals) == 1 + checkpoint = run(backend.checkpoint()) + branched = run(backend.apply("split.")).state + assert [goal.conclusion for goal in branched.goals] == ["True", "True"] + restored = run(backend.rollback(checkpoint)) + assert len(restored.goals) == 1 + assert restored.revision == 0 + complete = run(backend.apply("split; exact I.")).state + assert complete.is_complete + finally: + run(backend.close()) + + +def test_real_backend_contract(rocq_theorem: TheoremIdentity, tmp_path: Path): + backend = CoqPytBackend(timeout=30) + original = rocq_theorem.source.path.read_text(encoding="utf-8") + try: + state = run(backend.open(rocq_theorem)) + assert state.theorem == rocq_theorem + assert "∀" in state.goals[0].conclusion or "forall" in state.goals[0].conclusion + assert backend.lifecycle is LifecycleState.OPEN + + result = run(backend.apply("intros P Q H.")) + assert result.state.goals[0].conclusion in {"Q /\\ P", "Q ∧ P"} + checkpoint = run(backend.checkpoint()) + + rejected_state = run(backend.state()) + with pytest.raises(CommandRejectedError) as rejected: + run(backend.apply("exact I.")) + assert rejected.value.state == rejected_state + assert run(backend.state()) == rejected_state + + split = run(backend.apply("split.")).state + assert [goal.conclusion for goal in split.goals] == ["Q", "P"] + restored = run(backend.rollback(checkpoint)) + assert [goal.conclusion for goal in restored.goals] in [["Q /\\ P"], ["Q ∧ P"]] + + query_state = run(backend.state()) + query = run(backend.query("Check and_comm.")) + assert "and_comm" in query.output + assert run(backend.state()) == query_state + + complete = run( + backend.apply("destruct H as [HP HQ]; split; assumption.") + ).state + assert complete.is_complete + assert backend.lifecycle is LifecycleState.COMPLETE + + destination = tmp_path / "saved_contract.v" + certificate = run(backend.save_proof(destination)) + assert certificate.commands[-1] == "Qed." + assert "Admitted." not in destination.read_text(encoding="utf-8") + subprocess.run(["coqc", str(destination)], check=True, cwd=tmp_path) + with pytest.raises(FileExistsError): + run(backend.save_proof(destination)) + run(backend.save_proof(destination, overwrite=True)) + assert rocq_theorem.source.path.read_text(encoding="utf-8") == original + finally: + run(backend.close()) + assert backend.lifecycle is LifecycleState.CLOSED + run(backend.close()) diff --git a/proof-search/tests/test_coqpyt_backend_unit.py b/proof-search/tests/test_coqpyt_backend_unit.py new file mode 100644 index 0000000..ab32b74 --- /dev/null +++ b/proof-search/tests/test_coqpyt_backend_unit.py @@ -0,0 +1,254 @@ +"""Adapter contract tests independent of a CoqPyt/Rocq version pairing.""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from backend.rocq.session import CoqPytSession +from backend.rocq.backend import CoqLibraryPath, CoqPytBackend +from backend.prover_backend import ( + Checkpoint, CommandKind, CommandRejectedError, HelperLemmaSpec, + InvalidCheckpointError, InvalidLifecycleError, LifecycleState, + ProverProtocolError, SourceLocation, TheoremIdentity, +) + + +def run(coroutine): + return asyncio.run(coroutine) + + +class ScriptedCoqPytSession: + """Small CoqPytSession double; it does not implement agent policy.""" + + def __init__(self, file_path, **_options): + self.file_path = Path(file_path) + self.options = _options + self.last_error = None + self.commands = [] + self.proof_file = SimpleNamespace(current_goals=None) + self.proof = SimpleNamespace(steps=[SimpleNamespace(text="Proof.")]) + self.closed = False + self._refresh() + + def _goal(self, conclusion, hypotheses=()): + hyps = [SimpleNamespace(names=[name], ty=type_text, definition=None) + for name, type_text in hypotheses] + return SimpleNamespace(ty=conclusion, hyps=hyps) + + def _refresh(self): + states = [ + [self._goal("forall P Q : Prop, P /\\ Q -> Q /\\ P")], + [self._goal("Q /\\ P", (("P", "Prop"), ("Q", "Prop"), ("H", "P /\\ Q")))], + [self._goal("Q"), self._goal("P")], + [self._goal("P")], + [], + ] + config = SimpleNamespace( + goals=states[len(self.commands)], stack=[], shelf=[], given_up=[] + ) + self.proof_file.current_goals = SimpleNamespace(goals=config) + + def load(self, theorem_name): + return theorem_name == "demo_theorem" + + def get_last_error(self): + return self.last_error + + def get_context_terms(self): + return {} + + def apply_tactic(self, command): + command = command.strip() + expected = ["intros P Q H.", "split.", "exact H.2.", "exact H.1."] + if command == "Qed." and len(self.commands) == 4: + text = self.file_path.read_text(encoding="utf-8") + self.file_path.write_text(text.replace("Admitted.", "Qed."), encoding="utf-8") + return True + if len(self.commands) >= len(expected) or command != expected[len(self.commands)]: + self.last_error = "scripted Rocq rejection" + return False + self.commands.append(command) + self.proof.steps.append(SimpleNamespace(text=command)) + self._refresh() + return True + + def get_current_step_number(self): + return 1 + len(self.commands) + + def reset_by_step(self, step): + self.commands = self.commands[:step - 1] + self.proof.steps = self.proof.steps[:step] + self._refresh() + return True + + def search(self, command): + return "and_comm : forall A B : Prop, A /\\ B -> B /\\ A" + + def close(self): + self.closed = True + + +@pytest.fixture +def theorem(tmp_path): + source = tmp_path / "DuneManagedGoal.v" + source.write_text( + "Lemma demo_theorem : forall P Q : Prop, P /\\ Q -> Q /\\ P.\n" + "Proof. Admitted.\n", encoding="utf-8" + ) + return TheoremIdentity(SourceLocation(source, tmp_path), "demo_theorem") + + +def test_rocq_backend_owns_native_command_syntax(): + backend = CoqPytBackend(session_factory=ScriptedCoqPytSession) + commands = backend.helper_lemma_commands(HelperLemmaSpec("Hcomm", "P /\\ Q")) + + assert commands.declaration == "assert (Hcomm: P /\\ Q)" + assert (commands.open_scope, commands.close_scope) == ("{", "}") + assert backend.classify_command("admit.") is CommandKind.UNSOUND_COMPLETION + assert backend.automation_command() == "hammer." + + +def test_adapter_contract(theorem, tmp_path): + backend = CoqPytBackend(session_factory=ScriptedCoqPytSession) + original = theorem.source.path.read_text(encoding="utf-8") + with pytest.raises(InvalidLifecycleError): + run(backend.state()) + state = run(backend.open(theorem)) + assert state.theorem == theorem and backend.lifecycle is LifecycleState.OPEN + run(backend.apply("intros P Q H.")) + checkpoint = run(backend.checkpoint()) + before = run(backend.state()) + with pytest.raises(CommandRejectedError) as rejected: + run(backend.apply("exact I.")) + assert rejected.value.state == before == run(backend.state()) + branched = run(backend.apply("split.")).state + assert [goal.conclusion for goal in branched.goals] == ["Q", "P"] + assert run(backend.rollback(checkpoint)).goals[0].conclusion == "Q /\\ P" + assert "and_comm" in run(backend.query("Check and_comm.")).output + run(backend.apply("split.")) + run(backend.apply("exact H.2.")) + assert run(backend.apply("exact H.1.")).state.is_complete + destination = tmp_path / "saved.v" + certificate = run(backend.save_proof(destination)) + assert certificate.commands[-1] == "Qed." + assert "Qed." in destination.read_text(encoding="utf-8") + assert theorem.source.path.read_text(encoding="utf-8") == original + with pytest.raises(FileExistsError): + run(backend.save_proof(destination)) + run(backend.save_proof(destination, overwrite=True)) + foreign = Checkpoint(object(), object(), 0) + with pytest.raises(InvalidCheckpointError): + run(backend.rollback(foreign)) + run(backend.close()) + run(backend.close()) + assert backend.lifecycle is LifecycleState.CLOSED + +def test_coqpyt_session_selects_a_named_unproven_theorem(): + first = SimpleNamespace(text="Lemma first_theorem : True.") + target = SimpleNamespace( + text="Theorem demo_theorem : forall P : Prop, P -> P." + ) + session = object.__new__(CoqPytSession) + session.proof_file = SimpleNamespace(unproven_proofs=[first, target]) + + assert session.get_unproven_proof() is first + assert session.get_unproven_proof("demo_theorem") is target + assert session.get_unproven_proof("missing") is None + +class RejectedLoadCoqPytSession(ScriptedCoqPytSession): + def load(self, theorem_name): + self.last_error = f"theorem not found: {theorem_name}" + return False + + +def test_partial_open_failure_closes_and_removes_working_copy(theorem): + backend = CoqPytBackend(session_factory=RejectedLoadCoqPytSession) + + with pytest.raises(ProverProtocolError): + run(backend.open(theorem)) + + assert backend.lifecycle is LifecycleState.CLOSED + assert not list(theorem.source.path.parent.glob("lemmanet_backend_*.v")) + + +def test_dune_managed_source_keeps_basename_and_library_mappings(theorem, tmp_path): + library = tmp_path / "library" + library.mkdir() + backend = CoqPytBackend( + session_factory=ScriptedCoqPytSession, + library_paths=(CoqLibraryPath(library, "Example"),), + coqproject_extra_options=("-arg -w",), + ) + + run(backend.open(theorem)) + session = backend._session + assert session is not None + working_path = session.file_path + working_directory = working_path.parent + assert working_path.name == theorem.source.path.name + assert working_directory != theorem.source.path.parent + assert session.options["workspace"] == str(working_directory) + assert session.options["library_paths"] == [ + {"path": str(library.resolve()), "name": "Example"} + ] + assert session.options["auto_setup_coqproject"] is True + assert session.options["coqproject_extra_options"] == ["-arg -w"] + + run(backend.close()) + assert not working_directory.exists() + + +def test_close_before_open_is_idempotent(): + backend = CoqPytBackend(session_factory=ScriptedCoqPytSession) + run(backend.close()) + run(backend.close()) + assert backend.lifecycle is LifecycleState.CLOSED + + +def test_private_workspaces_share_stable_import_cache_identity(theorem, tmp_path): + library = tmp_path / "library" + library.mkdir() + identities = [] + + for _ in range(2): + backend = CoqPytBackend( + session_factory=ScriptedCoqPytSession, + library_paths=(CoqLibraryPath(library, "Example"),), + coqproject_extra_options=("-arg -w",), + ) + run(backend.open(theorem)) + session = backend._session + assert session is not None + identities.append(session.options["cache_workspace"]) + assert str(session.file_path.parent) not in identities[-1] + run(backend.close()) + + assert identities[0] == identities[1] + assert str(theorem.source.workspace.resolve()) in identities[0] + + +def test_completed_selected_proof_is_reset_only_in_private_copy(tmp_path): + source = tmp_path / "Completed.v" + source.write_text( + "Lemma first : True.\nProof. exact I. Qed.\n\n" + "Lemma demo_theorem : forall P Q : Prop, P /\\ Q -> Q /\\ P.\n" + "Proof. intros P Q H. exact H. Admitted.\n", + encoding="utf-8", + ) + original = source.read_text(encoding="utf-8") + identity = TheoremIdentity( + SourceLocation(source, tmp_path), "demo_theorem" + ) + backend = CoqPytBackend(session_factory=ScriptedCoqPytSession) + + run(backend.open(identity)) + session = backend._session + assert session is not None + working = session.file_path.read_text(encoding="utf-8") + assert "Lemma first : True.\nProof. exact I. Qed." in working + assert "Proof.\nAdmitted." in working + assert "intros P Q H. exact H." not in working + assert source.read_text(encoding="utf-8") == original + run(backend.close()) diff --git a/proof-search/tests/test_coqpyt_cache_key.py b/proof-search/tests/test_coqpyt_cache_key.py new file mode 100644 index 0000000..23deda5 --- /dev/null +++ b/proof-search/tests/test_coqpyt_cache_key.py @@ -0,0 +1,77 @@ +"""Unit tests for stable CoqPyt imported-library cache keys.""" + +from coqpyt.coq.proof_file import _AuxFile + + +def test_cache_directory_is_versioned_and_legacy_cache_is_removed( + tmp_path, monkeypatch +): + monkeypatch.setenv("HOME", str(tmp_path)) + legacy = tmp_path / ".cache" / "coqpyt_cache" + legacy.mkdir(parents=True) + (legacy / "obsolete-key").write_bytes(b"obsolete") + + current = _AuxFile.get_coqpyt_disk_cache_loc() + + assert current is not None + assert "1.1.0" in current + assert not legacy.exists() + + +def test_cache_key_uses_stable_identity_not_private_workspace( + tmp_path, monkeypatch +): + library = tmp_path / "Library.v" + library.write_text("Lemma cached : True. Proof. exact I. Qed.\n", encoding="utf-8") + hashes = [] + + def get_cached(cls, library_hash): + hashes.append(library_hash) + return {} + + monkeypatch.setattr( + _AuxFile, "get_from_disk_cache", classmethod(get_cached) + ) + + for private_workspace in ("/tmp/private-one", "/tmp/private-two"): + assert ( + _AuxFile.get_library( + "Demo.Library", + str(library), + timeout=1, + coq_lsp_options=None, + workspace=private_workspace, + cache_workspace="stable-project-identity", + use_disk_cache=True, + ) + == {} + ) + + assert hashes[0] == hashes[1] + + +def test_cache_key_changes_with_project_identity(tmp_path, monkeypatch): + library = tmp_path / "Library.v" + library.write_text("Definition cached := True.\n", encoding="utf-8") + hashes = [] + + def get_cached(cls, library_hash): + hashes.append(library_hash) + return {} + + monkeypatch.setattr( + _AuxFile, "get_from_disk_cache", classmethod(get_cached) + ) + + for identity in ("project-a", "project-b"): + _AuxFile.get_library( + "Demo.Library", + str(library), + timeout=1, + coq_lsp_options=None, + workspace="/tmp/private", + cache_workspace=identity, + use_disk_cache=True, + ) + + assert hashes[0] != hashes[1] diff --git a/proof-search/tests/test_coqpyt_helper_lemma.py b/proof-search/tests/test_coqpyt_helper_lemma.py index 23600bb..de5eeeb 100644 --- a/proof-search/tests/test_coqpyt_helper_lemma.py +++ b/proof-search/tests/test_coqpyt_helper_lemma.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import sys from pathlib import Path diff --git a/proof-search/tests/test_coq_interface_goal_cache.py b/proof-search/tests/test_coqpyt_session_goal_cache.py similarity index 50% rename from proof-search/tests/test_coq_interface_goal_cache.py rename to proof-search/tests/test_coqpyt_session_goal_cache.py index ca08b43..ab3a90f 100644 --- a/proof-search/tests/test_coq_interface_goal_cache.py +++ b/proof-search/tests/test_coqpyt_session_goal_cache.py @@ -6,7 +6,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface # noqa: E402 +from backend.rocq.session import CoqPytSession # noqa: E402 class _FakeProofFile: @@ -41,33 +41,34 @@ def __init__(self, text): self.text = text -def _new_coq(proof_steps): - coq = object.__new__(CoqInterface) - coq.proof_file = _FakeProofFile() - coq.proof = type("FakeProof", (), {"steps": list(proof_steps)})() - coq.logger = logging.getLogger("test_coq_interface_goal_cache") - coq.__dict__["_CoqInterface__goal_cache_key"] = None - coq.__dict__["_CoqInterface__cached_goals"] = None - return coq +def _new_session(proof_steps): + session = object.__new__(CoqPytSession) + session.proof_file = _FakeProofFile() + session.proof = type("FakeProof", (), {"steps": list(proof_steps)})() + session.logger = logging.getLogger("test_coqpyt_session_goal_cache") + session.__dict__["_CoqPytSession__goal_cache_key"] = None + session.__dict__["_CoqPytSession__cached_goals"] = None + session.__dict__["_CoqPytSession__goal_cache_filled"] = False + return session def test_get_goal_str_and_get_subgoals_share_goal_cache(): - coq = _new_coq([_FakeStep("intro.")]) + session = _new_session([_FakeStep("intro.")]) - first = coq.get_goal_str() - second = coq.get_subgoals() + first = session.get_goal_str() + second = session.get_subgoals() assert first == "fake-goals" assert second == [] - assert coq.proof_file.current_goals_calls == 1 - assert coq.proof_file.invalidate_calls == 1 + assert session.proof_file.current_goals_calls == 1 + assert session.proof_file.invalidate_calls == 1 def test_goal_cache_refreshes_after_proof_mutates(): - coq = _new_coq([_FakeStep("intro.")]) + session = _new_session([_FakeStep("intro.")]) - coq.get_goal_str() - coq.proof.steps.append(_FakeStep("apply.")) - coq.get_goal_str() + session.get_goal_str() + session.proof.steps.append(_FakeStep("apply.")) + session.get_goal_str() - assert coq.proof_file.current_goals_calls == 2 + assert session.proof_file.current_goals_calls == 2 diff --git a/proof-search/tests/test_coq_interface_queries.py b/proof-search/tests/test_coqpyt_session_queries.py similarity index 79% rename from proof-search/tests/test_coq_interface_queries.py rename to proof-search/tests/test_coqpyt_session_queries.py index 5310766..57337e1 100644 --- a/proof-search/tests/test_coq_interface_queries.py +++ b/proof-search/tests/test_coqpyt_session_queries.py @@ -1,5 +1,8 @@ +import pytest +pytestmark = pytest.mark.integration + """ -Diagnostic script to understand CoqInterface capabilities +Diagnostic script to understand CoqPytSession capabilities """ import sys from pathlib import Path @@ -7,23 +10,23 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession -def test_coq_interface_initialization(): - """Test CoqInterface initialization with full query command testing.""" - print("\n🔬 Testing CoqInterface Initialization (Enhanced search() method)") +def test_coqpyt_session_initialization(): + """Test CoqPytSession initialization with full query command testing.""" + print("\n🔬 Testing CoqPytSession Initialization (Enhanced search() method)") print("=" * 50) proof_file_path = PROJECT_ROOT / "examples" / "main_loop_invariant_2_established_Coq.v" try: - # Initialize CoqInterface - coq = CoqInterface(str(proof_file_path)) - print("✅ CoqInterface object created") + # Initialize CoqPytSession + coq = CoqPytSession(str(proof_file_path)) + print("✅ CoqPytSession object created") # CRUCIAL: Load the file first! coq.load() - print("✅ CoqInterface loaded successfully") + print("✅ CoqPytSession loaded successfully") # Test all query command functionality print("\n🔍 Testing enhanced search() method with all query types:") @@ -89,10 +92,10 @@ def test_coq_interface_initialization(): return successful_searches > 0 except Exception as e: - print(f"❌ CoqInterface initialization failed: {e}") + print(f"❌ CoqPytSession initialization failed: {e}") import traceback traceback.print_exc() return False if __name__ == "__main__": - test_coq_interface_initialization() \ No newline at end of file + test_coqpyt_session_initialization() diff --git a/proof-search/tests/test_coqpyt_simple.py b/proof-search/tests/test_coqpyt_simple.py index 8793a4f..3445a83 100644 --- a/proof-search/tests/test_coqpyt_simple.py +++ b/proof-search/tests/test_coqpyt_simple.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import sys from pathlib import Path diff --git a/proof-search/tests/test_coqpyt_svcomp.py b/proof-search/tests/test_coqpyt_svcomp.py index 645625b..7e49ef4 100644 --- a/proof-search/tests/test_coqpyt_svcomp.py +++ b/proof-search/tests/test_coqpyt_svcomp.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import os import sys from pathlib import Path @@ -222,4 +225,4 @@ def test_proof_with_correct_tactics(): if proof_success: sys.exit(0) else: - assert False, "Proof not completed" \ No newline at end of file + assert False, "Proof not completed" diff --git a/proof-search/tests/test_coqpyt_version_pin.py b/proof-search/tests/test_coqpyt_version_pin.py new file mode 100644 index 0000000..d1bf21d --- /dev/null +++ b/proof-search/tests/test_coqpyt_version_pin.py @@ -0,0 +1,20 @@ +"""CoqPyt vendoring provenance tests.""" + +import tomllib +from pathlib import Path + +import coqpyt + + +def test_vendored_coqpyt_is_pinned_to_upstream_1_1(): + metadata = tomllib.loads( + (Path(coqpyt.__file__).parent / "UPSTREAM.toml").read_text( + encoding="utf-8" + ) + ) + + assert coqpyt.__upstream_version__ == "1.1.0" + assert metadata["upstream"]["tag"] == "v1.1.0" + assert metadata["upstream"]["commit"] == ( + "f47237a3cdb8d0d9d6d3195971d529f5750fdf02" + ) diff --git a/proof-search/tests/test_direct_tactics.py b/proof-search/tests/test_direct_tactics.py index 4d84270..08c0b50 100644 --- a/proof-search/tests/test_direct_tactics.py +++ b/proof-search/tests/test_direct_tactics.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + #!/usr/bin/env python3 """ Simple test script to check if three tactics can prove the goal. @@ -10,7 +13,7 @@ # Add the parent directory to Python path sys.path.insert(0, str(Path(__file__).parent.parent)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from utils.logger import setup_logger def test_three_tactics(): @@ -24,7 +27,7 @@ def test_three_tactics(): try: # Load the Coq file - coq = CoqInterface(file_path) + coq = CoqPytSession(file_path) coq.load() # Clear existing tactics @@ -104,4 +107,4 @@ def test_three_tactics(): sys.exit(0) else: print("❌ FAILED: The three tactics did not prove the goal") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/proof-search/tests/test_extract_proof_content.py b/proof-search/tests/test_extract_proof_content.py index efde611..3fc214b 100644 --- a/proof-search/tests/test_extract_proof_content.py +++ b/proof-search/tests/test_extract_proof_content.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + #!/usr/bin/env python3 """ Simple test script for extract_essential_proof_content function using real file @@ -10,7 +13,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from agent.context_manager import ContextManager from utils.config import ProofAgentConfig from tests.test_utils import reset_coq_file_to_admitted, restore_coq_file_from_backup @@ -73,19 +76,19 @@ def test_extract_with_real_file(): config = ProofAgentConfig.from_file(str(config_file)) print(f"✅ Loaded configuration from {config_file}") - # Create CoqInterface - coq_interface = CoqInterface( + # Create CoqPytSession + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=config.coq.workspace or str(coq_file.parent), library_paths=config.coq.library_paths, auto_setup_coqproject=config.coq.auto_setup_coqproject, timeout=config.coq.timeout ) - coq_interface.load() + coqpyt_session.load() try: # Create ContextManager - context_manager = ContextManager(coq_interface, api_key=config.llm.api_key) + context_manager = ContextManager(coqpyt_session, api_key=config.llm.api_key) print("✅ ContextManager created") # Extract essential content @@ -157,7 +160,7 @@ def test_extract_with_real_file(): return all_passed finally: - coq_interface.close() + coqpyt_session.close() except Exception as e: print(f"❌ Test failed with exception: {e}") diff --git a/proof-search/tests/test_folder_batch.py b/proof-search/tests/test_folder_batch.py index 56c379e..0d6fed0 100644 --- a/proof-search/tests/test_folder_batch.py +++ b/proof-search/tests/test_folder_batch.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + #!/usr/bin/env python3 import os import sys @@ -9,7 +12,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from agent.context_manager import ContextManager from agent.proof_controller import ProofController from utils.config import ProofAgentConfig @@ -77,8 +80,8 @@ def prove_single_file(coq_file: Path, config: ProofAgentConfig) -> bool: if not clean_proof_file(coq_file): return False - # Initialize CoqInterface - coq_interface = CoqInterface( + # Initialize CoqPytSession + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=config.coq.workspace or str(Path(coq_file).parent), library_paths=config.coq.library_paths, @@ -89,19 +92,19 @@ def prove_single_file(coq_file: Path, config: ProofAgentConfig) -> bool: try: # Load the cleaned file - if not coq_interface.load(): + if not coqpyt_session.load(): return False # Initialize ContextManager context_manager = ContextManager( - coq_interface, + coqpyt_session, api_key=config.llm.api_key, enable_history_context=getattr(config, "enable_history_context", True) ) # Initialize ProofController with updated parameters controller = ProofController( - coq_interface=coq_interface, + coqpyt_session=coqpyt_session, context_manager=context_manager, max_steps=100, # Reasonable limit for testing enable_context_search=getattr(config, "enable_context_search", True), @@ -110,7 +113,7 @@ def prove_single_file(coq_file: Path, config: ProofAgentConfig) -> bool: ) # Check proof status - status = coq_interface.get_proof_status() + status = coqpyt_session.get_proof_status() if not status.get("has_proof", False): return False @@ -122,7 +125,7 @@ def prove_single_file(coq_file: Path, config: ProofAgentConfig) -> bool: return success finally: - coq_interface.close() + coqpyt_session.close() except Exception as e: error_msg = str(e) diff --git a/proof-search/tests/test_interactive_helper_lemma.py b/proof-search/tests/test_interactive_helper_lemma.py index 3a99bc7..d965ef6 100644 --- a/proof-search/tests/test_interactive_helper_lemma.py +++ b/proof-search/tests/test_interactive_helper_lemma.py @@ -4,6 +4,8 @@ InteractiveSessionManager commands, against a real Coq session. """ +import asyncio +import shutil import sys from pathlib import Path @@ -17,7 +19,19 @@ from agent.context_manager import ContextManager from agent.interactive_session import InteractiveSessionManager from agent.proof_controller import ProofController -from backend.coq_interface import CoqInterface +from backend.rocq.backend import CoqPytBackend +from backend.prover_backend import ( + HelperLemmaSpec, + SourceLocation, + TheoremIdentity, +) + + +pytestmark = pytest.mark.integration + + +def run(awaitable): + return asyncio.run(awaitable) TEST_CONTENT = """Definition is_sint32 (x : nat) : Prop := @@ -35,39 +49,39 @@ @pytest.fixture def session(tmp_path, monkeypatch): - """An initialized interactive session on a fresh proof, with a clean history.""" + """An initialized async backend session on a fresh private proof copy.""" + if shutil.which("coq-lsp") is None: + pytest.skip("coq-lsp is required") monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.chdir(tmp_path) coq_file = tmp_path / "interactive_helper_lemma.v" coq_file.write_text(TEST_CONTENT, encoding="utf-8") - - coq_interface = CoqInterface( - file_path=str(coq_file), - workspace=str(tmp_path), - timeout=20, + backend = CoqPytBackend(timeout=20) + theorem = TheoremIdentity( + SourceLocation(coq_file, tmp_path), "main" ) - assert coq_interface.load(), coq_interface.get_last_error() - + initial_state = run(backend.open(theorem)) context_manager = ContextManager( - coq_interface=coq_interface, + backend=backend, + initial_state=initial_state, history_file=str(tmp_path / "tactic_history.json"), enable_context_search=False, ) controller = ProofController( - coq_interface=coq_interface, + backend=backend, + initial_state=initial_state, context_manager=context_manager, enable_recording=False, history_file=str(tmp_path / "tactic_history.json"), ) manager = InteractiveSessionManager(controller) - # No theorem name, exactly as `main.py` calls it without --theorem - assert controller._init_proof_session() + assert run(controller._init_proof_session()) try: yield manager finally: - coq_interface.close() + run(backend.close()) def _tactics(controller): @@ -81,13 +95,13 @@ def test_theorem_name_is_read_from_the_file(session): def test_supplied_theorem_name_wins(session): - assert session.controller._init_proof_session("explicit_name") + assert run(session.controller._init_proof_session("explicit_name")) assert session.controller.current_theorem_name == "explicit_name" def test_lemma_command_opens_subproof(session): controller = session.controller - session._do_lemma(f"Hhelper: {HELPER_STATEMENT}") + run(session._do_lemma(f"Hhelper: {HELPER_STATEMENT}")) assert _tactics(controller) == [f"assert (Hhelper: {HELPER_STATEMENT})", "{"] assert len(controller.helper_lemma_stack) == 1 @@ -101,7 +115,7 @@ def test_lemma_command_opens_subproof(session): def test_unnamed_lemma_gets_generated_name(session): controller = session.controller - session._do_lemma(HELPER_STATEMENT) + run(session._do_lemma(HELPER_STATEMENT)) open_lemmas = controller.helper_lemma_context() assert len(open_lemmas) == 1 @@ -111,10 +125,10 @@ def test_unnamed_lemma_gets_generated_name(session): def test_subproof_closes_and_is_recorded(session): controller = session.controller - session._do_lemma(f"Hhelper: {HELPER_STATEMENT}") + run(session._do_lemma(f"Hhelper: {HELPER_STATEMENT}")) for tactic in HELPER_TACTICS: - session._do_user_tactic(tactic) + run(session._do_user_tactic(tactic)) # Closing brace applied, back in the parent proof assert controller.helper_lemma_stack == [] @@ -129,24 +143,26 @@ def test_subproof_closes_and_is_recorded(session): assert entries[0].proof_tactics == HELPER_TACTICS + ["}"] # The lemma is usable in the parent proof - session._do_user_tactic("exact Hhelper.") + run(session._do_user_tactic("exact Hhelper.")) assert controller.is_successful def test_recorded_lemma_is_replayed_from_cache(session): controller = session.controller - session._do_lemma(f"Hhelper: {HELPER_STATEMENT}") + run(session._do_lemma(f"Hhelper: {HELPER_STATEMENT}")) for tactic in HELPER_TACTICS: - session._do_user_tactic(tactic) + run(session._do_user_tactic(tactic)) assert len(controller.tactic_history.helper_lemma_entries) == 1 # Roll the whole thing back, then propose the identical lemma again - session._do_rollback(len(controller._tactics_with_states)) + run(session._do_rollback(len(controller._tactics_with_states))) assert controller._tactics_with_states == [] - result = controller.open_helper_lemma( - f"assert (Hhelper: {HELPER_STATEMENT})", source="user" + result = run( + controller.open_helper_lemma( + HelperLemmaSpec("Hhelper", HELPER_STATEMENT), source="user" + ) ) assert result["success"] assert result["replayed"], "cached proof should have been replayed" @@ -156,42 +172,42 @@ def test_recorded_lemma_is_replayed_from_cache(session): def test_drop_removes_assert_and_brace(session): controller = session.controller - session._do_user_tactic("intros i.") + run(session._do_user_tactic("intros i.")) before = _tactics(controller) - session._do_lemma(f"Hhelper: {HELPER_STATEMENT}") - session._do_user_tactic("intros x.") + run(session._do_lemma(f"Hhelper: {HELPER_STATEMENT}")) + run(session._do_user_tactic("intros x.")) assert len(controller.helper_lemma_context()) == 1 - session._do_drop() + run(session._do_drop()) assert _tactics(controller) == before assert controller.helper_lemma_stack == [] assert controller.helper_lemma_context() == [] # Coq is back on the parent goal, which is still provable - session._do_user_tactic("unfold is_sint32.") - session._do_user_tactic("reflexivity.") + run(session._do_user_tactic("unfold is_sint32.")) + run(session._do_user_tactic("reflexivity.")) assert controller.is_successful def test_drop_outside_subproof_is_a_no_op(session): controller = session.controller - session._do_user_tactic("intros i.") + run(session._do_user_tactic("intros i.")) before = _tactics(controller) - session._do_drop() + run(session._do_drop()) assert _tactics(controller) == before def test_rollback_onto_brace_also_removes_assert(session): controller = session.controller - session._do_user_tactic("intros i.") - session._do_lemma(f"Hhelper: {HELPER_STATEMENT}") - session._do_user_tactic("intros x.") + run(session._do_user_tactic("intros i.")) + run(session._do_lemma(f"Hhelper: {HELPER_STATEMENT}")) + run(session._do_user_tactic("intros x.")) # 2 steps would land on '{', which must take the assert with it - session._do_rollback(2) + run(session._do_rollback(2)) assert _tactics(controller) == ["intros i."] assert controller.helper_lemma_stack == [] @@ -199,9 +215,9 @@ def test_rollback_onto_brace_also_removes_assert(session): def test_admit_closes_subproof_without_recording(session): controller = session.controller - session._do_lemma(f"Hhelper: {HELPER_STATEMENT}") + run(session._do_lemma(f"Hhelper: {HELPER_STATEMENT}")) - session._do_admit() + run(session._do_admit()) assert controller.helper_lemma_stack == [] assert _tactics(controller)[-1] == "}" @@ -210,9 +226,9 @@ def test_admit_closes_subproof_without_recording(session): def test_failed_lemma_is_reported_and_leaves_no_trace(session, capsys): controller = session.controller - session._do_user_tactic("intros i.") + run(session._do_user_tactic("intros i.")) - session._do_lemma("Hbogus: this_identifier_does_not_exist") + run(session._do_lemma("Hbogus: this_identifier_does_not_exist")) assert _tactics(controller) == ["intros i."] assert controller.helper_lemma_stack == [] @@ -223,11 +239,11 @@ def test_failed_lemma_is_reported_and_leaves_no_trace(session, capsys): def test_depth_limit_is_reported(session, capsys): controller = session.controller for i in range(controller.MAX_HELPER_LEMMA_DEPTH): - session._do_lemma(f"Hnest{i}: {i} = {i}") + run(session._do_lemma(f"Hnest{i}: {i} = {i}")) assert len(controller.helper_lemma_stack) == controller.MAX_HELPER_LEMMA_DEPTH capsys.readouterr() - session._do_lemma("Htoodeep: 9 = 9") + run(session._do_lemma("Htoodeep: 9 = 9")) # Rejected before touching the proof assert len(controller.helper_lemma_stack) == controller.MAX_HELPER_LEMMA_DEPTH @@ -250,7 +266,7 @@ def test_lemma_respects_ablation_toggle(session): controller.context_manager.enable_helper_lemma = False assert not controller.helper_lemma_enabled() - session._do_lemma(f"Hhelper: {HELPER_STATEMENT}") + run(session._do_lemma(f"Hhelper: {HELPER_STATEMENT}")) assert _tactics(controller) == [] assert controller.helper_lemma_context() == [] @@ -258,18 +274,18 @@ def test_lemma_respects_ablation_toggle(session): def test_nested_lemmas_open_and_close_in_order(session): controller = session.controller - session._do_lemma(f"Houter: {HELPER_STATEMENT}") - session._do_lemma("Hinner: 0 = 0") + run(session._do_lemma(f"Houter: {HELPER_STATEMENT}")) + run(session._do_lemma("Hinner: 0 = 0")) assert [hl["name"] for hl in controller.helper_lemma_context()] == ["Houter", "Hinner"] - session._do_user_tactic("reflexivity.") + run(session._do_user_tactic("reflexivity.")) # Inner closed, outer still open assert [hl["name"] for hl in controller.helper_lemma_context()] == ["Houter"] assert session._prompt() == "lemmanet[Houter]> " for tactic in HELPER_TACTICS: - session._do_user_tactic(tactic) + run(session._do_user_tactic(tactic)) assert controller.helper_lemma_context() == [] assert controller.helper_lemma_stack == [] diff --git a/proof-search/tests/test_llm_tactic_generator.py b/proof-search/tests/test_llm_tactic_generator.py index bccdac6..36d5fe4 100644 --- a/proof-search/tests/test_llm_tactic_generator.py +++ b/proof-search/tests/test_llm_tactic_generator.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import os import sys from pathlib import Path @@ -6,7 +9,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from agent.context_manager import ContextManager from utils.config import ProofAgentConfig from utils.coq_utils import find_transitive_dependencies @@ -23,12 +26,12 @@ def test_context_manager_initialization(config): print(f"Error: Example file not found at {coq_file}") return False - # Create a CoqInterface instance first - coq = CoqInterface(str(coq_file)) + # Create a CoqPytSession instance first + coq = CoqPytSession(str(coq_file)) coq.load() try: - # Instantiate ContextManager with the required coq_interface argument + # Instantiate ContextManager with the required coqpyt_session argument cm = ContextManager(coq, api_key=config.llm.api_key) # Check that the ContextManager has the expected attributes @@ -70,7 +73,7 @@ def test_context_manager_with_proof(config): print(f"Error: Example file not found at {coq_file}") return False - coq = CoqInterface(str(coq_file)) + coq = CoqPytSession(str(coq_file)) coq.load() try: @@ -130,7 +133,7 @@ def test_extract_essential_content(config): print(f"Error: Example file not found at {coq_file}") return False - coq = CoqInterface(str(coq_file)) + coq = CoqPytSession(str(coq_file)) coq.load() try: diff --git a/proof-search/tests/test_proof_controller_backend.py b/proof-search/tests/test_proof_controller_backend.py new file mode 100644 index 0000000..b83295d --- /dev/null +++ b/proof-search/tests/test_proof_controller_backend.py @@ -0,0 +1,284 @@ +"""Agent tests against the typed backend contract.""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from agent.context_search import ContextSearch +from agent.interactive_session import InteractiveSessionManager +from agent.proof_controller import ProofController +from tests.stub_backend import StubProverBackend +from backend.prover_backend import HelperLemmaSpec, SourceLocation, TheoremIdentity + + +def run(coroutine): + return asyncio.run(coroutine) + + +class HistoryDouble: + def __init__(self): + self.tactics = [] + self.helpers = [] + + def add_successful_tactic(self, **record): + self.tactics.append(record) + + def add_successful_helper_lemma(self, **record): + self.helpers.append(record) + + def find_helper_lemma_proof(self, _statement): + return None + + def get_similar_helper_lemmas(self, _goal, n=3): + return [] + + def get_similar_history(self, _goal, n=5): + return [] + + def save_history(self): + return None + + +class DeterministicContextManager: + def __init__(self, backend, initial_state, decisions): + self.backend = backend + self.backend_state = initial_state + self.decisions = list(decisions) + self.chat_session = SimpleNamespace(messages=[], current_plan="") + self.tactic_history = HistoryDouble() + self.enable_history_context = True + self.enable_context_search = True + self.last_action_info = {} + self.queries = [] + self.context_search = ContextSearch(backend) + self.cost_exhausted = False + + def build_initial_prompt(self, _tree): + return "initial" + + def get_action(self, *_args, **_kwargs): + if not self.decisions: + return {"type": "tactic", "content": "Abort."}, "call" + return self.decisions.pop(0), "call" + + def cost_budget_exhausted(self): + return self.cost_exhausted + + def get_tactic(self, content, _tool_call_id): + return content + + async def handle_query_call(self, content, _tool_call_id): + result = await self.backend.query(content) + self.queries.append(result) + return result.output + + def handle_plan_call(self, content, _tool_call_id): + self.chat_session.current_plan = content + return "plan recorded" + + def handle_helper_lemma_call(self, content, _tool_call_id): + return HelperLemmaSpec(content["name"], content["statement"]) + + def get_similar_history(self, goal, n=5): + return self.tactic_history.get_similar_history(goal, n) + + +def theorem(tmp_path: Path) -> TheoremIdentity: + source = tmp_path / "demo.stub" + source.write_text("demo theorem source", encoding="utf-8") + return TheoremIdentity(SourceLocation(source, tmp_path), "demo_theorem") + + +def make_controller(tmp_path, decisions): + backend = StubProverBackend() + identity = theorem(tmp_path) + initial_state = run(backend.open(identity)) + context = DeterministicContextManager(backend, initial_state, decisions) + controller = ProofController( + backend, + initial_state, + context, + max_steps=20, + max_errors=2, + enable_recording=False, + history_file=str(tmp_path / "history.json"), + ) + controller._finish_proof = lambda tactics: controller._record_successful_proof( + tactics + ) + return backend, context, controller + + +def tactic(command): + return {"type": "tactic", "content": command} + + +def test_cost_budget_exhaustion_stops_without_applying_next_action(tmp_path): + backend, context, controller = make_controller( + tmp_path, [tactic("intro H.")] + ) + context.cost_exhausted = True + + assert not run(controller.prove_theorem()) + assert controller.cost_budget_exhausted + assert controller.termination_reason == "cost budget exhausted" + assert run(backend.state()).revision == 0 + + +def test_controller_construction_requires_backend(tmp_path): + identity = theorem(tmp_path) + backend = StubProverBackend() + state = run(backend.open(identity)) + context = DeterministicContextManager(backend, state, []) + + with pytest.raises(TypeError): + ProofController(object(), state, context, enable_recording=False) + + +def test_rejected_tactic_is_retried_and_completion_is_learned(tmp_path): + decisions = [ + tactic("nonsense."), + tactic("intro H."), + tactic("split."), + tactic("exact H.2."), + tactic("exact H.1."), + ] + backend, _context, controller = make_controller(tmp_path, decisions) + + assert run(controller.prove_theorem("demo_theorem")) + assert controller.failed_tactics == ["nonsense."] + assert controller.successful_tactics == [ + "intro H.", + "split.", + "exact H.2.", + "exact H.1.", + ] + assert len(controller.tactic_history.tactics) == 4 + assert run(backend.state()).is_complete + + +def test_context_query_preserves_state_and_agent_continues(tmp_path): + decisions = [ + {"type": "query", "content": "Check and_comm."}, + tactic("intro H."), + tactic("split."), + tactic("exact H.2."), + tactic("exact H.1."), + ] + backend, context, controller = make_controller(tmp_path, decisions) + + assert run(controller.prove_theorem()) + assert len(context.queries) == 1 + assert "and_comm" in context.queries[0].output + assert controller.query_commands == ["Check and_comm."] + + +def test_plan_text_does_not_implicitly_give_up(tmp_path): + decisions = [ + {"type": "plan", "content": "Do not abort; introduce the hypothesis."}, + tactic("intro H."), + tactic("split."), + tactic("exact H.2."), + tactic("exact H.1."), + ] + _backend, _context, controller = make_controller(tmp_path, decisions) + + assert run(controller.prove_theorem()) + assert not controller.give_up + + +def test_abort_requires_confirmation(tmp_path): + backend, _context, controller = make_controller( + tmp_path, [tactic("Abort."), tactic("Abort.")] + ) + + assert not run(controller.prove_theorem()) + assert controller.give_up + assert run(backend.state()).revision == 0 + assert controller.gen_step_count == 1 + + +def test_successful_tactic_clears_abort_confirmation(tmp_path): + decisions = [ + tactic("Abort."), + tactic("intro H."), + tactic("Abort."), + tactic("split."), + tactic("exact H.2."), + tactic("exact H.1."), + ] + _backend, _context, controller = make_controller(tmp_path, decisions) + + assert run(controller.prove_theorem()) + assert not controller.give_up + + +def test_agent_chooses_rollback_and_replays_a_different_branch(tmp_path): + decisions = [ + tactic("intro H."), + tactic("split."), + {"type": "rollback", "content": {"reason": "retry", "steps": 1}}, + tactic("split."), + tactic("exact H.2."), + tactic("exact H.1."), + ] + backend, _context, controller = make_controller(tmp_path, decisions) + + assert run(controller.prove_theorem()) + assert controller.state.is_complete + assert [item["tactic"] for item in controller._tactics_with_states] == [ + "intro H.", + "split.", + "exact H.2.", + "exact H.1.", + ] + + +def test_controller_saves_certificate_only_to_explicit_target(tmp_path): + decisions = [ + tactic("intro H."), + tactic("split."), + tactic("exact H.2."), + tactic("exact H.1."), + ] + backend, _context, controller = make_controller(tmp_path, decisions) + source = controller.state.theorem.source.path + original = source.read_text(encoding="utf-8") + + assert run(controller.prove_theorem()) + destination = tmp_path / "saved.proof" + certificate = run(controller.save_proof(destination)) + assert certificate.destination == destination + assert source.read_text(encoding="utf-8") == original + assert destination.exists() + run(backend.close()) + + +def test_context_search_uses_backend_query(tmp_path): + backend = StubProverBackend() + run(backend.open(theorem(tmp_path))) + search = ContextSearch(backend) + + result = run(search.search("Check and_comm.")) + assert "and_comm" in result.content + assert result.source == "prover_query" + run(backend.close()) + + +def test_interactive_tactic_query_and_rollback_use_backend(tmp_path, capsys): + backend, _context, controller = make_controller(tmp_path, []) + session = InteractiveSessionManager(controller) + + async def scenario(): + assert await controller._init_proof_session() + await session._do_user_tactic("intro H.", silent=True) + assert controller.state.revision == 1 + await session._do_search("Check and_comm.") + await session._do_rollback(1) + + run(scenario()) + assert controller.state.revision == 0 + assert "and_comm" in capsys.readouterr().out + run(backend.close()) diff --git a/proof-search/tests/test_proof_tree_step_by_step.py b/proof-search/tests/test_proof_tree_step_by_step.py index b4dd6dd..4151f1c 100644 --- a/proof-search/tests/test_proof_tree_step_by_step.py +++ b/proof-search/tests/test_proof_tree_step_by_step.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + #!/usr/bin/env python3 """ Test script to visualize proof tree evolution step by step. @@ -11,7 +14,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from agent.context_manager import ContextManager from agent.proof_tree import ProofTree from agent.proof_controller import ProofController @@ -51,8 +54,8 @@ def test_proof_tree_evolution(): config = ProofAgentConfig.from_file(str(config_file)) print(f"✅ Loaded configuration from {config_file}") - # Create CoqInterface - coq_interface = CoqInterface( + # Create CoqPytSession + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=config.coq.workspace or str(coq_file.parent), library_paths=config.coq.library_paths, @@ -61,19 +64,19 @@ def test_proof_tree_evolution(): ) try: - coq_interface.load() - print("✅ CoqInterface loaded") + coqpyt_session.load() + print("✅ CoqPytSession loaded") # Create ContextManager context_manager = ContextManager( - coq_interface=coq_interface, + coqpyt_session=coqpyt_session, api_key=config.llm.api_key ) print("✅ ContextManager created") # Create ProofController - this maintains the proof tree controller = ProofController( - coq_interface=coq_interface, + coqpyt_session=coqpyt_session, context_manager=context_manager, max_steps=100, enable_recording=False @@ -93,9 +96,9 @@ def test_proof_tree_evolution(): print("🌳 Initialized new ProofTree") # Add initial root node to the proof tree - initial_goals = coq_interface.get_goal_str() + initial_goals = coqpyt_session.get_goal_str() if not controller.proof_tree.root: - initial_hypotheses = coq_interface.get_hypothesis() + initial_hypotheses = coqpyt_session.get_hypothesis() controller.proof_tree.add_node( tactic="Proof.", goals_before=initial_goals.strip() if initial_goals else '', @@ -103,7 +106,7 @@ def test_proof_tree_evolution(): hypotheses_before=initial_hypotheses.strip() if initial_hypotheses else '', hypotheses_after=initial_hypotheses.strip() if initial_hypotheses else '', step_number=0, - subgoals_after=coq_interface.get_subgoals() + subgoals_after=coqpyt_session.get_subgoals() ) # Print initial proof tree @@ -135,9 +138,9 @@ def test_proof_tree_evolution(): print('=' * 80) # Get state before for display - subgoals_before = coq_interface.get_subgoals() - goals_before = coq_interface.get_goal_str() - hypotheses_before = coq_interface.get_hypothesis() + subgoals_before = coqpyt_session.get_subgoals() + goals_before = coqpyt_session.get_goal_str() + hypotheses_before = coqpyt_session.get_hypothesis() print(f"\n📊 Before tactic:") print(f" Subgoals count: {len(subgoals_before)}") @@ -146,13 +149,13 @@ def test_proof_tree_evolution(): success = controller._apply_tactic(tactic) if not success: - error = coq_interface.get_last_error() + error = coqpyt_session.get_last_error() raise Exception(f"\n❌ Tactic failed: {error}") # Get state after for display - subgoals_after = coq_interface.get_subgoals() - goals_after = coq_interface.get_goal_str() - hypotheses_after = coq_interface.get_hypothesis() + subgoals_after = coqpyt_session.get_subgoals() + goals_after = coqpyt_session.get_goal_str() + hypotheses_after = coqpyt_session.get_hypothesis() print(f"\n✅ Tactic applied successfully!") print(f"📊 After tactic:") @@ -215,8 +218,8 @@ def test_proof_tree_evolution(): return False finally: - coq_interface.close() - print("\n✅ CoqInterface closed") + coqpyt_session.close() + print("\n✅ CoqPytSession closed") if __name__ == "__main__": @@ -237,4 +240,4 @@ def test_proof_tree_evolution(): print("=" * 80) - sys.exit(0 if success else 1) \ No newline at end of file + sys.exit(0 if success else 1) diff --git a/proof-search/tests/test_prover_backend_contract.py b/proof-search/tests/test_prover_backend_contract.py new file mode 100644 index 0000000..f5694c4 --- /dev/null +++ b/proof-search/tests/test_prover_backend_contract.py @@ -0,0 +1,153 @@ +"""Contract scenarios required of every prover backend implementation.""" + +import asyncio +from pathlib import Path + +import pytest + +from tests.stub_backend import StubProverBackend, InvalidCheckpointError +from backend.prover_backend import ( + CommandKind, CommandRejectedError, HelperLemmaSpec, + InvalidLifecycleError, LifecycleState, ProverBackendError, SourceLocation, TheoremIdentity, +) + + +def run(coroutine): + return asyncio.run(coroutine) + + +@pytest.fixture +def theorem(tmp_path: Path) -> TheoremIdentity: + return TheoremIdentity(SourceLocation(tmp_path / "demo.stub", tmp_path), "demo_theorem") + + +def test_open_named_theorem_exposes_context_and_goal(theorem): + backend = StubProverBackend() + state = run(backend.open(theorem)) + assert state.theorem == theorem + assert state.goals[0].conclusion == "P /\\ Q -> Q /\\ P" + assert [entry.name for entry in state.context.local_entries] == ["P", "Q"] + assert backend.lifecycle is LifecycleState.OPEN + + +def test_open_rejects_unknown_theorem_with_backend_error(theorem): + backend = StubProverBackend() + unknown = TheoremIdentity(theorem.source, "unknown_theorem") + + with pytest.raises(ProverBackendError, match="unknown theorem"): + run(backend.open(unknown)) + assert backend.lifecycle is LifecycleState.CREATED + + +def test_native_command_policy_is_exposed_by_backend(): + backend = StubProverBackend() + commands = backend.helper_lemma_commands(HelperLemmaSpec("Hcomm", "P /\\ Q")) + + assert commands.declaration == "assert (Hcomm: P /\\ Q)" + assert backend.classify_command(commands.open_scope) is CommandKind.STRUCTURAL + assert backend.classify_command("Admitted.") is CommandKind.UNSOUND_COMPLETION + assert backend.classify_command("Abort.") is CommandKind.ABORT + assert backend.automation_command() is None + + +def test_successful_tactic_and_rejection_is_atomic(theorem): + backend = StubProverBackend() + run(backend.open(theorem)) + result = run(backend.apply("intro H.")) + assert result.state.revision == 1 + before = run(backend.state()) + with pytest.raises(CommandRejectedError) as caught: + run(backend.apply("nonsense.")) + assert caught.value.state == before + assert run(backend.state()) == before + + +def test_tactic_can_produce_multiple_subgoals(theorem): + backend = StubProverBackend() + run(backend.open(theorem)) + run(backend.apply("intro H.")) + state = run(backend.apply("split.")).state + assert [goal.conclusion for goal in state.goals] == ["Q", "P"] + + +def test_checkpoint_and_rollback(theorem): + backend = StubProverBackend() + run(backend.open(theorem)) + run(backend.apply("intro H.")) + checkpoint = run(backend.checkpoint()) + run(backend.apply("split.")) + restored = run(backend.rollback(checkpoint)) + assert restored.revision == 1 + assert [goal.conclusion for goal in restored.goals] == ["Q /\\ P"] + foreign = StubProverBackend() + run(foreign.open(theorem)) + with pytest.raises(InvalidCheckpointError): + run(foreign.rollback(checkpoint)) + + +def test_context_query_preserves_state(theorem): + backend = StubProverBackend() + before = run(backend.open(theorem)) + result = run(backend.query("Check and_comm.")) + assert result.output.startswith("and_comm :") + assert run(backend.state()) == before + + +def complete(backend, theorem): + run(backend.open(theorem)) + for command in ("intro H.", "split.", "exact H.2.", "exact H.1."): + run(backend.apply(command)) + + +def test_completion_is_part_of_proof_state(theorem): + backend = StubProverBackend() + complete(backend, theorem) + assert run(backend.state()).is_complete + assert backend.lifecycle is LifecycleState.COMPLETE + with pytest.raises(InvalidLifecycleError): + run(backend.apply("anything.")) + + +def test_certificate_saving_and_overwrite(theorem, tmp_path): + backend = StubProverBackend() + complete(backend, theorem) + destination = tmp_path / "demo.proof" + certificate = run(backend.save_proof(destination)) + assert certificate.destination == destination + assert certificate.commands[-1] == "exact H.1." + assert destination.read_text(encoding="utf-8").startswith("intro H.\n") + with pytest.raises(FileExistsError): + run(backend.save_proof(destination)) + assert run(backend.save_proof(destination, overwrite=True)) == certificate + + +def test_invalid_lifecycle_calls_and_cleanup(theorem, tmp_path): + backend = StubProverBackend() + for operation in ( + backend.state, backend.checkpoint, + lambda: backend.query("Check and_comm."), + lambda: backend.apply("intro H."), + lambda: backend.save_proof(tmp_path / "proof"), + ): + with pytest.raises(InvalidLifecycleError): + run(operation()) + run(backend.open(theorem)) + with pytest.raises(InvalidLifecycleError): + run(backend.open(theorem)) + run(backend.close()) + run(backend.close()) + assert backend.lifecycle is LifecycleState.CLOSED + with pytest.raises(InvalidLifecycleError): + run(backend.state()) + + +def test_context_manager_closes_after_failure(): + backend = StubProverBackend() + + async def use_backend(): + with pytest.raises(RuntimeError): + async with backend: + raise RuntimeError("stop") + + run(use_backend()) + assert backend.lifecycle is LifecycleState.CLOSED diff --git a/proof-search/tests/test_rendering.py b/proof-search/tests/test_rendering.py new file mode 100644 index 0000000..55ad6e1 --- /dev/null +++ b/proof-search/tests/test_rendering.py @@ -0,0 +1,147 @@ +"""Unit tests for prover-agnostic prompt rendering in agent/rendering.py.""" + +from pathlib import Path + +from agent.rendering import ( + render_context_entry, render_global_context, render_goals, + render_hypotheses, render_initial_context, +) +from backend.prover_backend import ( + ContextEntry, Goal, GoalId, ProofState, ProvingContext, SourceLocation, + TheoremIdentity, +) + + +def theorem(name="demo_theorem") -> TheoremIdentity: + return TheoremIdentity(SourceLocation(Path("demo.src"), None), name) + + +def test_render_goals_single_goal_is_bare_conclusion(): + state = ProofState(theorem(), (Goal(GoalId("g0"), "P /\\ Q"),), ProvingContext(), 0) + assert render_goals(state) == "P /\\ Q" + + +def test_render_goals_multiple_goals_are_numbered(): + state = ProofState( + theorem(), + (Goal(GoalId("g0"), "P"), Goal(GoalId("g1"), "Q")), + ProvingContext(), + 1, + ) + assert render_goals(state) == "Goal 1:\nP\n\nGoal 2:\nQ" + + +def test_render_goals_empty_is_empty_string(): + state = ProofState(theorem(), (), ProvingContext(), 0) + assert render_goals(state) == "" + + +def test_render_hypotheses_reads_the_focused_goal(): + hypotheses = ( + ContextEntry("H", "P /\\ Q"), + ContextEntry("n", "nat", value_text="0"), + ) + state = ProofState( + theorem(), (Goal(GoalId("g0"), "Q", hypotheses),), ProvingContext(), 0 + ) + assert render_hypotheses(state) == "H : P /\\ Q\nn := 0 : nat" + + +def test_render_hypotheses_empty_when_no_goals(): + state = ProofState(theorem(), (), ProvingContext(), 0) + assert render_hypotheses(state) == "" + + +def test_render_context_entry_with_and_without_value(): + assert render_context_entry(ContextEntry("H", "P")) == "H : P" + assert ( + render_context_entry(ContextEntry("x", "nat", value_text="1")) + == "x := 1 : nat" + ) + + +def test_render_global_context_caps_and_notes_the_remainder(): + entries = tuple(ContextEntry(f"e{i}", "nat") for i in range(5)) + state = ProofState( + theorem(), (Goal(GoalId("g0"), "True"),), ProvingContext(entries), 0 + ) + rendered = render_global_context(state, limit=3) + assert "e0 : nat" in rendered and "e2 : nat" in rendered + assert "e3 : nat" not in rendered + assert "and 2 more" in rendered + + +def test_render_global_context_empty_when_no_entries(): + state = ProofState(theorem(), (Goal(GoalId("g0"), "True"),), ProvingContext(), 0) + assert render_global_context(state) == "" + + +def test_render_initial_context_includes_theorem_goal_and_hypotheses(): + hypotheses = (ContextEntry("h", "P /\\ Q"),) + context = ProvingContext(global_entries=(ContextEntry("and_comm", "..."),)) + state = ProofState( + theorem("sorted_tail'vc"), + (Goal(GoalId("g0"), "sorted (drop 1 s)", hypotheses),), + context, + 0, + ) + rendered = render_initial_context(state) + assert "Theorem: sorted_tail'vc" in rendered + assert "sorted (drop 1 s)" in rendered + assert "h : P /\\ Q" in rendered + assert "and_comm : ..." in rendered + + +def test_render_initial_context_omits_empty_sections(): + state = ProofState( + theorem(), (Goal(GoalId("g0"), "True"),), ProvingContext(), 0 + ) + rendered = render_initial_context(state) + assert "Hypotheses:" not in rendered + assert "Available global declarations:" not in rendered + + +def test_rendering_caps_each_goal_and_hypothesis_with_explicit_marker(): + events = [] + hypotheses = ( + ContextEntry("huge", "H" * 300), + ContextEntry("small", "P"), + ) + state = ProofState( + theorem(), + (Goal(GoalId("g0"), "G" * 300, hypotheses),), + ProvingContext(), + 0, + ) + goals = render_goals( + state, max_chars=120, on_elision=lambda kind, count: events.append( + (kind, count) + ) + ) + rendered_hypotheses = render_hypotheses( + state, max_chars=120, on_elision=lambda kind, count: events.append( + (kind, count) + ) + ) + + assert len(goals) <= 120 + assert "elided" in goals and "use 'query'" in goals + huge_line, small_line = rendered_hypotheses.splitlines() + assert len(huge_line) <= 120 + assert "elided" in huge_line and "use 'query'" in huge_line + assert small_line == "small : P" + assert [kind for kind, _ in events] == ["goal", "hypothesis"] + + +def test_rendering_under_the_cap_is_byte_identical(): + state = ProofState( + theorem(), + (Goal(GoalId("g0"), "Q", (ContextEntry("h", "P"),)),), + ProvingContext(), + 0, + ) + assert render_goals(state, max_chars=12000) == render_goals(state) + assert render_hypotheses(state, max_chars=12000) == render_hypotheses(state) + assert render_initial_context( + state, max_chars=12000 + ) == render_initial_context(state) diff --git a/proof-search/tests/test_repl_reporting.py b/proof-search/tests/test_repl_reporting.py index 32811c5..af943e6 100644 --- a/proof-search/tests/test_repl_reporting.py +++ b/proof-search/tests/test_repl_reporting.py @@ -10,7 +10,9 @@ class _StubController: - """_report touches nothing on the controller, so a bare object suffices.""" + """Only construction touches the backend; reporting is otherwise isolated.""" + + backend = object() def _session(): diff --git a/proof-search/tests/test_rollback.py b/proof-search/tests/test_rollback.py index 6ade7de..a7bcd8e 100644 --- a/proof-search/tests/test_rollback.py +++ b/proof-search/tests/test_rollback.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import sys from pathlib import Path import time @@ -6,7 +9,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from utils.config import ProofAgentConfig from tests.test_utils import reset_coq_file_to_admitted, restore_coq_file_from_backup @@ -14,11 +17,11 @@ coq_file = PROJECT_ROOT / "examples" / "main_loop_invariant_2_established_Coq.v" config_file = PROJECT_ROOT / "configs" / "default_config.json" -def print_current_goals(coq_interface): - """Print current goals from the CoqInterface""" +def print_current_goals(coqpyt_session): + """Print current goals from the CoqPytSession""" print("\n🎯 Current Goals:") try: - goals_str = coq_interface.get_goal_str() + goals_str = coqpyt_session.get_goal_str() if goals_str and goals_str != "No current goals": print(goals_str) else: @@ -26,11 +29,11 @@ def print_current_goals(coq_interface): except Exception as e: print(f"(Error getting goals: {e})") -def print_current_hypotheses(coq_interface): - """Print current hypotheses from the CoqInterface""" +def print_current_hypotheses(coqpyt_session): + """Print current hypotheses from the CoqPytSession""" print("\n🔍 Current Hypotheses:") try: - hypotheses_str = coq_interface.get_hypothesis() + hypotheses_str = coqpyt_session.get_hypothesis() if hypotheses_str and hypotheses_str.strip(): print(hypotheses_str) else: @@ -38,15 +41,15 @@ def print_current_hypotheses(coq_interface): except Exception as e: print(f"(Error getting hypotheses: {e})") -def print_current_proof_state(coq_interface): +def print_current_proof_state(coqpyt_session): """Print complete current proof state (goals + hypotheses)""" print("\n" + "🔍 CURRENT PROOF STATE".center(50, "=")) # Print hypotheses first (they're the context) - print_current_hypotheses(coq_interface) + print_current_hypotheses(coqpyt_session) # Then print goals (what we're trying to prove) - print_current_goals(coq_interface) + print_current_goals(coqpyt_session) print("=" * 50) @@ -100,10 +103,10 @@ def clean_proof_file(file_path): traceback.print_exc() return False -def print_applied_tactics(coq_interface, label="Applied Tactics"): +def print_applied_tactics(coqpyt_session, label="Applied Tactics"): """Print all tactics applied so far""" try: - current_step = coq_interface.get_current_step_number() + current_step = coqpyt_session.get_current_step_number() print(f"\n📝 {label} (Current step: {current_step}):") print("=" * 50) @@ -112,7 +115,7 @@ def print_applied_tactics(coq_interface, label="Applied Tactics"): return for i in range(1, current_step + 1): - step_info = coq_interface.get_step_info(i) + step_info = coqpyt_session.get_step_info(i) if step_info.get("success", False): tactic_text = step_info.get("text", "Unknown").strip() print(f" Step {i}: {tactic_text}") @@ -136,9 +139,9 @@ def test_rollback_functionality(): print("❌ Failed to clean proof file") return False - # Load configuration and initialize CoqInterface + # Load configuration and initialize CoqPytSession config = ProofAgentConfig.from_file(str(config_file)) - coq_interface = CoqInterface( + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=config.coq.workspace or str(coq_file.parent), library_paths=config.coq.library_paths, @@ -149,16 +152,16 @@ def test_rollback_functionality(): try: # Load the file - success = coq_interface.load() + success = coqpyt_session.load() if not success: - print(f"❌ Failed to load file: {coq_interface.get_last_error()}") + print(f"❌ Failed to load file: {coqpyt_session.get_last_error()}") return False print("✅ File loaded successfully") # Show initial state - print_applied_tactics(coq_interface, "Initial State") - print_current_proof_state(coq_interface) + print_applied_tactics(coqpyt_session, "Initial State") + print_current_proof_state(coqpyt_session) # Define test tactics (the actual sequence needed for the proof) # NOTE: Step 1 is "Proof." which is already applied when we load @@ -183,19 +186,19 @@ def test_rollback_functionality(): print(f"\n📝 Step {step_number}: Applying '{tactic}'") # Show state before applying tactic - print_applied_tactics(coq_interface, f"Before Step {step_number}") + print_applied_tactics(coqpyt_session, f"Before Step {step_number}") # Capture state before tactic - goals_before = coq_interface.get_goal_str() - hypotheses_before = coq_interface.get_hypothesis() + goals_before = coqpyt_session.get_goal_str() + hypotheses_before = coqpyt_session.get_hypothesis() # Apply tactic - success = coq_interface.apply_tactic(tactic) + success = coqpyt_session.apply_tactic(tactic) if success: # Capture state after tactic - goals_after = coq_interface.get_goal_str() - hypotheses_after = coq_interface.get_hypothesis() + goals_after = coqpyt_session.get_goal_str() + hypotheses_after = coqpyt_session.get_hypothesis() # Store tactic with states tactic_data = { @@ -213,16 +216,16 @@ def test_rollback_functionality(): print(f" Goals after: {len(goals_after)} chars") # Show state after applying tactic - print_applied_tactics(coq_interface, f"After Step {step_number}") + print_applied_tactics(coqpyt_session, f"After Step {step_number}") else: - print(f"❌ Step {step_number} FAILED: {coq_interface.get_last_error()}") + print(f"❌ Step {step_number} FAILED: {coqpyt_session.get_last_error()}") return False print(f"\n📊 Applied {len(successful_tactics_with_states)} tactics successfully") # Show final state after all tactics - print_applied_tactics(coq_interface, "After All Tactics") - print_current_proof_state(coq_interface) + print_applied_tactics(coqpyt_session, "After All Tactics") + print_current_proof_state(coqpyt_session) # Test rollback to different steps # NOTE: Step 1 = "Proof.", Step 2 = "intros...", Step 3 = "assert...", etc. @@ -233,10 +236,10 @@ def test_rollback_functionality(): print("="*60) time.sleep(2) # Small delay for clarity # Show current state before rollback attempt - print_applied_tactics(coq_interface, f"Before Rollback to Step {target_step}") + print_applied_tactics(coqpyt_session, f"Before Rollback to Step {target_step}") # Validate target step - current_step = coq_interface.get_current_step_number() + current_step = coqpyt_session.get_current_step_number() max_applied_step = max([t['step_number'] for t in successful_tactics_with_states]) if successful_tactics_with_states else 1 print(f"📊 Current step: {current_step}, Target step: {target_step}, Max applied: {max_applied_step}") @@ -271,34 +274,34 @@ def test_rollback_functionality(): tactic = tactic_data['tactic'] print(f" 📝 Applying step {step_to_apply}: {tactic}") - success = coq_interface.apply_tactic(tactic) + success = coqpyt_session.apply_tactic(tactic) if success: print(f" ✅ Step {step_to_apply} applied successfully") else: - print(f" ❌ Step {step_to_apply} failed: {coq_interface.get_last_error()}") + print(f" ❌ Step {step_to_apply} failed: {coqpyt_session.get_last_error()}") break else: print(f" ❌ No tactic data found for step {step_to_apply}") break # Update current step - current_step = coq_interface.get_current_step_number() + current_step = coqpyt_session.get_current_step_number() print(f"📊 After forward progress: Current step: {current_step}") # Now handle rollback if current_step > target_step if current_step > target_step: # Use efficient reset_by_step method print(f"🔄 Using efficient reset_by_step({target_step}) method") - reset_success = coq_interface.reset_by_step(target_step) + reset_success = coqpyt_session.reset_by_step(target_step) if reset_success: print(f"✅ Successfully reset to step {target_step} using pop method") # Show state after rollback - print_applied_tactics(coq_interface, f"After Rollback to Step {target_step}") + print_applied_tactics(coqpyt_session, f"After Rollback to Step {target_step}") # Verify final step count - final_step = coq_interface.get_current_step_number() + final_step = coqpyt_session.get_current_step_number() print(f"📊 Final step count: {final_step}") if final_step == target_step: @@ -310,7 +313,7 @@ def test_rollback_functionality(): expected_goals = None if target_step == 1: # Step 1 is "Proof." - we know the initial goal - expected_goals = coq_interface.get_goal_str() # Current goals should be the initial goals + expected_goals = coqpyt_session.get_goal_str() # Current goals should be the initial goals else: # Find the tactic data for target_step to get expected goals_after for data in successful_tactics_with_states: @@ -319,7 +322,7 @@ def test_rollback_functionality(): break # Verify state matches expected (if we have expected data) - current_goals = coq_interface.get_goal_str() + current_goals = coqpyt_session.get_goal_str() if expected_goals is not None: print(f"\n🔍 Verification:") print(f" Current goals: {len(current_goals)} chars") @@ -334,7 +337,7 @@ def test_rollback_functionality(): print(f" Expected: {expected_goals[:100]}...") # Show current state - print_current_proof_state(coq_interface) + print_current_proof_state(coqpyt_session) # Test going forward by applying the next tactic next_step = target_step + 1 @@ -352,40 +355,40 @@ def test_rollback_functionality(): # Show expected state before applying next tactic expected_before = next_tactic_data['goals_before'] - current_before = coq_interface.get_goal_str() + current_before = coqpyt_session.get_goal_str() print(f"🔍 State verification before next tactic:") print(f" Current state: {len(current_before)} chars") print(f" Expected state: {len(expected_before)} chars") - forward_success = coq_interface.apply_tactic(next_tactic) + forward_success = coqpyt_session.apply_tactic(next_tactic) if forward_success: print(f"✅ Forward progress successful!") # Show state after forward progress - print_applied_tactics(coq_interface, f"After Forward Progress") + print_applied_tactics(coqpyt_session, f"After Forward Progress") # Verify we're now at next_step - new_step_count = coq_interface.get_current_step_number() + new_step_count = coqpyt_session.get_current_step_number() if new_step_count == next_step: print(f"✅ Step count correct: {new_step_count}") else: print(f"⚠️ Step count unexpected: expected {next_step}, got {new_step_count}") else: - print(f"❌ Forward progress failed: {coq_interface.get_last_error()}") + print(f"❌ Forward progress failed: {coqpyt_session.get_last_error()}") print(f"🔍 Current proof state when forward progress failed:") - print_current_proof_state(coq_interface) + print_current_proof_state(coqpyt_session) else: print(f"💡 No next tactic available for step {next_step} - this is expected if we're at the last step") else: - print(f"❌ Reset to step {target_step} failed: {coq_interface.get_last_error()}") + print(f"❌ Reset to step {target_step} failed: {coqpyt_session.get_last_error()}") return False elif current_step == target_step: print(f"✅ Already at target step {target_step} after adjustments") # Show final state after this rollback test - print_applied_tactics(coq_interface, f"Final State After Rollback Test {target_step}") + print_applied_tactics(coqpyt_session, f"Final State After Rollback Test {target_step}") print("\n" + "="*60) print(f"\n🎉 All rollback tests completed successfully!") @@ -393,7 +396,7 @@ def test_rollback_functionality(): finally: # Clean up - coq_interface.close() + coqpyt_session.close() except Exception as e: print(f"❌ Rollback test failed: {e}") @@ -438,4 +441,4 @@ def test_rollback_functionality(): print(f" - Coq state resets correctly") print(f" - Tactics replay successfully") print(f" - State verification works") - print(f" - Ready for controller integration!") \ No newline at end of file + print(f" - Ready for controller integration!") diff --git a/proof-search/tests/test_state_manager.py b/proof-search/tests/test_state_manager.py index faac773..01ff959 100644 --- a/proof-search/tests/test_state_manager.py +++ b/proof-search/tests/test_state_manager.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import sys from pathlib import Path @@ -5,12 +8,12 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from agent.proof_tree import ProofState if __name__ == "__main__": file_path = str(PROJECT_ROOT / "examples" / "example.v") - coq = CoqInterface(file_path) + coq = CoqPytSession(file_path) coq.load() print("== Initial proof steps ==") diff --git a/proof-search/tests/test_subgoals.py b/proof-search/tests/test_subgoals.py index 288356a..c1bb486 100644 --- a/proof-search/tests/test_subgoals.py +++ b/proof-search/tests/test_subgoals.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import os import sys from pathlib import Path @@ -6,7 +9,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from utils.config import ProofAgentConfig # --- CONFIGURATION --- @@ -22,8 +25,8 @@ def test_intros_tactic(): config = ProofAgentConfig.from_file(str(config_file)) print(f"✅ Loaded configuration from {config_file}") - # Initialize CoqInterface using configuration - coq_interface = CoqInterface( + # Initialize CoqPytSession using configuration + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=config.coq.workspace or str(coq_file.parent), library_paths=config.coq.library_paths, @@ -33,18 +36,18 @@ def test_intros_tactic(): ) try: - print("✅ Created CoqInterface") + print("✅ Created CoqPytSession") # Load the file - success = coq_interface.load() + success = coqpyt_session.load() if not success: - print(f"❌ Failed to load file: {coq_interface.get_last_error()}") + print(f"❌ Failed to load file: {coqpyt_session.get_last_error()}") return False print("✅ File loaded successfully") # Get proof status - status = coq_interface.get_proof_status() + status = coqpyt_session.get_proof_status() print(f"\n📊 Proof status: loaded={status.get('has_proof')}, steps={status.get('proof_steps')}") if not status.get("has_proof", False): @@ -53,18 +56,18 @@ def test_intros_tactic(): # Clear existing proof steps to start fresh print("\n🔄 Clearing existing proof steps...") - if not coq_interface.clear_unproven_proof_steps(): + if not coqpyt_session.clear_unproven_proof_steps(): print("❌ Failed to clear proof steps") return False print("✅ Proof steps cleared") # Verify the proof is clean - should only have "Proof." step - if coq_interface.proof and coq_interface.proof.steps: - step_count = len(coq_interface.proof.steps) + if coqpyt_session.proof and coqpyt_session.proof.steps: + step_count = len(coqpyt_session.proof.steps) print(f"📊 Proof steps after clearing: {step_count}") if step_count == 1: - first_step = coq_interface.proof.steps[0].text.strip() + first_step = coqpyt_session.proof.steps[0].text.strip() print(f" First step: '{first_step}'") if first_step == "Proof.": print("✅ Proof is clean - only contains 'Proof.'") @@ -72,7 +75,7 @@ def test_intros_tactic(): print(f"⚠️ Warning: First step is not 'Proof.': '{first_step}'") else: print(f"⚠️ Warning: Expected 1 step (Proof.), but found {step_count} steps") - for i, step in enumerate(coq_interface.proof.steps): + for i, step in enumerate(coqpyt_session.proof.steps): print(f" Step {i+1}: {step.text.strip()}") else: print("⚠️ Warning: No proof steps found after clearing") @@ -82,9 +85,9 @@ def test_intros_tactic(): print("📊 STATE BEFORE APPLYING intros.") print("="*80) - subgoals_before = coq_interface.get_subgoals() - goals_before = coq_interface.get_goal_str() - hypotheses_before = coq_interface.get_hypothesis() + subgoals_before = coqpyt_session.get_subgoals() + goals_before = coqpyt_session.get_goal_str() + hypotheses_before = coqpyt_session.get_hypothesis() print(f"\n🎯 Goals (raw string):") print(goals_before) @@ -111,9 +114,9 @@ def test_intros_tactic(): print("⚡ APPLYING TACTIC: intros.") print("="*80) - success = coq_interface.apply_tactic("intros.") + success = coqpyt_session.apply_tactic("intros.") if not success: - error = coq_interface.get_last_error() + error = coqpyt_session.get_last_error() print(f"❌ Failed to apply intros.: {error}") return False @@ -124,9 +127,9 @@ def test_intros_tactic(): print("📊 STATE AFTER APPLYING intros.") print("="*80) - subgoals_after = coq_interface.get_subgoals() - goals_after = coq_interface.get_goal_str() - hypotheses_after = coq_interface.get_hypothesis() + subgoals_after = coqpyt_session.get_subgoals() + goals_after = coqpyt_session.get_goal_str() + hypotheses_after = coqpyt_session.get_hypothesis() print(f"\n🎯 Goals (raw string):") print(goals_after) @@ -219,7 +222,7 @@ def test_intros_tactic(): finally: # Always clean up - coq_interface.close() + coqpyt_session.close() except Exception as e: print(f"❌ Testing failed: {e}") diff --git a/proof-search/tests/test_svcomp.py b/proof-search/tests/test_svcomp.py index 32cbb99..20e14b6 100644 --- a/proof-search/tests/test_svcomp.py +++ b/proof-search/tests/test_svcomp.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import sys from pathlib import Path @@ -5,7 +8,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from utils.config import ProofAgentConfig from tests.test_utils import reset_coq_file_to_admitted, restore_coq_file_from_backup @@ -13,11 +16,11 @@ coq_file = PROJECT_ROOT / "examples" / "main_loop_invariant_2_established_Coq.v" config_file = PROJECT_ROOT / "configs" / "default_config.json" -def print_current_goals(coq_interface): - """Print current goals from the CoqInterface""" +def print_current_goals(coqpyt_session): + """Print current goals from the CoqPytSession""" print("\n🎯 Current Goals:") try: - goals_str = coq_interface.get_goal_str() + goals_str = coqpyt_session.get_goal_str() if goals_str and goals_str != "No current goals": print(goals_str) else: @@ -25,11 +28,11 @@ def print_current_goals(coq_interface): except Exception as e: print(f"(Error getting goals: {e})") -def print_current_hypotheses(coq_interface): - """Print current hypotheses from the CoqInterface""" +def print_current_hypotheses(coqpyt_session): + """Print current hypotheses from the CoqPytSession""" print("\n🔍 Current Hypotheses:") try: - hypotheses_str = coq_interface.get_hypothesis() + hypotheses_str = coqpyt_session.get_hypothesis() if hypotheses_str and hypotheses_str.strip(): print(hypotheses_str) else: @@ -37,24 +40,24 @@ def print_current_hypotheses(coq_interface): except Exception as e: print(f"(Error getting hypotheses: {e})") -def print_current_proof_state(coq_interface): +def print_current_proof_state(coqpyt_session): """Print complete current proof state (goals + hypotheses)""" print("\n" + "🔍 CURRENT PROOF STATE".center(50, "=")) # Print hypotheses first (they're the context) - print_current_hypotheses(coq_interface) + print_current_hypotheses(coqpyt_session) # Then print goals (what we're trying to prove) - print_current_goals(coq_interface) + print_current_goals(coqpyt_session) print("=" * 50) -def print_steps(coq_interface): - """Print proof steps using CoqInterface""" +def print_steps(coqpyt_session): + """Print proof steps using CoqPytSession""" print("== Proof Steps ==") try: - if coq_interface.proof and coq_interface.proof.steps: - for i, step in enumerate(coq_interface.proof.steps): + if coqpyt_session.proof and coqpyt_session.proof.steps: + for i, step in enumerate(coqpyt_session.proof.steps): print(f"{i+1}: {step.text.strip()}") else: print("No steps available") @@ -91,8 +94,8 @@ def test_proof_with_correct_tactics(): print(f"✅ Loaded configuration from {config_file}") print(f"📚 Library paths configured: {len(config.coq.library_paths)}") - # Initialize CoqInterface using configuration (agent will auto-create _CoqProject) - coq_interface = CoqInterface( + # Initialize CoqPytSession using configuration (agent will auto-create _CoqProject) + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=config.coq.workspace or str(coq_file.parent), library_paths=config.coq.library_paths, @@ -102,18 +105,18 @@ def test_proof_with_correct_tactics(): ) try: - print("✅ Created CoqInterface with auto-configured libraries") + print("✅ Created CoqPytSession with auto-configured libraries") # Load the cleaned file using agent API - success = coq_interface.load() + success = coqpyt_session.load() if not success: - print(f"❌ Failed to load cleaned file: {coq_interface.get_last_error()}") + print(f"❌ Failed to load cleaned file: {coqpyt_session.get_last_error()}") return False print("✅ Cleaned file loaded successfully") # Get proof status using agent API after loading cleaned file - status = coq_interface.get_proof_status() + status = coqpyt_session.get_proof_status() print(f"📊 Proof status after cleaning: loaded={status.get('has_proof')}, steps={status.get('proof_steps')}") if not status.get("has_proof", False): @@ -126,8 +129,8 @@ def test_proof_with_correct_tactics(): print("\n" + "="*60) print("🚀 INITIAL STATE (AFTER CLEANING)") print("="*60) - print_steps(coq_interface) - print_current_proof_state(coq_interface) # Show both goals and hypotheses + print_steps(coqpyt_session) + print_current_proof_state(coqpyt_session) # Show both goals and hypotheses # Correct tactics based on the provided sequence - KEEPING YOUR EXACT FORMAT tactics = [ @@ -154,22 +157,22 @@ def test_proof_with_correct_tactics(): print(f"📝 STEP {i}/{len(tactics)}: {tactic.strip()}") print('='*60) - # Apply tactic using CoqInterface API - success = coq_interface.apply_tactic(tactic) + # Apply tactic using CoqPytSession API + success = coqpyt_session.apply_tactic(tactic) if success: successful_steps += 1 print("✅ Tactic applied successfully") # Show current proof steps - print_steps(coq_interface) + print_steps(coqpyt_session) # Show COMPLETE proof state (hypotheses + goals) after tactic - print_current_proof_state(coq_interface) + print_current_proof_state(coqpyt_session) # Check if proof is complete using agent API try: - goals_str = coq_interface.get_goal_str() + goals_str = coqpyt_session.get_goal_str() if goals_str == "No current goals" or not goals_str.strip(): if "Qed" in tactic: print("🎉 PROOF COMPLETED WITH QED!") @@ -181,21 +184,21 @@ def test_proof_with_correct_tactics(): else: failed_steps += 1 - error_msg = coq_interface.get_last_error() + error_msg = coqpyt_session.get_last_error() print(f"❌ TACTIC FAILED: {tactic.strip()}") print(f" Error: {error_msg}") # Show current state for debugging using agent APIs print("🔍 Proof state when tactic failed:") - print_steps(coq_interface) - print_current_proof_state(coq_interface) # Show both for debugging + print_steps(coqpyt_session) + print_current_proof_state(coqpyt_session) # Show both for debugging # Continue to try remaining tactics continue finally: # Always clean up - coq_interface.close() + coqpyt_session.close() except Exception as e: print(f"❌ Proof testing failed: {e}") @@ -216,4 +219,4 @@ def test_proof_with_correct_tactics(): # Test proof with correct tactics using agent APIs and config print("🧪 Testing proof with agent APIs using config file...") - test_proof_with_correct_tactics() \ No newline at end of file + test_proof_with_correct_tactics() diff --git a/proof-search/tests/test_svcomp_helper_lemma.py b/proof-search/tests/test_svcomp_helper_lemma.py index 781f798..0daf750 100644 --- a/proof-search/tests/test_svcomp_helper_lemma.py +++ b/proof-search/tests/test_svcomp_helper_lemma.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import sys import os from pathlib import Path @@ -6,7 +9,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession TEST_CONTENT = """Definition is_sint32 (x : nat) : Prop := @@ -19,11 +22,11 @@ """ -def print_current_goals(coq_interface): - """Print current goals from the CoqInterface.""" +def print_current_goals(coqpyt_session): + """Print current goals from the CoqPytSession.""" print("\nCurrent Goals:") try: - goals_str = coq_interface.get_goal_str() + goals_str = coqpyt_session.get_goal_str() if goals_str and goals_str != "No current goals": print(goals_str) else: @@ -32,11 +35,11 @@ def print_current_goals(coq_interface): print(f"(Error getting goals: {e})") -def print_current_hypotheses(coq_interface): - """Print current hypotheses from the CoqInterface.""" +def print_current_hypotheses(coqpyt_session): + """Print current hypotheses from the CoqPytSession.""" print("\nCurrent Hypotheses:") try: - hypotheses_str = coq_interface.get_hypothesis() + hypotheses_str = coqpyt_session.get_hypothesis() if hypotheses_str and hypotheses_str.strip(): print(hypotheses_str) else: @@ -45,22 +48,22 @@ def print_current_hypotheses(coq_interface): print(f"(Error getting hypotheses: {e})") -def print_current_proof_state(coq_interface): +def print_current_proof_state(coqpyt_session): """Print complete current proof state.""" print("\n" + "CURRENT PROOF STATE".center(80, "=")) - print_current_hypotheses(coq_interface) - print_current_goals(coq_interface) + print_current_hypotheses(coqpyt_session) + print_current_goals(coqpyt_session) print("=" * 80) def test_helper_lemma_completion_closes_subproof(tmp_path): - """Test CoqInterface helper-lemma brace completion on an SV-COMP-style goal.""" + """Test CoqPytSession helper-lemma brace completion on an SV-COMP-style goal.""" coq_file = tmp_path / "svcomp_helper_lemma.v" coq_file.write_text(TEST_CONTENT, encoding="utf-8") old_home = os.environ.get("HOME") os.environ["HOME"] = str(tmp_path) - coq_interface = CoqInterface( + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=str(tmp_path), timeout=20, @@ -68,7 +71,7 @@ def test_helper_lemma_completion_closes_subproof(tmp_path): try: print(f"Loading file: {coq_file}") - assert coq_interface.load(), coq_interface.get_last_error() + assert coqpyt_session.load(), coqpyt_session.get_last_error() tactics = [ "intros i.", @@ -78,10 +81,10 @@ def test_helper_lemma_completion_closes_subproof(tmp_path): for i, tactic in enumerate(tactics, 1): print(f"\nStep {i}: Applying tactic: {tactic}") - assert coq_interface.apply_tactic(tactic), coq_interface.get_last_error() - print_current_proof_state(coq_interface) + assert coqpyt_session.apply_tactic(tactic), coqpyt_session.get_last_error() + print_current_proof_state(coqpyt_session) - assert not coq_interface.is_helper_lemma_proof_complete() + assert not coqpyt_session.is_helper_lemma_proof_complete() print("\nSolving helper lemma sub-proof") helper_tactics = [ @@ -91,22 +94,22 @@ def test_helper_lemma_completion_closes_subproof(tmp_path): ] for tactic in helper_tactics: - assert coq_interface.apply_tactic(tactic), coq_interface.get_last_error() + assert coqpyt_session.apply_tactic(tactic), coqpyt_session.get_last_error() - print_current_proof_state(coq_interface) + print_current_proof_state(coqpyt_session) - assert coq_interface.is_helper_lemma_proof_complete() - assert coq_interface.proof.steps[-1].text.strip() == "}" + assert coqpyt_session.is_helper_lemma_proof_complete() + assert coqpyt_session.proof.steps[-1].text.strip() == "}" print("\nSolving main proof with helper lemma in context") - assert coq_interface.apply_tactic("apply Hhelper."), coq_interface.get_last_error() + assert coqpyt_session.apply_tactic("apply Hhelper."), coqpyt_session.get_last_error() - status = coq_interface.get_proof_completion_status() + status = coqpyt_session.get_proof_completion_status() assert status["is_complete"], status assert status["qed_already_applied"], status finally: - coq_interface.close() + coqpyt_session.close() if old_home is None: os.environ.pop("HOME", None) else: diff --git a/proof-search/tests/test_svcomp_llm.py b/proof-search/tests/test_svcomp_llm.py index bef3296..22c8de2 100644 --- a/proof-search/tests/test_svcomp_llm.py +++ b/proof-search/tests/test_svcomp_llm.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import os import sys from pathlib import Path @@ -8,7 +11,7 @@ import logging -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from agent.context_manager import ContextManager from agent.proof_controller import ProofController from utils.config import ProofAgentConfig @@ -17,12 +20,12 @@ coq_file = PROJECT_ROOT / "examples" / "main_loop_invariant_2_established_Coq.v" config_file = PROJECT_ROOT / "configs" / "default_config.json" -def print_current_goals(coq_interface): - """Print current goals from the CoqInterface""" +def print_current_goals(coqpyt_session): + """Print current goals from the CoqPytSession""" ''' print("\n🎯 Current Goals:") try: - goals_str = coq_interface.get_goal_str() + goals_str = coqpyt_session.get_goal_str() if goals_str and goals_str != "No current goals": print(goals_str) else: @@ -32,12 +35,12 @@ def print_current_goals(coq_interface): ''' pass -def print_steps(coq_interface): - """Print proof steps using CoqInterface""" +def print_steps(coqpyt_session): + """Print proof steps using CoqPytSession""" print("== Proof Steps ==") try: - if coq_interface.proof and coq_interface.proof.steps: - for i, step in enumerate(coq_interface.proof.steps): + if coqpyt_session.proof and coqpyt_session.proof.steps: + for i, step in enumerate(coqpyt_session.proof.steps): print(f"{i+1}: {step.text.strip()}") else: print("No steps available") @@ -121,8 +124,8 @@ def test_llm_proof_generation_with_controller(): print(f" - {lib['name']}: {lib['path']}") print(f"⚙️ Auto setup CoqProject: {config.coq.auto_setup_coqproject}") - # Initialize CoqInterface using configuration - coq_interface = CoqInterface( + # Initialize CoqPytSession using configuration + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=config.coq.workspace or str(coq_file.parent), library_paths=config.coq.library_paths, @@ -132,12 +135,12 @@ def test_llm_proof_generation_with_controller(): ) try: - print("✅ Created CoqInterface with auto-configured libraries") + print("✅ Created CoqPytSession with auto-configured libraries") # Load the cleaned file - success = coq_interface.load() + success = coqpyt_session.load() if not success: - print(f"❌ Failed to load cleaned file: {coq_interface.get_last_error()}") + print(f"❌ Failed to load cleaned file: {coqpyt_session.get_last_error()}") return False print("✅ Cleaned file loaded successfully") @@ -145,7 +148,7 @@ def test_llm_proof_generation_with_controller(): # Initialize ContextManager print("🤖 Initializing LLM ContextManager...") context_manager = ContextManager( - coq_interface, + coqpyt_session, api_key=config.llm.api_key, enable_history_context=getattr(config, "enable_history_context", True) ) @@ -173,7 +176,7 @@ def test_llm_proof_generation_with_controller(): # Initialize ProofController with updated parameters print("🔧 Initializing ProofController with error handling...") controller = ProofController( - coq_interface=coq_interface, + coqpyt_session=coqpyt_session, context_manager=context_manager, max_steps=100, # Reasonable limit for testing enable_context_search=getattr(config, "enable_context_search", True), @@ -183,7 +186,7 @@ def test_llm_proof_generation_with_controller(): print("✅ ProofController initialized successfully") # Get proof status after loading cleaned file - status = coq_interface.get_proof_status() + status = coqpyt_session.get_proof_status() print(f"📊 Proof status after cleaning: loaded={status.get('has_proof')}, steps={status.get('proof_steps')}") if not status.get("has_proof", False): @@ -196,8 +199,8 @@ def test_llm_proof_generation_with_controller(): print("\n" + "="*60) print("🚀 INITIAL STATE (AFTER CLEANING)") print("="*60) - print_steps(coq_interface) - print_current_goals(coq_interface) + print_steps(coqpyt_session) + print_current_goals(coqpyt_session) # Extract theorem name from the file theorem_name = "main_loop_invariant_2_established" @@ -257,7 +260,7 @@ def test_llm_proof_generation_with_controller(): print(f" - Query usage: {len(controller.query_commands) / total_commands * 100:.1f}%") # Check final proof status - final_status = coq_interface.get_proof_status() + final_status = coqpyt_session.get_proof_status() is_complete = is_successful print(f"\n🎯 Final Proof Status:") @@ -265,16 +268,16 @@ def test_llm_proof_generation_with_controller(): print(f" - Proof steps: {final_status.get('proof_steps', 'unknown')}") try: - is_actually_complete = coq_interface.is_proof_complete() - print(f" - CoqInterface reports complete: {is_actually_complete}") + is_actually_complete = coqpyt_session.is_proof_complete() + print(f" - CoqPytSession reports complete: {is_actually_complete}") is_complete = is_successful or is_actually_complete except Exception as e: print(f" - Error checking completion: {e}") # Show final proof structure print(f"\n📋 Final proof structure generated by controller:") - print_steps(coq_interface) - print_current_goals(coq_interface) + print_steps(coqpyt_session) + print_current_goals(coqpyt_session) # Evaluate the error handling effectiveness with enhanced metrics if is_successful: @@ -306,7 +309,7 @@ def test_llm_proof_generation_with_controller(): finally: # Always clean up - coq_interface.close() + coqpyt_session.close() except Exception as e: print(f"❌ ProofController test failed: {e}") diff --git a/proof-search/tests/test_tactic_history.py b/proof-search/tests/test_tactic_history.py index 5957184..aa4a31b 100644 --- a/proof-search/tests/test_tactic_history.py +++ b/proof-search/tests/test_tactic_history.py @@ -1,3 +1,6 @@ +import pytest +pytestmark = pytest.mark.integration + import os import sys import json @@ -7,7 +10,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from backend.coq_interface import CoqInterface +from backend.rocq.session import CoqPytSession from agent.history_recorder import TacticHistoryManager, TacticHistoryEntry from utils.config import ProofAgentConfig @@ -25,7 +28,7 @@ def clean_ansi_codes(text): return ansi_escape.sub('', text) def debug_coq_setup(): - """Debug the CoqInterface setup process step by step""" + """Debug the CoqPytSession setup process step by step""" print("\n🔍 DEBUGGING COQ SETUP PROCESS") print("=" * 50) @@ -92,19 +95,19 @@ def debug_coq_setup(): traceback.print_exc() return False - # Create CoqInterface - print(f"🔧 Creating CoqInterface...") + # Create CoqPytSession + print(f"🔧 Creating CoqPytSession...") try: - coq_interface = CoqInterface( + coqpyt_session = CoqPytSession( file_path=str(coq_file), workspace=config.coq.workspace or str(coq_file.parent), library_paths=config.coq.library_paths, auto_setup_coqproject=config.coq.auto_setup_coqproject, timeout=config.coq.timeout ) - print(f"✅ CoqInterface created successfully") + print(f"✅ CoqPytSession created successfully") except Exception as e: - print(f"❌ Failed to create CoqInterface: {e}") + print(f"❌ Failed to create CoqPytSession: {e}") import traceback traceback.print_exc() return False @@ -112,11 +115,11 @@ def debug_coq_setup(): # Try to load the file print(f"📂 Loading Coq file...") try: - load_result = coq_interface.load() + load_result = coqpyt_session.load() if load_result: - print(f"✅ CoqInterface loaded successfully") + print(f"✅ CoqPytSession loaded successfully") else: - error_msg = coq_interface.get_last_error() + error_msg = coqpyt_session.get_last_error() print(f"❌ Failed to load file: {error_msg}") return False except Exception as e: @@ -129,8 +132,8 @@ def debug_coq_setup(): print(f"🧪 Testing simple tactic application...") try: # Get initial state - initial_goals = clean_ansi_codes(coq_interface.get_goal_str()) - initial_hypotheses = clean_ansi_codes(coq_interface.get_hypothesis()) + initial_goals = clean_ansi_codes(coqpyt_session.get_goal_str()) + initial_hypotheses = clean_ansi_codes(coqpyt_session.get_hypothesis()) print(f"📊 Initial state:") print(f" - Goals: {initial_goals[:100]}...") @@ -140,14 +143,14 @@ def debug_coq_setup(): test_tactic = "intros i_1 i Hle Hlow Hup Hup_i Hsint." print(f"🎯 Applying tactic: {test_tactic}") - success = coq_interface.apply_tactic(test_tactic) + success = coqpyt_session.apply_tactic(test_tactic) if success: print(f"✅ Tactic applied successfully") # Get new state - new_goals = clean_ansi_codes(coq_interface.get_goal_str()) - new_hypotheses = clean_ansi_codes(coq_interface.get_hypothesis()) + new_goals = clean_ansi_codes(coqpyt_session.get_goal_str()) + new_hypotheses = clean_ansi_codes(coqpyt_session.get_hypothesis()) print(f"📊 New state:") print(f" - Goals: {new_goals[:100]}...") @@ -202,7 +205,7 @@ def debug_coq_setup(): print(f"❌ File not created") return False else: - error_msg = coq_interface.get_last_error() + error_msg = coqpyt_session.get_last_error() print(f"❌ Tactic failed: {error_msg}") return False @@ -214,8 +217,8 @@ def debug_coq_setup(): finally: try: - coq_interface.close() - print(f"🔒 CoqInterface closed") + coqpyt_session.close() + print(f"🔒 CoqPytSession closed") except: pass diff --git a/proof-search/tests/test_test_utils.py b/proof-search/tests/test_test_utils.py new file mode 100644 index 0000000..61b5530 --- /dev/null +++ b/proof-search/tests/test_test_utils.py @@ -0,0 +1,29 @@ +"""Tests for helpers that temporarily rewrite prover fixtures.""" + +from tests.test_utils import ( + reset_coq_file_to_admitted, + restore_coq_file_from_backup, +) + + +def test_reset_inline_coq_proof_and_restore_exact_source(tmp_path): + source = tmp_path / "Inline.v" + original = ( + "Lemma first : True.\n" + "Proof. exact I. Qed.\n" + "Lemma second : True.\n" + "Proof. exact I. Qed.\n" + ) + source.write_text(original, encoding="utf-8") + + assert reset_coq_file_to_admitted(source, backup=True) + assert source.read_text(encoding="utf-8") == ( + "Lemma first : True.\n" + "Proof.\n" + "Admitted.\n" + "Lemma second : True.\n" + "Proof. exact I. Qed.\n" + ) + + assert restore_coq_file_from_backup(source) + assert source.read_text(encoding="utf-8") == original diff --git a/proof-search/tests/test_utils.py b/proof-search/tests/test_utils.py index d257461..2426ae2 100644 --- a/proof-search/tests/test_utils.py +++ b/proof-search/tests/test_utils.py @@ -1,15 +1,17 @@ +import re import shutil from pathlib import Path -from typing import Optional # Project root for tests PROJECT_ROOT = Path(__file__).parent.parent +_PROOF_START = re.compile(r"(? bool: """ Reset a Coq file so its first proof ends with Admitted. instead of Qed. - This makes it an "unproven" proof that coqpyt and CoqInterface can work with. + This makes it an "unproven" proof that the Rocq backend can work with. Args: file_path: Path to the .v file @@ -22,49 +24,26 @@ def reset_coq_file_to_admitted(file_path: Path, backup: bool = True) -> bool: if not file_path.exists(): return False - - # Create backup if requested + + content = file_path.read_text(encoding="utf-8") + proof_start = _PROOF_START.search(content) + if proof_start is None: + return False + + proof_end = _PROOF_END.search(content, proof_start.end()) + if proof_end is None: + return False + if backup: - backup_path = file_path.with_suffix('.v.backup') + backup_path = file_path.with_suffix(".v.backup") shutil.copy2(file_path, backup_path) - - with open(file_path, 'r') as f: - content = f.read() - - # Find the first proof block and reset it to just Proof. Admitted. - lines = content.split('\n') - clean_lines = [] - in_proof = False - proof_found = False - - for line in lines: - stripped = line.strip() - - # Start of proof - if stripped.startswith('Proof.') and not proof_found: - clean_lines.append(line) - clean_lines.append('Admitted.') # Add Admitted. right after Proof. - in_proof = True - proof_found = True - continue - - # End of proof - skip everything until we see Qed. or Admitted. - if in_proof: - if stripped in ('Qed.', 'Admitted.', 'Defined.'): - in_proof = False - # Skip all lines inside the proof - continue - - clean_lines.append(line) - - if not proof_found: - return False - - # Write clean content back - clean_content = '\n'.join(clean_lines) - with open(file_path, 'w') as f: - f.write(clean_content) - + + clean_content = ( + content[:proof_start.start()] + + "Proof.\nAdmitted." + + content[proof_end.end():] + ) + file_path.write_text(clean_content, encoding="utf-8") return True @@ -91,4 +70,3 @@ def restore_coq_file_from_backup(file_path: Path) -> bool: def get_example_file() -> Path: """Get path to the standard example.v test file.""" return PROJECT_ROOT / "examples" / "example.v" - diff --git a/proof-search/utils/config.py b/proof-search/utils/config.py index 66b1dd7..6f56961 100644 --- a/proof-search/utils/config.py +++ b/proof-search/utils/config.py @@ -9,11 +9,13 @@ class LLMConfig: """Configuration for LLM settings.""" model: str = "openai/gpt-4.1" temperature: float = 0.1 + reasoning_effort: Optional[str] = None max_tokens: int = 512 api_key: Optional[str] = None api_base: Optional[str] = None timeout: int = 30 enable_caching: bool = True + max_cost_usd: Optional[float] = None @dataclass class CoqConfig: @@ -35,11 +37,20 @@ class InteractiveConfig: """Configuration for interactive co-development mode.""" enabled: bool = False +@dataclass +class BackendConfig: + """Selected prover backend.""" + name: str = "rocq" + + + + @dataclass class ProofAgentConfig: """Main configuration for the proof agent.""" llm: LLMConfig coq: CoqConfig + backend: BackendConfig = field(default_factory=BackendConfig) interactive: InteractiveConfig = field(default_factory=InteractiveConfig) # General settings @@ -57,6 +68,7 @@ class ProofAgentConfig: enable_rollback: bool = True max_context_search: int = 3 max_errors: int = 3 + max_state_chars: int = 12_000 @classmethod def from_file(cls, config_path: str) -> 'ProofAgentConfig': @@ -82,6 +94,7 @@ def from_file(cls, config_path: str) -> 'ProofAgentConfig': return cls( llm=LLMConfig(**llm_config), coq=CoqConfig(**config_dict.get('coq', {})), + backend=BackendConfig(**config_dict.get('backend', {})), interactive=InteractiveConfig(**config_dict.get('interactive', {})), log_level=config_dict.get("log_level", "INFO"), log_file=config_dict.get("log_file"), @@ -95,6 +108,7 @@ def from_file(cls, config_path: str) -> 'ProofAgentConfig': enable_recording=ablation.get("enable_recording", True), max_context_search=ablation.get("max_context_search", 3), max_errors=ablation.get("max_errors", 3), + max_state_chars=ablation.get("max_state_chars", 12_000), ) @classmethod @@ -140,6 +154,7 @@ def save_to_file(self, config_path: str): config_dict = { 'llm': asdict(self.llm), 'coq': asdict(self.coq), + 'backend': asdict(self.backend), 'enable_rollback': self.enable_rollback, 'log_level': self.log_level, 'log_file': self.log_file, @@ -153,7 +168,7 @@ def update_from_dict(self, updates: Dict[str, Any]): """Update configuration from dictionary.""" for key, value in updates.items(): if hasattr(self, key): - if key in ['llm', 'coq']: + if key in ['llm', 'coq', 'backend']: # Update nested config objects config_obj = getattr(self, key) for sub_key, sub_value in value.items(): diff --git a/proof-search/utils/recorder.py b/proof-search/utils/recorder.py index 18e728b..eae81c8 100644 --- a/proof-search/utils/recorder.py +++ b/proof-search/utils/recorder.py @@ -19,7 +19,6 @@ from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Any -from coqpyt.coq.proof_file import ProofFile from utils.logger import setup_logger @@ -76,7 +75,7 @@ def _load_existing_records(self): # Don't clear existing records on error - keep what we have pass - def start_proof_recording(self, proof_file: ProofFile, theorem_name: str = None, metadata: Dict[str, Any] = None): + def start_proof_recording(self, proof_file: Path, theorem_name: str = None, metadata: Dict[str, Any] = None): """ Start recording a new proof attempt. @@ -95,8 +94,8 @@ def start_proof_recording(self, proof_file: ProofFile, theorem_name: str = None, self.active_proof = { 'session_id': self.current_session_id, - 'proof_file': proof_file.path, - 'proof_file_full_path': proof_file.path, + 'proof_file': str(proof_file), + 'proof_file_full_path': str(proof_file), 'theorem_name': theorem_name or 'unnamed', 'start_time': datetime.now().isoformat(), 'metadata': metadata or {}, diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..453ca67 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +markers = + integration: requires an installed external prover + live_api: makes an explicitly authorized paid external model request diff --git a/requirement.txt b/requirement.txt index 8db4e22..7930f1a 100644 --- a/requirement.txt +++ b/requirement.txt @@ -1,4 +1,6 @@ -packaging +# CoqPyt is vendored from v1.1.0; see proof-search/coqpyt/UPSTREAM.toml. +packaging>=25.0 +pyyaml>=6.0.0,<6.1.0 pytest litellm graphviz