diff --git a/composer/io/context.py b/composer/io/context.py index c2fa1ba3..d6a8e326 100644 --- a/composer/io/context.py +++ b/composer/io/context.py @@ -138,6 +138,23 @@ def emit_custom_event(payload: Mapping[str, Any]): raise ValueError("No IO handler installed") curr_io[0].push(ProgressEvent(dict(payload))) + +def push_custom_update( + payload: Mapping[str, Any], *, thread_id: str, checkpoint_id: str = "" +) -> bool: + """Push a ``CustomUpdate`` to the current ``with_handler`` scope's queue so it + reaches ``EventHandler.handle_event`` — the out-of-graph analogue of + ``get_stream_writer()``. Used by backends (e.g. the Rust IoC loop) that emit + domain events *between* graph calls, where ``get_stream_writer()`` is unavailable. + Returns ``False`` (event dropped) if no handler scope is installed.""" + curr_io = _io_handler.get() + if curr_io is None: + return False + curr_io[0].push( + CustomUpdate(payload=dict(payload), thread_id=thread_id, checkpoint_id=checkpoint_id) + ) + return True + async def run_graph[S: StateLike, C: StateLike | None, I: StateLike]( graph: CompiledStateGraph[S, C, I, Any], ctxt: C, diff --git a/composer/pipeline/ecosystem.py b/composer/pipeline/ecosystem.py index 23a57d9d..e9f62449 100644 --- a/composer/pipeline/ecosystem.py +++ b/composer/pipeline/ecosystem.py @@ -185,10 +185,21 @@ def _evm_analysis_extra_input(source: SourceCode) -> list[str | dict]: #: Cargo/Anchor project layout: hide build output, VCS, lockfiles, and the JS side; keep the #: crate sources and `tests/`. (Contrast the Foundry-shaped ``FS_FORBIDDEN_READ``.) RUST_FORBIDDEN_READ = r"(^target/.*)|(^\.git.*)|(^node_modules/.*)|(.*\.lock$)" -# NOTE: the confined-build scratch dirs (``.sandbox_cargo`` / ``.sandbox_rustup`` / -# ``.sandbox_tmp`` and nested ``target/``) are also excluded, but that extension lives with the -# rust-framework layer that introduces confined Rust builds — no build runs in this front-half, so -# those dirs never exist here. +# The pipeline generates hundreds of MB of build/scratch *inside the workdir* mid-run, which the +# source tools' file-listing would otherwise pull into LLM context and blow the model's window: +# • ``.sandbox_cargo`` — the command sandbox's private CARGO_HOME (docs/command-sandbox.md §3); +# a build fills it with the *entire* cargo registry (~19.5k files, ~520 MB). +# • ``.sandbox_rustup`` — the sandbox's private RUSTUP_HOME; its ``toolchains`` is a symlink to +# the shared rustup home, so a naive listing would enumerate the whole Rust toolchain. +# • ``.sandbox_tmp`` — the sandbox's private linker TMPDIR. +# • nested ``target/`` — cargo build output below the root (e.g. the generated +# ``fuzz//target``, ~4k files, ~900 MB); the top-level ``^target/`` above misses it. +# These are never source, so they are never readable by the source tools (belt-and-suspenders with +# each run's own cleanup: a re-run or cached CI workspace can leave them behind). +RUST_FORBIDDEN_READ = ( + RUST_FORBIDDEN_READ + + r"|(^\.sandbox_cargo/.*)|(^\.sandbox_rustup/.*)|(^\.sandbox_tmp/.*)|(.*/target/.*)" +) RUST_CODE_EXPLORER_PROMPT = """\ You are a code-exploration assistant analyzing Rust source for on-chain programs (e.g. Solana diff --git a/composer/rustapp/__init__.py b/composer/rustapp/__init__.py new file mode 100644 index 00000000..4489ce7c --- /dev/null +++ b/composer/rustapp/__init__.py @@ -0,0 +1,137 @@ +"""Host for AutoProver applications whose backend is *implemented* in Rust (PyO3). + +"Rust" here is the **backend implementation** language — the wheel is compiled Rust — and it is +orthogonal to the ecosystem, i.e. the language of the *code being analyzed*: a Rust-implemented +backend may analyze Solidity (``echoprover`` selects ecosystem ``evm``) or Rust (Crucible selects +``solana``). The analyzed-source language rides on the ecosystem (see +``composer.pipeline.ecosystem.Language``); this host never assumes it from the fact that the +backend is a Rust wheel — it reads it from ``app.ecosystem.language``. + +This package is the Python side of the seam described in +``docs/rust-backend-api.md``. A Rust application is a wheel built with +``autoprover-sdk`` (see ``rust/``) exposing a small, synchronous, JSON FFI surface +— a **passive service** the pipeline drives: + + descriptor() -> str # the AppDescriptor (declarative spine) + validate_preconditions(args_json) -> str|None + units(input_json) -> str # the report rows / validation units + author_prompt(input_json, failure_json|None) -> str + judge_prompt(input_json, spec) -> str|None + compile(input_json, spec, workdir, sandbox_json) -> str # BLOCKING (run-confined) + validate(input_json, spec, unit, workdir, sandbox_json) -> str # BLOCKING (run-confined) + finalize(outcomes_json) -> str|None + +The host loads that module, synthesizes the pipeline's phase enum from the +descriptor, and wraps the module in a :class:`PipelineBackend` whose ``formalize`` +runs the author→compile→judge→validate loop (:mod:`composer.rustapp.adapter`) — +Python owns the loop and every LLM turn; the two blocking callouts run the toolchain +via ``run-confined``. No IoC ``resume`` protocol and no ``pyo3-async`` bridge. + +Entry points: + +* :func:`composer.rustapp.cli.tui_main` / ``console_main`` — a complete runnable + application from a module name (the descriptor drives argparse, the entry point, + the frontend, and ``main()``). This is the whole vertical. +* :func:`composer.rustapp.host.build_application` — synthesize the phase enum, + labels, section order and backend factory for a frontend / ``main()``. +* :func:`composer.rustapp.entry.rust_entry_point` — the async entry point context + manager (services + ``WorkflowContext``), yielding the Executor. +* :func:`composer.rustapp.host.run_rust_pipeline` — headless: build the backend + from a module name and run the shared driver directly. +""" + +from composer.rustapp.descriptor import ( + AppDescriptor, + ArgDefault, + ArgSpec, + ArtifactLayout, + CoreSlot, + DeliverableMode, + EventKind, + PhaseSpec, + SetupSpec, +) +from composer.rustapp.result import RustArtifact, RustFormalResult +from composer.rustapp.adapter import ( + RustBackend, + RustFormalizer, + RustPreparedSystem, + as_report_backend, + author_and_compile, + make_emitter, + unique_slugs, +) +from composer.rustapp.store import RustArtifactStore +from composer.rustapp.host import ( + BackendOptions, + RustApplication, + StoreFactory, + build_application, + build_backend, + build_core_phases, + build_phase_enum, + load_descriptor, + load_module, + resolve_ecosystem, + run_application, + run_rust_pipeline, +) +from composer.rustapp.entry import ( + EnvBuilder, + RustRunner, + build_arg_parser, + build_neutral_env, + rust_entry_point, +) +from composer.rustapp.frontend import ( + GenericRustApp, + GenericRustConsoleHandler, + GenericRustTaskHandler, +) + +# NOTE: composer.rustapp.cli is intentionally NOT imported here — it runs +# `import composer.bind` (import-time DI / test-tape bootstrap), which the +# built-in apps only trigger from their `main` modules. Import it explicitly: +# from composer.rustapp.cli import tui_main, console_main + +__all__ = [ + "AppDescriptor", + "ArgDefault", + "ArgSpec", + "ArtifactLayout", + "CoreSlot", + "DeliverableMode", + "EventKind", + "PhaseSpec", + "SetupSpec", + "RustArtifact", + "RustFormalResult", + "RustBackend", + "RustFormalizer", + "RustPreparedSystem", + "as_report_backend", + "author_and_compile", + "make_emitter", + "unique_slugs", + "RustArtifactStore", + "RustApplication", + "BackendOptions", + "StoreFactory", + "build_application", + "build_backend", + "build_core_phases", + "build_phase_enum", + "load_descriptor", + "load_module", + "resolve_ecosystem", + "run_application", + "run_rust_pipeline", + "EnvBuilder", + "RustRunner", + "build_arg_parser", + "build_neutral_env", + "rust_entry_point", + "GenericRustApp", + "GenericRustConsoleHandler", + "GenericRustTaskHandler", +] diff --git a/composer/rustapp/adapter.py b/composer/rustapp/adapter.py new file mode 100644 index 00000000..895dbca0 --- /dev/null +++ b/composer/rustapp/adapter.py @@ -0,0 +1,770 @@ +"""Adapter: wrap a Rust wheel (a :class:`~autoprover_sdk.Backend`) as a +:class:`~composer.pipeline.core.PipelineBackend`. + +The Rust wheel is a **passive service** (``docs/rust-backend-api.md``): Python owns the +author→compile→judge→validate loop and every LLM turn, and calls the wheel's pure callouts +(``descriptor`` / ``units`` / ``author_prompt`` / ``judge_prompt`` / ``finalize``) plus the two +blocking ones (``compile`` / ``validate``) that run the toolchain via ``run-confined``. There is +no IoC ``resume`` loop and no ``Effects`` protocol. + +Three phase objects mirror the CVL / foundry backends: + +* :class:`RustBackend` — ``PipelineBackend`` (guidance, phases, store, ``prepare_system``). +* :class:`RustPreparedSystem` — builds the formalizer (thin; no app-specific setup). +* :class:`RustFormalizer` — ``formalize`` runs the loop; ``fetch_verdicts`` reads the verdicts + ``validate`` baked into the result. + +App-specific orchestration (a shared setup artifact, workspace prep, crate assembly) is +descriptor-driven here — no per-application Python package (``docs/rust-pure-app.md``): the wheel +declares ``setup`` / ``workspace_prep`` / ``deliverable_mode=callout`` / ``finalize`` and the +generic :class:`RustPreparedSystem` runs them. +""" + +import asyncio +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Awaitable, Callable, NotRequired, cast, get_args, override + +from pydantic import Field +from graphcore.graph import FlowInput, tool_state_update +from graphcore.tools.schemas import WithAsyncDependencies, WithInjectedId +from langgraph.graph import MessagesState +from langgraph.types import Command + +from composer.io.multi_job import TaskInfo +from composer.pipeline.core import ( + CorePhases, + Formalizer, + GaveUp, + PipelineRun, + PreparedSystem, + SystemAnalysisSpec, +) +from composer.pipeline.ecosystem import Ecosystem +from composer.sandbox.command import DEFAULT_TIMEOUT_S +from composer.sandbox.config import BackendSpec, SandboxConfig +from composer.rustapp.descriptor import AppDescriptor +from composer.rustapp.result import RustArtifact, RustFormalResult +from composer.spec.artifacts import ArtifactStore +from composer.spec.context import WorkflowContext +from composer.spec.source.report.collect import ReportComponentInput, Verdict +from composer.spec.graph_builder import bind_standard, run_to_completion +from composer.ui.tool_display import tool_display +from composer.spec.source.report.schema import Outcome, ReportBackend, RuleName +from composer.spec.system_model import BaseApplication, FeatureUnit +from composer.spec.types import PropertyFormulation +from composer.spec.util import slugify_filename, uniq_thread_id + +_log = logging.getLogger(__name__) + +# Author→compile revise budget (was the Rust sessions' SETUP/PC_MAX_ATTEMPTS). +DEFAULT_MAX_ATTEMPTS = 7 + +# Derived from the ReportBackend literal so the two can't drift (single source of truth). +_REPORT_BACKENDS: frozenset[str] = frozenset(get_args(ReportBackend.__value__)) + + +def as_report_backend(tag: str) -> ReportBackend: + """Validate a wheel's free-form ``backend_tag`` against the closed report set.""" + if tag not in _REPORT_BACKENDS: + raise ValueError( + f"unknown report backend_tag {tag!r}; expected one of {sorted(_REPORT_BACKENDS)}" + ) + return cast(ReportBackend, tag) + + +# --------------------------------------------------------------------------- +# The LLM authoring turn — the Rust backend's binding of the shared agent primitive +# (bind_standard / run_to_completion), the peer of composer/foundry/author.py. Python +# runs this; the backend only supplies the prompt (its author_prompt/judge_prompt +# callouts). It is the "author" step of the author→compile→judge→validate loop. +# --------------------------------------------------------------------------- + +# Neutral fallback system prompt. The backend's prompt payload carries the task-specific +# `instruction` and MAY carry its own `system` prompt; when it doesn't, this applies. It +# conveys only the tool-using-agent + result-tool contract — no domain/language specifics +# (those belong in the backend's prompt). +_DEFAULT_SYS_PROMPT = ( + "You are an authoring agent. Use the available tools to explore the target " + "program's source and any reference material, then produce the requested artifact. " + "When done, call the `result` tool with your complete final answer as a single " + "string — the artifact source only, with no surrounding prose or code fences." +) + + +def _split_prompt(messages: Any) -> tuple[str | None, str]: + """Split a backend prompt payload into ``(system, instruction)``. + + The payload is a bare instruction string, or a dict carrying ``instruction`` and + (optionally) a backend-defined ``system`` prompt. ``system`` is ``None`` when the + backend doesn't supply one (the caller falls back to :data:`_DEFAULT_SYS_PROMPT`).""" + if isinstance(messages, dict): + return messages.get("system"), messages.get("instruction") or json.dumps(messages) + return None, messages + + +# NOTE: `bind_standard` introspects this state class's ``__annotations__`` at runtime to +# unwrap ``result: NotRequired[T]`` — so the annotation must stay a real object, not a +# string. This is a concrete reason the repo bans ``from __future__ import annotations`` +# (see CLAUDE.md); stringized annotations would break the unwrap here. +class _LlmState(MessagesState): + result: NotRequired[str] + + +# In-loop-judge author state: `reviewed_text`/`review_ok` record the last draft the author sent +# to `request_review` and the judge's verdict on it; the result gate (`_review_gate`) blocks +# finalization until the submitted draft is one the judge accepted. +class _JudgedAuthorState(MessagesState): + result: NotRequired[str] + reviewed_text: NotRequired[str] + review_ok: NotRequired[bool] + + +class _LlmInput(FlowInput): + pass + + +# A callable that reviews a candidate artifact: ``(draft) -> (accepted, feedback)``. The host +# builds it (see :func:`_make_judge_hook`) around the wheel's ``judge_prompt``. +type _JudgeHook = Callable[[str], Awaitable[tuple[bool, str]]] + + +# Appended to the system prompt when the judge runs in-loop, so the author knows the review +# protocol and the finalize gate. Kept generic (no backend/domain specifics). +_REVIEW_PROTOCOL = ( + "\n\nBefore you finalize, a reviewer must accept your work. Call the `request_review` tool " + "with the exact artifact text you intend to submit; if the review is REJECTED, revise and " + "call `request_review` again. Only call `result` with a draft the reviewer ACCEPTED — the " + "`result` call is rejected otherwise." +) + + +def _review_gate(state: _JudgedAuthorState, result_value: str) -> str | None: + """Block ``result`` unless the submitted draft is exactly the one the judge accepted.""" + if state.get("review_ok") and state.get("reviewed_text") == result_value: + return None + return ( + "Not accepted yet: call `request_review` with this exact draft and get an ACCEPTED " + "review before calling `result` (revise and re-review if it was REJECTED)." + ) + + +@tool_display("Requesting review", "Review") +class _RequestReview(WithInjectedId, WithAsyncDependencies[Command, "_JudgeHook"]): + """Ask the reviewer to evaluate your current draft against the task's criteria. Returns the + verdict and any feedback; if REJECTED, revise and call this again before finalizing.""" + + draft: str = Field( + description="The complete candidate artifact source to review — the exact text you intend " + "to submit via `result`, no surrounding prose or code fences." + ) + + @override + async def run(self) -> Command: + with self.tool_deps() as judge: + ok, feedback = await judge(self.draft) + verdict = "ACCEPTED" if ok else "REJECTED" + content = f"Review {verdict}.\n\n{feedback}" if feedback else f"Review {verdict}." + return tool_state_update( + tool_call_id=self.tool_call_id, content=content, + reviewed_text=self.draft, review_ok=ok, + ) + + +async def run_llm_agent( + env: Any, messages: Any, *, recursion_limit: int, backend_name: str = "rust", + turn_label: str = "authoring", judge: "_JudgeHook | None" = None, memory_tool: Any = None, + exclude_tools: frozenset[str] = frozenset(), +) -> str: + """Run one bounded, tool-enabled turn and return its final text. ``turn_label`` + names the turn's role ("authoring" / "judge") for the UI/log panel. + + Binds the env's tool belt (source navigation + RAG search over the backend's + knowledge base) and a result tool, and runs an agent to completion — so the + prompt can pull in framework docs / read the program. Must run inside a + ``with_handler`` scope (the caller wraps it in ``run.runner``). + + When ``judge`` is given, the turn becomes an in-loop-review author (docs/crucible-judge-in-loop.md): + a ``request_review`` tool runs the judge in-session and ``result`` is gated on an accepted draft, + so the author self-revises against feedback. ``memory_tool`` (when given) is added to the belt so + facts persist across turns/components. ``exclude_tools`` drops named tools from the belt (used to + clamp the review sub-agent's exploration — docs/crucible-judge-cost.md §3).""" + tools = [t for t in (getattr(env, "all_tools", None) or env.rag_tools) if t.name not in exclude_tools] + if memory_tool is not None: + tools.append(memory_tool) + system, instruction = _split_prompt(messages) + doc = "Your complete final answer as a single string (e.g. the authored source file)." + if judge is None: + builder = bind_standard(env.builder_heavy(), _LlmState, doc=doc) + state_input: FlowInput = _LlmInput(input=[]) + else: + builder = bind_standard(env.builder_heavy(), _JudgedAuthorState, doc=doc, validator=_review_gate) + tools = [*tools, _RequestReview.bind(judge).as_tool("request_review")] + system = (system or _DEFAULT_SYS_PROMPT) + _REVIEW_PROTOCOL + state_input = _LlmInput(input=[]) + graph: Any = ( + builder + .with_input(_LlmInput) + .with_sys_prompt(system or _DEFAULT_SYS_PROMPT) + .with_initial_prompt(instruction) + .with_tools(tools) + .compile_async() + ) + res = await run_to_completion( + graph, + state_input, + thread_id=uniq_thread_id(f"{backend_name}-llm"), + recursion_limit=recursion_limit, + description=f"{backend_name} {turn_label} turn", + ) + result = res.get("result") + return result if isinstance(result, str) else json.dumps(result) + + +# --------------------------------------------------------------------------- +# Shared loop helpers (used by RustFormalizer.formalize and app setup artifacts). +# --------------------------------------------------------------------------- + +def make_emitter() -> Callable[[str, dict], None]: + """A ``emit(kind, payload)`` that streams a domain event to the current task's panel. + Routes out-of-graph (the loop isn't inside a LangGraph run) via ``push_custom_update``, + keyed by the active ``run_task`` id — the same routing the old ``RealEffects.emit`` used.""" + from composer.diagnostics.timing import get_current_task_id + from composer.io.context import push_custom_update + + def emit(kind: str, payload: dict) -> None: + push_custom_update({"type": kind, **payload}, thread_id=get_current_task_id() or "rust") + + return emit + + +def _strip_fence(text: str) -> str: + """Strip a leading/trailing ``​```lang`` code fence if the model wrapped its answer + (the authored artifact is written verbatim into a source file, so a fence would break it).""" + t = text.strip() + if t.startswith("```"): + first_nl = t.find("\n") + body = t[first_nl + 1 :] if first_nl != -1 else t + return body.removesuffix("```").rstrip().removesuffix("```").rstrip() + return t + + +def unique_slugs(props: list[PropertyFormulation]) -> list[str]: + """One unique kebab slug per property (basis for its unit/feature name). Titles are unique + at extraction; a slug collision (punctuation/casing) gets a numeric suffix.""" + slugs: list[str] = [] + seen: dict[str, int] = {} + for p in props: + base = slugify_filename(p.title) or "inv" + n = seen.get(base, 0) + seen[base] = n + 1 + slugs.append(base if n == 0 else f"{base}_{n}") + return slugs + + +def _first_line(s: str) -> str: + return next((ln for ln in s.splitlines() if ln.strip()), "").strip() + + +def _parse_judge(review: str) -> tuple[bool, str]: + """Interpret a judge reply as (accept, feedback). Accepts a JSON ``{accept, feedback}`` (what + the Crucible judge emits) or a plain reply led by ``ACCEPT`` / ``REJECT``.""" + try: + obj = json.loads(review) + if isinstance(obj, dict): + return bool(obj.get("accept")), str(obj.get("feedback", "")) + except (json.JSONDecodeError, ValueError): + pass + return (not review.strip().upper().startswith("REJECT")), review + + +async def _author_turn( + module: Any, input_json: str, failure: dict | None, *, env: Any, recursion_limit: int, + backend_name: str, judge: "_JudgeHook | None" = None, memory_tool: Any = None, +) -> str: + """One authoring turn: render the backend's prompt (with any prior failure as revise + context), run the tool-enabled LLM agent, and strip a code fence off the result. When + ``judge`` is given, the author reviews and self-revises in-session (docs/crucible-judge-in-loop.md).""" + prompt = json.loads( + module.author_prompt(input_json, json.dumps(failure) if failure is not None else None) + ) + reply = await run_llm_agent( + env, prompt, recursion_limit=recursion_limit, backend_name=backend_name, + judge=judge, memory_tool=memory_tool, + ) + return _strip_fence(reply) + + +# The review sub-agent gets the program API + fixture in its prompt and shares the run memory, so +# it doesn't need the expensive `code_explorer` exploration sub-agent — direct file reads +# (`get_file`/`grep`) cover its spot-checks. Dropping it is the bulk of the review cost +# (docs/crucible-judge-cost.md §3): each `code_explorer` call is itself a multi-call sub-agent. +_JUDGE_EXCLUDE_TOOLS = frozenset({"code_explorer"}) + + +async def _judge_turn( + module: Any, input_json: str, spec: str, *, env: Any, recursion_limit: int, backend_name: str, + emit: Callable[[str, dict], None] | None = None, memory_tool: Any = None, +) -> tuple[bool, str]: + """Optional LLM review of a spec: ``(accept, feedback)``. ``(True, "")`` when the backend + declares no judge (``judge_prompt`` → ``None``, the default). When a review actually runs, + emit a ``judge`` event carrying the verdict so the frontend surfaces accept/reject.""" + jp = module.judge_prompt(input_json, spec) + if not jp: + return True, "" + review = await run_llm_agent( + env, json.loads(jp), recursion_limit=recursion_limit, + backend_name=backend_name, turn_label="judge", memory_tool=memory_tool, + exclude_tools=_JUDGE_EXCLUDE_TOOLS, + ) + ok, feedback = _parse_judge(review) + if emit is not None: + emit("judge", { + "line": "reviewer accepted the tests" if ok + else f"reviewer rejected — revising: {_first_line(feedback)}", + "outcome": "GOOD" if ok else "BAD", + }) + return ok, feedback + + +def _make_judge_hook( + module: Any, input_json: str, *, env: Any, recursion_limit: int, backend_name: str, + emit: Callable[[str, dict], None] | None, memory_tool: Any, +) -> "_JudgeHook": + """Wrap the wheel's judge as a ``(draft) -> (accepted, feedback)`` callable for the in-loop + ``request_review`` tool. Reuses :func:`_judge_turn` so the verdict event still fires.""" + async def judge(draft: str) -> tuple[bool, str]: + return await _judge_turn( + module, input_json, draft, env=env, recursion_limit=recursion_limit, + backend_name=backend_name, emit=emit, memory_tool=memory_tool, + ) + return judge + + +async def author_and_compile( + module: Any, + input_dict: dict, + *, + env: Any, + sandbox_dict: BackendSpec, + workdir: Path, + recursion_limit: int, + backend_name: str, + emit: Callable[[str, dict], None], + max_attempts: int = DEFAULT_MAX_ATTEMPTS, + command_sem: asyncio.Semaphore | None = None, +) -> str | GaveUp: + """Author an artifact's spec, gate it with the backend's ``compile`` (retry on failure) and + optional ``judge``. Returns the compiled spec text, or :class:`GaveUp`. Used for artifacts + that have no report units to validate — e.g. Crucible's shared setup fixture (a compile-only + gate). The component path fuses the build gate into ``validate`` instead (see + :meth:`RustFormalizer.formalize`).""" + input_json = json.dumps(input_dict) + sandbox_json = json.dumps(sandbox_dict) + failure: dict | None = None + for _ in range(max_attempts): + spec = await _author_turn( + module, input_json, failure, env=env, recursion_limit=recursion_limit, backend_name=backend_name + ) + result = json.loads( + await _run_blocking( + lambda: module.compile(input_json, spec, str(workdir), sandbox_json), command_sem + ) + ) + if result.get("status") != "ok": + errors = result.get("errors", "") + failure = {"draft": spec, "errors": errors} + emit("build_output", {"line": _first_line(errors) or "build failed; revising"}) + continue + ok, feedback = await _judge_turn( + module, input_json, spec, env=env, recursion_limit=recursion_limit, + backend_name=backend_name, emit=emit, + ) + if not ok: + failure = {"draft": spec, "errors": feedback, "kind": "judge"} + continue + return spec + return GaveUp(reason=f"{backend_name}: did not pass compile/judge in {max_attempts} attempts") + + +async def _run_blocking(thunk: Callable[[], str], sem: asyncio.Semaphore | None) -> str: + """Run a blocking wheel call (``compile``/``validate`` — they spawn ``run-confined`` and + release the GIL) off the event loop, serialized by ``sem`` when the backend shares one + workdir/crate across concurrent units.""" + if sem is not None: + async with sem: + return await asyncio.to_thread(thunk) + return await asyncio.to_thread(thunk) + + +def _confined_target(root: Path, rel: str) -> Path: + """Join a wheel-supplied relative path under ``root``, rejecting absolute paths / ``..`` + traversal — mirrors the Rust ``confined_join`` so host-written deliverable/prep files stay + inside the project (the wheel is trusted, but defense-in-depth is cheap).""" + p = Path(rel) + if p.is_absolute() or ".." in p.parts: + raise ValueError(f"unsafe file path {rel!r}: absolute or traverses outside the workdir") + return root / p + + +async def run_workspace_prep( + module: Any, + input_dict: dict, + *, + workdir: Path, + sandbox: SandboxConfig | None, + command_timeout_s: int, +) -> None: + """Execute the wheel's pure ``workspace_prep`` plan (``docs/rust-pure-app.md`` §4): write the + declared files (path-confined), then — only when a sandbox is enabled, so a later + confined+offline build finds its deps warm — ``cargo fetch`` each ``warm_dirs`` and build the + named program via the shared Solana build capability. + + Network stays Python-owned and the posture is unchanged: fetches run *unconfined* (a fetch + executes no untrusted code), the code-executing build runs *confined + offline* + (``build_program`` handles both). The wheel supplies only file contents + which dirs/program — + never a command line.""" + plan = json.loads(module.workspace_prep(json.dumps(input_dict))) + for rel, contents in (plan.get("files") or {}).items(): + target = _confined_target(workdir, rel) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(contents) + + warm_dirs = plan.get("warm_dirs") or [] + build_prog = plan.get("build_program") + if not warm_dirs and not build_prog: + return + + from composer.spec.solana.build import build_program, warm_cargo_cache + + if warm_dirs and sandbox is not None and sandbox.enabled: + # Warm into the SAME private CARGO_HOME the confined offline build will read. + from composer.sandbox.recipes import sandbox_cargo_home + + cargo_home = sandbox_cargo_home(str(workdir)) + for d in warm_dirs: + await warm_cargo_cache( + _confined_target(workdir, d), cargo_home=cargo_home, timeout_s=command_timeout_s + ) + if build_prog: + await build_program(str(workdir), build_prog, timeout_s=command_timeout_s, sandbox=sandbox) + + +# --------------------------------------------------------------------------- +# The formalizer. +# --------------------------------------------------------------------------- + +class RustFormalizer(Formalizer[RustFormalResult, FeatureUnit]): + """Drives a Rust :class:`~autoprover_sdk.Backend` through the author→compile→judge→validate + loop. Ecosystem-agnostic: the unit is any :class:`FeatureUnit`, marshalled via + ``feature_json()``.""" + + def __init__( + self, + module: Any, + descriptor: AppDescriptor, + *, + sandbox: SandboxConfig | None = None, + command_timeout_s: int = DEFAULT_TIMEOUT_S, + command_sem: asyncio.Semaphore | None = None, + max_attempts: int = DEFAULT_MAX_ATTEMPTS, + context_extra: dict | None = None, + setup_result: str | None = None, + ): + # Intermediate (PR2): report schema still master's `prover`/`foundry`, so cast the + # wheel's tag directly rather than validating against a set that lacks "crucible". + # PR3 (crucible) closes the ReportBackend literal and swaps this to as_report_backend. + super().__init__(RustFormalResult, cast(ReportBackend, descriptor.backend_tag)) + self._module = module + self._descriptor = descriptor + self._sandbox = sandbox + self._command_timeout_s = command_timeout_s + self._command_sem = command_sem + self._max_attempts = max_attempts + # Injected into every component's ``AuthorInput.context`` (declared-arg values + the + # compiled setup artifact under its ``context_key``); the prepared system assembles it. + self._context_extra = context_extra or {} + # The compiled setup spec (Crucible's fixture), forwarded to ``finalize`` so a + # callout-mode wheel can render the whole deliverable. + self._setup_result = setup_result + + # -- hooks an application backend may override ------------------------- + + def _context(self, run: PipelineRun) -> dict: + """The ``AuthorInput.context`` blob for a component. The program plus whatever the + prepared system injected (declared args + the setup artifact under its context key).""" + return {"program": str(run.source.contract_name), **self._context_extra} + + def _before_formalize(self, feat: FeatureUnit, slugs: list[str]) -> None: + """Place any crate scaffolding before compile/validate. Base: nothing (the wheel + materializes its crate per confined run via the ``files`` map).""" + return None + + async def _sandbox_spec(self, workdir: Path) -> BackendSpec: + if self._sandbox is None or not self._sandbox.enabled: + return {"argv_prefix": [], "timeout_s": self._command_timeout_s} + return await self._sandbox.backend_spec(workdir, timeout_s=self._command_timeout_s) + + # -- the loop ---------------------------------------------------------- + + @override + async def formalize( + self, + label: str, + feat: FeatureUnit, + props: list[PropertyFormulation], + ctx: WorkflowContext[RustFormalResult], + run: PipelineRun, + ) -> RustFormalResult | GaveUp: + workdir = Path(run.source.project_root) + slugs = unique_slugs(props) + self._before_formalize(feat, slugs) + + input_dict = { + "kind": "component", + "program": str(run.source.contract_name), + "component": feat.feature_json(), + "props": [ + {"title": p.title, "sort": p.sort, "description": p.description, "slug": s} + for p, s in zip(props, slugs) + ], + "context": self._context(run), + } + input_json = json.dumps(input_dict) + sandbox_dict = await self._sandbox_spec(workdir) + sandbox_json = json.dumps(sandbox_dict) + emit = make_emitter() + units = json.loads(self._module.units(input_json)) + + # When the wheel supplies a judge for this input, it runs in-loop: a `request_review` tool + # inside the author session, which self-revises against feedback and can only finalize an + # accepted draft (docs/crucible-judge-in-loop.md). The author and judge share the run + # memory across components. Probe the pure callout — `judge_prompt` returns None exactly + # when there is no judge for this kind, so no review machinery is bound then. + has_judge = self._module.judge_prompt(input_json, "") is not None + memory_tool = ctx.get_memory_tool() if has_judge else None + judge_hook = _make_judge_hook( + self._module, input_json, env=run.env, recursion_limit=ctx.recursion_limit, + backend_name=self._descriptor.name, emit=emit, memory_tool=memory_tool, + ) if has_judge else None + + # Fused author → validate loop: validate's build IS the compile gate (no separate dry-run + # per component — that ~2×'d the e2e). The units share one build, so a BuildFailed from any + # unit re-authors the whole spec. + failure: dict | None = None + for _ in range(self._max_attempts): + spec = await _author_turn( + self._module, input_json, failure, env=run.env, + recursion_limit=ctx.recursion_limit, backend_name=self._descriptor.name, + judge=judge_hook, memory_tool=memory_tool, + ) + + # Each report unit declares the *target* that validates it (its own name by default; + # e.g. Crucible shares one `c_invariants` target across all its units). Run each + # DISTINCT target once; the backend returns a verdict per unit it covers — it owns + # attribution (how a failure maps to units), the host records verbatim. + targets = list(dict.fromkeys(u.get("target") or u["unit"] for u in units)) + prop_of = {u["unit"]: u["property"] for u in units} + + verdicts: dict[str, dict] = {} + property_units: list[tuple[str, list[str]]] = [] + build_failed: str | None = None + for target in targets: + res = json.loads( + await _run_blocking( + lambda target=target, spec=spec: self._module.validate( + input_json, spec, target, str(workdir), sandbox_json + ), + self._command_sem, + ) + ) + if res.get("kind") == "build_failed": + build_failed = res.get("errors", "") + break + for unit, verdict in res["verdicts"]: + verdicts[unit] = verdict + prop = prop_of.get(unit, unit) + property_units.append((prop, [unit])) + detail = verdict.get("detail") + line = f'{prop}: {verdict.get("outcome")}' + emit( + "verdict", + {"outcome": verdict.get("outcome"), "name": prop, + "line": f"{line} — {detail}" if detail else line}, + ) + if build_failed is not None: + failure = {"draft": spec, "errors": build_failed} + emit("build_output", {"line": _first_line(build_failed) or "build failed; revising"}) + continue + return RustFormalResult(artifact_text=spec, units=property_units, verdicts=verdicts) + + return GaveUp( + reason=f"{self._descriptor.name}: did not compile/pass judge in {self._max_attempts} attempts" + ) + + @override + async def fetch_verdicts( + self, inp: ReportComponentInput[RustFormalResult] + ) -> dict[RuleName, Verdict]: + formalized = inp.formalized + if formalized is None: + return {} + return { + unit: Verdict( + outcome=Outcome(v["outcome"]), + line=v.get("line"), + duration_seconds=v.get("duration_seconds"), + unit_file=v.get("unit_file") or formalized.unit_file, + message=v.get("detail"), + ) + for unit, v in formalized.result.verdicts.items() + } + + @override + async def finalize(self, outcomes, run: PipelineRun) -> None: + from composer.pipeline.core import Delivered + + components = [] + for o in outcomes: + res = o.result + entry: dict = {"name": o.feat.display_name, "delivered": isinstance(res, Delivered)} + if isinstance(res, Delivered): + # A callout-mode wheel renders the whole deliverable from these (Crucible: folds + # each section into the shared crate, keyed by its property_units feature). + entry["unit_file"] = res.unit_file + entry["run_link"] = res.run_link + entry["artifact_text"] = res.result.artifact_text + entry["property_units"] = res.result.property_units() + components.append(entry) + payload = { + "program": str(run.source.contract_name), + "components": components, + "setup": self._setup_result, + } + raw = await asyncio.to_thread(self._module.finalize, json.dumps(payload)) + if not raw: + return + files: dict[str, str] = json.loads(raw) + root = Path(run.source.project_root) + for rel, contents in files.items(): + target = _confined_target(root, rel) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(contents) + + +@dataclass +class RustPreparedSystem(PreparedSystem[RustFormalResult, FeatureUnit, Any]): + """Generic prepared system, descriptor-driven: run the wheel's workspace prep, author the + optional shared ``setup`` artifact, and build a formalizer carrying the injected context. + + Fully expresses what Crucible used to need a subclass for (``docs/rust-pure-app.md``): the + shared fixture, the harness warm + ``.so`` build, per-run serialization, and the + context-thread of the fixture + declared args.""" + + backend: "RustBackend" + analyzed: BaseApplication | None = None + + @override + async def prepare_formalization(self, run: PipelineRun) -> Formalizer[RustFormalResult, FeatureUnit]: + b = self.backend + descriptor = b.descriptor + workdir = Path(run.source.project_root) + program = str(run.source.contract_name) + # One shared crate / target dir → serialize the toolchain runs (declared by the wheel). + command_sem = asyncio.Semaphore(1) if descriptor.serialize_toolchain else None + + analyzed_json = self.analyzed.model_dump(mode="json") if self.analyzed is not None else {} + prep_input = { + "kind": "setup", "program": program, "component": analyzed_json, + "props": [], "context": {}, + } + + # 1. Workspace prep: write the wheel's manifest, warm deps, build the program. + await run_workspace_prep( + b.module, prep_input, workdir=workdir, + sandbox=b.sandbox, command_timeout_s=b.command_timeout_s, + ) + + # 2. Every component's context = declared args + (optionally) the compiled setup artifact. + context_extra: dict = dict(b.declared_args) + setup_result: str | None = None + if descriptor.setup is not None: + sandbox_dict: BackendSpec = ( + await b.sandbox.backend_spec(workdir, timeout_s=b.command_timeout_s) + if (b.sandbox is not None and b.sandbox.enabled) + else {"argv_prefix": [], "timeout_s": b.command_timeout_s} + ) + emit = make_emitter() + fixture = await run.runner( + TaskInfo( + f"{descriptor.name}-setup", descriptor.setup.label, + cast(Any, b._phase)[descriptor.setup.phase_key], + ), + lambda: author_and_compile( + b.module, prep_input, env=run.env, sandbox_dict=sandbox_dict, + workdir=workdir, recursion_limit=run.ctx.recursion_limit, + backend_name=descriptor.name, emit=emit, command_sem=command_sem, + ), + ) + if isinstance(fixture, GaveUp): + raise RuntimeError(f"{descriptor.name} setup gave up: {fixture.reason}") + setup_result = fixture + context_extra[descriptor.setup.context_key] = fixture + + return RustFormalizer( + b.module, b.descriptor, sandbox=b.sandbox, + command_timeout_s=b.command_timeout_s, + command_sem=command_sem, context_extra=context_extra, setup_result=setup_result, + ) + + +@dataclass +class RustBackend: + """A :class:`PipelineBackend` backed by a Rust wheel. Structurally satisfies the protocol — + the driver never imports it. Ecosystem-agnostic: it locates the main and marshals units + through the resolved ``ecosystem`` + the ``FeatureUnit`` protocol. + + Subclass (or replace via ``backend_cls``) when the app needs non-generic prep — e.g. + Crucible's shared fixture + harness crate.""" + + module: Any + descriptor: AppDescriptor + _phase: type + _core_phases: CorePhases + artifact_store: ArtifactStore[Any, RustFormalResult] + ecosystem: Ecosystem[Any, Any, Any] + # Wall-clock ceiling for a single compile/validate (a first build can be minutes). + command_timeout_s: int = DEFAULT_TIMEOUT_S + # How to confine every toolchain run (docs/command-sandbox.md). None → unsandboxed. + sandbox: SandboxConfig | None = None + # Parsed values of the descriptor's declared CLI args, injected into every component's + # ``AuthorInput.context`` (e.g. Crucible's ``fuzz_timeout``). Set by the entry point. + declared_args: dict[str, Any] = field(default_factory=dict) + + @property + def backend_guidance(self) -> str: + return self.descriptor.backend_guidance + + @property + def analysis_spec(self) -> SystemAnalysisSpec: + return SystemAnalysisSpec(self.descriptor.analysis_key, "rust-properties") + + @property + def core_phases(self) -> CorePhases: + return self._core_phases + + async def prepare_system( + self, analyzed: BaseApplication, run: PipelineRun + ) -> PreparedSystem[RustFormalResult, FeatureUnit, Any]: + return RustPreparedSystem( + self.ecosystem.locate_main(analyzed, run.source), self, analyzed + ) + + def to_artifact_id(self, c: FeatureUnit) -> RustArtifact: + return RustArtifact( + c.slug, + self.descriptor.artifact_layout.artifact_prefix, + self.descriptor.artifact_layout.artifact_extension, + ) diff --git a/composer/rustapp/cli.py b/composer/rustapp/cli.py new file mode 100644 index 00000000..05d3671f --- /dev/null +++ b/composer/rustapp/cli.py @@ -0,0 +1,126 @@ +"""Generic ``main()`` glue for a Rust application. + +Two shapes, differing only in who owns the event loop (identical to the built-in +apps' ``composer/cli/*.py``): + +* :func:`tui_main` — the pipeline runs as a background worker inside the Textual + app, streaming into it. +* :func:`console_main` — the pipeline runs directly, printing on completion. + +A Rust application ships a two-line CLI: + + from composer.rustapp.cli import tui_main + def main() -> int: + return tui_main("my_app") + +``import composer.bind`` runs first (import-time DI / test-tape bootstrap), exactly +as the built-in ``main()``s require. +""" + +import asyncio + +import composer.bind as _ # noqa: F401 (side-effecting DI/tape bootstrap; must load first) + +from composer.diagnostics.timing import RunSummary +from composer.pipeline.core import CorePipelineResult +from composer.rustapp.adapter import as_report_backend +from composer.rustapp.entry import EnvBuilder, rust_entry_point +from composer.rustapp.frontend import GenericRustApp, GenericRustConsoleHandler +from composer.rustapp.host import build_application +from composer.rustapp.result import RustFormalResult +from composer.rustapp.results import format_verdict_lines, summarize_verdicts + + +def _event_kinds(app) -> set[str]: + return {e.kind for e in app.descriptor.event_kinds} + + +def _notice_kinds(app) -> set[str]: + return {e.kind for e in app.descriptor.event_kinds if e.notice} + + +def _component_label(app) -> str: + """The counts-block noun for one formalized unit ("Components" / "Instructions").""" + return (app.descriptor.component_noun or "component").capitalize() + "s" + + +def _verdict_lines(app, result: CorePipelineResult[RustFormalResult]) -> list[str]: + """Per-unit verdict tally + listing when the results carry verdicts; empty otherwise + (a run-service backend, or a wheel that bakes none).""" + return format_verdict_lines( + summarize_verdicts(result, as_report_backend(app.descriptor.backend_tag)) + ) + + +async def _tui_main(module_name: str, *, env_builder: EnvBuilder | None = None) -> int: + summary = RunSummary() + app_meta = build_application(module_name) + async with rust_entry_point(app_meta, summary, env_builder=env_builder) as pipeline: + tui = GenericRustApp( + phase_labels=app_meta.phase_labels, + section_order=app_meta.section_order, + header_text=app_meta.header_text, + event_kinds=_event_kinds(app_meta), + notice_kinds=_notice_kinds(app_meta), + ) + result: CorePipelineResult[RustFormalResult] | None = None + + async def work(): + nonlocal result + try: + result = await pipeline(tui.make_handler) + noun = (app_meta.descriptor.component_noun or "component") + msg = ( + f"{app_meta.name} complete: {result.n_components} {noun}s, " + f"{result.n_properties} properties" + ) + if tally := summarize_verdicts( + result, as_report_backend(app_meta.descriptor.backend_tag) + ).tally: + msg += f" — {tally}" + if result.failures: + msg += f", {len(result.failures)} failures" + tui.notify(msg) + except Exception as exc: # noqa: BLE001 — surface to the UI, don't crash the loop + tui.notify(f"Pipeline failed: {exc}", severity="error") + finally: + tui._pipeline_done = True + + tui.set_work(work) + await tui.run_async() + print(summary.format()) + if result is not None: + for line in _verdict_lines(app_meta, result): + print(line) + for f in result.failures: + print(f" FAILED: {f}") + return 0 + + +async def _console_main(module_name: str, *, env_builder: EnvBuilder | None = None) -> int: + summary = RunSummary() + app_meta = build_application(module_name) + async with rust_entry_point(app_meta, summary, env_builder=env_builder) as run: + result = await run(GenericRustConsoleHandler(_event_kinds(app_meta)).make_handler) + print(f"\n{'=' * 60}") + print(summary.format()) + print(f"\n {_component_label(app_meta)}: {result.n_components}") + print(f" Properties: {result.n_properties}") + for line in _verdict_lines(app_meta, result): + print(line) + if result.failures: + print(f" Failures: {len(result.failures)}") + for f in result.failures: + print(f" - {f}") + print(f"{'=' * 60}") + return 0 + + +def tui_main(module_name: str, *, env_builder: EnvBuilder | None = None) -> int: + """Run ``module_name`` as a Textual TUI application. Blocks until the run ends.""" + return asyncio.run(_tui_main(module_name, env_builder=env_builder)) + + +def console_main(module_name: str, *, env_builder: EnvBuilder | None = None) -> int: + """Run ``module_name`` in console (no-TUI) mode. Blocks until the run ends.""" + return asyncio.run(_console_main(module_name, env_builder=env_builder)) diff --git a/composer/rustapp/descriptor.py b/composer/rustapp/descriptor.py new file mode 100644 index 00000000..dc6a9ee2 --- /dev/null +++ b/composer/rustapp/descriptor.py @@ -0,0 +1,141 @@ +"""Python mirror of the Rust ``AppDescriptor`` (see ``autoprover-sdk``). + +These pydantic models are the Python side of the descriptor ABI. They are parsed +from the JSON a Rust wheel returns from ``descriptor()`` and consumed by the host +to synthesize the phase enum, argparse, frontend and artifact store. Keep the +field names in lockstep with ``rust/autoprover-sdk/src/lib.rs``. +""" + +import enum +from typing import Literal + +from pydantic import BaseModel, Field + +#: Ecosystem/chain tag. Mirrors ``composer.pipeline.ecosystem.ChainTag`` (kept local so this +#: ABI-mirror module stays decoupled from the pipeline); the host resolves it against the +#: ecosystem registry. +ChainTag = Literal["evm", "solana", "soroban"] + + +class CoreSlot(str, enum.Enum): + """Which driver-tagged core phase a declared phase fills.""" + + ANALYSIS = "analysis" + EXTRACTION = "extraction" + FORMALIZATION = "formalization" + REPORT = "report" + + +class DeliverableMode(str, enum.Enum): + """How the source deliverable is written (mirrors the Rust ``DeliverableMode``). + + ``per_component`` (default): the generic store writes one ``{prefix}_{slug}.{ext}`` file per + component. ``callout``: the store writes no per-component source; the wheel's ``finalize`` + renders the whole deliverable (e.g. Crucible's one shared crate).""" + + PER_COMPONENT = "per_component" + CALLOUT = "callout" + + +class SetupSpec(BaseModel): + """A shared setup artifact authored once before per-component formalization (Crucible's + shared fixture). The host runs the author→compile loop for a ``kind="setup"`` input under + ``phase_key`` and threads the compiled spec into each component's context under + ``context_key``. Mirrors the Rust ``SetupSpec``.""" + + phase_key: str + label: str + context_key: str + + +class PhaseSpec(BaseModel): + """One task-grouping phase; ``key`` becomes the synthesized enum member name.""" + + key: str + label: str + order: int = 0 + core_slot: CoreSlot | None = None + + +class ArgDefault(BaseModel): + """Tagged default value for a declared CLI argument.""" + + kind: Literal["str", "int", "bool"] + value: str | int | bool | None = None + + +class ArgSpec(BaseModel): + """A CLI flag the generic entry point adds beyond the positional inputs.""" + + flag: str + help: str + default: ArgDefault + required: bool = False + + +class EventKind(BaseModel): + """A domain event kind the frontend should render (see ``Command::Emit``). + + ``notice`` events are surfaced as a persistent, always-visible callout (plus a toast) + rather than a line in the collapsible per-task events log — for one-shot important + results such as a per-unit verdict. Defaults to ``False`` so wheels built before + the field existed still load.""" + + kind: str + label: str + notice: bool = False + + +class ArtifactLayout(BaseModel): + """Project-root-relative deliverable layout.""" + + deliverable_dir: str + internal_dir: str + report_dir: str + artifact_dir: str + artifact_prefix: str + artifact_extension: str + property_suffix: str + #: Under ``callout`` deliverable mode, the project-relative primary deliverable path, + #: ``{program}``-templated (Crucible: ``fuzz/{program}/src/main.rs``). Used only as each + #: component's report link; ``None`` in ``per_component`` mode. + deliverable_primary: str | None = None + + +class AppDescriptor(BaseModel): + """The complete declaration a Rust wheel exports.""" + + name: str + header_text: str + #: The ecosystem (chain) whose system model / prompts the shared front half uses. The + #: host resolves it against ``composer.pipeline.ecosystem.ECOSYSTEMS``. Defaults to + #: ``"evm"`` so wheels built before this field existed keep working. + ecosystem: ChainTag = "evm" + backend_tag: str + backend_guidance: str + analysis_key: str + phases: list[PhaseSpec] + args: list[ArgSpec] = Field(default_factory=list) + rag_db_default: str | None = None + event_kinds: list[EventKind] = Field(default_factory=list) + artifact_layout: ArtifactLayout + #: Optional shared-setup step run before per-component formalization (see :class:`SetupSpec`). + setup: SetupSpec | None = None + #: How the source deliverable is written (see :class:`DeliverableMode`). + deliverable_mode: DeliverableMode = DeliverableMode.PER_COMPONENT + #: Serialize the blocking toolchain callouts on one semaphore — set when the app shares a + #: single build dir / target across units. + serialize_toolchain: bool = False + #: Default to the fail-closed ``launcher`` sandbox provider (still overridable by + #: ``COMPOSER_SANDBOX_PROVIDER``). Set by any wheel that runs untrusted native toolchains. + confine_by_default: bool = False + #: Human noun for one formalized unit in the console/TUI summary ("instruction" for + #: Crucible). ``None`` → "component". + component_noun: str | None = None + + def ordered_phases(self) -> list[PhaseSpec]: + return sorted(self.phases, key=lambda p: (p.order, p.key)) + + def core_slot_map(self) -> dict[CoreSlot, str]: + """The declared phase ``key`` for each core slot it fills.""" + return {p.core_slot: p.key for p in self.phases if p.core_slot is not None} diff --git a/composer/rustapp/entry.py b/composer/rustapp/entry.py new file mode 100644 index 00000000..c9371f0a --- /dev/null +++ b/composer/rustapp/entry.py @@ -0,0 +1,417 @@ +"""Generic async entry point for a Rust application. + +Mirrors ``composer/foundry/entry.py``'s shape — parse args → open DB / store / +checkpointer / logging → yield a closure the caller drives with a handler factory +— but is *descriptor-driven*: the CLI flags, precondition validation, and report +tag all come from the Rust wheel's ``AppDescriptor`` instead of being hard-coded. + +The imperative service wiring (Postgres pools, the async tool context, the thread +logger, ``WorkflowContext``) stays Python and is essentially identical to the +foundry entry point — that shell is irreducibly async and is not something Rust +owns (see ``docs/rust-applications.md`` §4.2). What Rust contributes here is only +declarative: the arg schema and the ``validate_preconditions`` hook. + +The env built here is *neutral*: the standard source-navigation toolset +(``code_explorer`` + fs tools) with no RAG surface. A backend that wants a RAG +database can supply its own env builder via ``env_builder=``. +""" + +import argparse +import asyncio +import hashlib +import json +import os +import pathlib +import sys +import uuid +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Awaitable, Callable, cast + +from langgraph.store.base import BaseStore + +from composer.core.user import user_data_ns +from composer.diagnostics.logging_setup import setup_autoprove_logging +from composer.diagnostics.timing import RunSummary, install_run_summary +from composer.input.parsing import add_protocol_args +from composer.input.types import ( + DEFAULT_RECURSION_LIMIT, + ExtendedModelOptions, +) +from composer.io.multi_job import HandlerFactory, TaskInfo, run_task +from composer.io.thread_logging import default_logging_ns, thread_logger +from composer.kb.knowledge_base import DefaultEmbedder +from composer.pipeline.core import CorePipelineResult +from composer.rag.models import get_model +from composer.rustapp.descriptor import ArgDefault, ArgSpec +from composer.rustapp.host import RustApplication, build_application, run_application +from composer.rustapp.result import RustFormalResult +from composer.sandbox.config import SandboxConfig +from composer.spec.context import SourceCode, SourceFields, WorkflowContext +from composer.spec.service_host import ModelProvider, PureServiceHost, ServiceHost +from composer.spec.source.design_doc_finder import ( + DESIGN_DOC_DISCOVERY_TASK_ID, + discovery_cache_key, + resolve_design_doc, +) +from composer.spec.source.source_env import ( + build_basic_source_tools, + build_source_tools, +) +from composer.spec.system_model import SolidityIdentifier +from composer.spec.util import FS_FORBIDDEN_READ +from composer.ui.tool_display import async_tool_context +from composer.workflow.services import llm_factory, standard_connections +from composer.llm.registry import get_provider_for + +# A caller-supplied env builder, for backends that want a custom tool/RAG surface. +EnvBuilder = Callable[..., ServiceHost] + +# The Executor a frontend drives. +RustRunner = Callable[ + [HandlerFactory], Awaitable[CorePipelineResult[RustFormalResult]] +] + + +def build_neutral_env( + *, + model_provider: ModelProvider, + project_root: str, + store: BaseStore, + source_question_ns: tuple[str, ...], + recursion_limit: int, + forbidden_read: str = FS_FORBIDDEN_READ, +) -> ServiceHost: + """A source-navigation env with no RAG surface — the same ``code_explorer`` + + fs tools the built-in backends use for analysis/authoring. ``forbidden_read`` + is the ecosystem's fs-exclusion default (Cargo layout for Rust, Foundry for EVM).""" + basic = build_basic_source_tools(root=project_root, forbidden_read=forbidden_read) + full = build_source_tools( + basic, model_provider, store, source_question_ns, recursion_limit=recursion_limit + ) + return PureServiceHost(models=model_provider, rag_tools=(), sort="existing").bind_source_tools( + full + ) + + +def _default_env_builder(rag_db: str | None) -> EnvBuilder: + """The env builder for a wheel that declares no custom ``env_builder``: neutral (source tools + only) when the descriptor sets no ``rag_db_default``, otherwise the same source tools plus the + declared corpus's RAG search tools (replaces the old per-app ``build_crucible_env``).""" + if not rag_db: + return build_neutral_env + + def builder( + *, + model_provider: ModelProvider, + project_root: str, + store: BaseStore, + source_question_ns: tuple[str, ...], + recursion_limit: int, + forbidden_read: str = FS_FORBIDDEN_READ, + ) -> ServiceHost: + from composer.tools.rag_env import build_rag_tools + + basic = build_basic_source_tools(root=project_root, forbidden_read=forbidden_read) + full = build_source_tools( + basic, model_provider, store, source_question_ns, recursion_limit=recursion_limit + ) + return PureServiceHost( + models=model_provider, rag_tools=build_rag_tools(rag_db), sort="existing" + ).bind_source_tools(full) + + return builder + + +def _build_confinement(app: RustApplication, args: dict) -> SandboxConfig: + """The default command-sandbox config for a wheel that sets ``confine_by_default`` — the + fail-closed ``launcher`` provider (overridable by ``COMPOSER_SANDBOX_PROVIDER``), with the + wheel's ``sandbox_grants`` (extra read-only paths / env names) unioned in. Python owns the + policy; the wheel only *declares* the grants (``docs/rust-pure-app.md`` §5.2).""" + from composer.sandbox.config import SandboxConfig + from composer.sandbox.recipes import DEFAULT_ENV_PASSTHROUGH + + grants = json.loads(app.module.sandbox_grants(json.dumps(args))) + extra_ro = tuple(pathlib.Path(p) for p in grants.get("extra_ro", [])) + extra_env = tuple(grants.get("extra_env", [])) + provider = os.environ.get("COMPOSER_SANDBOX_PROVIDER", "launcher") + return SandboxConfig( + provider=provider, + extra_ro=extra_ro, + env_passthrough=DEFAULT_ENV_PASSTHROUGH + extra_env, + ) + + +def _user_ns(*parts: str | None) -> tuple[str, ...]: + return user_data_ns() + tuple(p for p in parts if p) + + +def _root_cache_key( + project_root: str, system_doc_path: pathlib.Path, relative_path: str, contract_name: str +) -> str: + doc_hash = hashlib.sha256(system_doc_path.read_bytes()).hexdigest() + combined = "|".join([project_root, doc_hash, relative_path, contract_name]) + return hashlib.sha256(combined.encode()).hexdigest()[:16] + + +def _add_declared_args(parser: argparse.ArgumentParser, specs: list[ArgSpec]) -> list[str]: + """Add the descriptor's declared flags; return their argparse dests.""" + dests: list[str] = [] + for spec in specs: + dest = spec.flag.lstrip("-").replace("-", "_") + dests.append(dest) + d: ArgDefault = spec.default + if d.kind == "bool": + parser.add_argument( + spec.flag, dest=dest, action="store_true", + default=bool(d.value), help=spec.help, + ) + elif d.kind == "int": + parser.add_argument( + spec.flag, dest=dest, type=int, default=d.value, + required=spec.required, help=spec.help, + ) + else: # str + parser.add_argument( + spec.flag, dest=dest, type=str, default=d.value, + required=spec.required, help=spec.help, + ) + return dests + + +def _discovery_phase(app: RustApplication) -> Any: + """The phase to tag the design-doc-discovery task with: a descriptor phase keyed + ``discover_design_doc`` if the app declares one (a dedicated UI section), else the + first ordered phase (so a generic wheel still groups it somewhere sensible).""" + ordered = app.descriptor.ordered_phases() + key = "discover_design_doc" + if any(p.key == key for p in ordered): + return app.phase[key] + return app.phase[ordered[0].key] + + +@asynccontextmanager +async def rust_entry_point( + app: RustApplication, + summary: RunSummary, + *, + argv: list[str] | None = None, + env_builder: EnvBuilder | None = None, + run_pipeline_fn: Callable[..., Awaitable[CorePipelineResult[RustFormalResult]]] | None = None, +) -> AsyncIterator[RustRunner]: + """Parse args, open services, and yield the Executor for ``app``. + + Pass a pre-built :class:`RustApplication` (from :func:`build_application`) so the + backend and the frontend share one phase enum. ``argv`` overrides ``sys.argv`` + (useful in tests); ``env_builder`` overrides :func:`build_neutral_env`.""" + descriptor = app.descriptor + parser = argparse.ArgumentParser(description=f"{descriptor.name} — AutoProver (Rust backend)") + add_protocol_args(parser, ExtendedModelOptions) + parser.add_argument( + "--recursion-limit", type=int, default=DEFAULT_RECURSION_LIMIT, + help=f"Max graph iterations (default: {DEFAULT_RECURSION_LIMIT})", + ) + parser.add_argument("project_root", help="Project root") + parser.add_argument("main_contract", help="Main contract as path:ContractName") + parser.add_argument( + "system_doc", nargs="?", default=None, + help="Path to the design document (text or PDF); auto-discovered if omitted", + ) + parser.add_argument("--max-concurrent", type=int, default=4, help="Max concurrent agents (default: 4)") + parser.add_argument("--cache-ns", default=None, help="Cache namespace (enables cross-run caching)") + parser.add_argument("--memory-ns", default=None, help="Memory namespace (default: thread id)") + parser.add_argument("--interactive", action="store_true", help="Interactively refine extracted properties") + parser.add_argument("--max-bug-rounds", type=int, default=3, help="Max bug-extraction rounds per component (default: 3)") + declared_dests = _add_declared_args(parser, descriptor.args) + + args = parser.parse_args(argv) + + project_root = pathlib.Path(args.project_root).resolve() + main_path, contract_name = args.main_contract.split(":", 1) + contract_name = SolidityIdentifier(contract_name) + full_path = pathlib.Path(main_path).resolve() + if not full_path.is_relative_to(project_root): + parser.error(f"Invalid path: {full_path} not under project root {project_root}") + relative_path = str(full_path.relative_to(project_root)) + + # Rust-owned precondition validation (cf. foundry's foundry.toml check). + declared_args = {d: getattr(args, d) for d in declared_dests} + err = app.validate_preconditions( + { + "project_root": str(project_root), + "main_contract": args.main_contract, + "system_doc": args.system_doc or "", + **declared_args, + } + ) + if err: + parser.error(err) + + # The ecosystem's fs-exclusion default (Cargo layout for Rust, Foundry for EVM). + forbidden_read = app.ecosystem.language.default_forbidden_read + model = get_model() + + thread_id = f"{descriptor.name}_{uuid.uuid4().hex[:12]}" + text_log, events_log = setup_autoprove_logging(str(project_root), thread_id) + print(f"{descriptor.name} logs: {text_log}\n events: {events_log}", file=sys.stderr) + install_run_summary(summary) + + # argparse Namespace duck-types the ModelConfiguration protocol (the model flags come from + # ExtendedModelOptions); the built-in entries cast their args the same way. + tiered = get_provider_for(tiered=cast(Any, args)) + discovery_phase = _discovery_phase(app) + + async with ( + standard_connections(provider=tiered.provider_kind, embedder=DefaultEmbedder(model)) as conns, + async_tool_context(), + thread_logger( + conns.store, + { + "root_thread_id": thread_id, + "workflow": descriptor.name, + "memory_ns": args.memory_ns if args.memory_ns is not None else thread_id, + }, + default_logging_ns(uid=None), + run_id=summary.run_id, + ), + ): + model_provider = ModelProvider( + heavy_model=tiered.heavy, + lite_model=tiered.lite, + checkpointer=conns.checkpointer, + ) + # The design-doc finder works off the source fields alone; its cache namespace + # is doc-independent (keyed by project/contract), so it's built up front. + init_source = SourceFields( + project_root=str(project_root), + contract_name=contract_name, + relative_path=relative_path, + forbidden_read=forbidden_read, + ) + disc_cache_ns: tuple[str, ...] | None = ( + _user_ns( + args.cache_ns, "discovery", + discovery_cache_key(str(project_root), relative_path, str(contract_name)), + ) + if args.cache_ns is not None + else None + ) + disc_ctx = WorkflowContext.create( + services=conns.memory, thread_id=thread_id, store=conns.store, + recursion_limit=args.recursion_limit, memory_namespace=args.memory_ns, + cache_namespace=disc_cache_ns, + ) + semaphore = asyncio.Semaphore(args.max_concurrent) + + async def runner(handler: HandlerFactory) -> CorePipelineResult[RustFormalResult]: + # 1. Resolve the design doc: use the supplied path, else discover one as a + # visible task (needs the handler scope, which only exists here). + if args.system_doc is not None: + sys_path = pathlib.Path(args.system_doc) + else: + sys_path = await run_task( + factory=handler, + info=TaskInfo( + task_id=DESIGN_DOC_DISCOVERY_TASK_ID, + label="Design Doc Discovery", + phase=discovery_phase, + ), + fn=lambda: resolve_design_doc( + source=init_source, uploader=conns.uploader, + models=model_provider, disc_ctx=disc_ctx, + ), + semaphore=semaphore, + ) + + content = await conns.uploader.get_document(sys_path) + if content is None: + raise ValueError(f"cannot read design document: {sys_path}") + + # 2. Doc-dependent construction. The root cache key hashes the doc bytes, so + # a discovered doc and a supplied one produce an identical key. + root_key = _root_cache_key( + str(project_root), sys_path, relative_path, str(contract_name) + ) + cache_root: tuple[str, ...] | None = ( + _user_ns(args.cache_ns, root_key) if args.cache_ns is not None else None + ) + source_input = SourceCode( + content=content, + project_root=str(project_root), + contract_name=contract_name, + relative_path=relative_path, + forbidden_read=forbidden_read, + ) + source_question_ns = _user_ns("source_agent", "cache", root_key) + # Descriptor-driven env: neutral, or (if the wheel declares a RAG corpus) the same + # source tools plus that corpus's search tools. A wheel can still force its own via + # ``env_builder=``. + builder = env_builder or _default_env_builder(app.descriptor.rag_db_default) + env = builder( + model_provider=model_provider, + project_root=str(project_root), + store=conns.indexed_store, + source_question_ns=source_question_ns, + recursion_limit=args.recursion_limit, + forbidden_read=forbidden_read, + ) + ctx = WorkflowContext.create( + services=conns.memory, + thread_id=thread_id, + store=conns.store, + recursion_limit=args.recursion_limit, + cache_namespace=cache_root, + memory_namespace=args.memory_ns, + ) + + # Thread the declared CLI args into the backend (→ every component's context) and, + # if the wheel asks to be confined by default, build the launcher policy with its + # declared sandbox grants. Both are inert for a wheel that declares neither. + app.options.declared_args = declared_args + if app.options.sandbox is None and app.descriptor.confine_by_default: + app.options.sandbox = _build_confinement( + app, + { + "project_root": str(project_root), + "main_contract": args.main_contract, + "system_doc": args.system_doc or "", + **declared_args, + }, + ) + + # 3. A backend that needs a bespoke store/pipeline (e.g. Crucible's crate + # store) supplies run_pipeline_fn; everything else uses the generic host. + if run_pipeline_fn is not None: + return await run_pipeline_fn( + source_input=source_input, ctx=ctx, handler_factory=handler, + env=env, args=args, + ) + return await run_application( + app, + source_input=source_input, + ctx=ctx, + handler_factory=handler, + env=env, + max_concurrent=args.max_concurrent, + max_bug_rounds=args.max_bug_rounds, + interactive=args.interactive, + ) + + yield runner + + +def build_arg_parser(app: RustApplication) -> argparse.ArgumentParser: + """Build (but do not run) the descriptor-driven argument parser — exposed for + tests and ``--help`` introspection without opening any service.""" + parser = argparse.ArgumentParser(description=f"{app.descriptor.name} — AutoProver (Rust backend)") + add_protocol_args(parser, ExtendedModelOptions) + parser.add_argument("--recursion-limit", type=int, default=DEFAULT_RECURSION_LIMIT) + parser.add_argument("project_root") + parser.add_argument("main_contract") + parser.add_argument("system_doc", nargs="?", default=None) + parser.add_argument("--max-concurrent", type=int, default=4) + parser.add_argument("--cache-ns", default=None) + parser.add_argument("--memory-ns", default=None) + parser.add_argument("--interactive", action="store_true") + parser.add_argument("--max-bug-rounds", type=int, default=3) + _add_declared_args(parser, app.descriptor.args) + return parser diff --git a/composer/rustapp/frontend.py b/composer/rustapp/frontend.py new file mode 100644 index 00000000..c37ac6a1 --- /dev/null +++ b/composer/rustapp/frontend.py @@ -0,0 +1,152 @@ +"""Generic frontend for a Rust application. + +Both frontends are thin, descriptor-driven subclasses of the shared bases: + +* :class:`GenericRustApp` — a ``MultiJobApp`` TUI whose phase labels / section + order come from the descriptor. +* :class:`GenericRustConsoleHandler` — the stdout ``HandlerFactory``. + +Domain-event rendering is *data-driven* by the descriptor's ``event_kinds``: a +Rust ``Command::Emit`` becomes a ``{"type": kind, ...}`` custom-stream payload, +which the handler writes to the task's log if ``kind`` is a declared event kind. +No per-application Python subclass is needed — the same generic handler renders +any Rust app's events (see ``docs/rust-applications.md`` §4.4). +""" + +import json +from collections.abc import Set as AbstractSet +from typing import Any, override + +from rich.text import Text +from textual.containers import VerticalScroll +from textual.widgets import Collapsible, RichLog + +from composer.io.event_handler import EventHandler, NullEventHandler +from composer.io.multi_job import TaskInfo +from composer.ui.multi_console_handler import MultiJobConsoleHandler +from composer.ui.multi_job_app import MultiJobApp, MultiJobTaskHandler, TaskHost +from composer.ui.tool_display import ToolDisplayConfig + + +def _render_event(payload: dict) -> str: + """A one-line rendering of an emit payload: prefer a ``line`` field, else a + compact JSON of everything but the discriminating ``type``.""" + if isinstance(payload.get("line"), str): + return payload["line"] + rest = {k: v for k, v in payload.items() if k != "type"} + return json.dumps(rest) if rest else "" + + +# Glyph for a notice event that carries a neutral ``outcome`` (e.g. a verdict). Mirrors +# the report/TUI GOOD→✓ / BAD→✗ vocabulary so a result scans at a glance. +_OUTCOME_GLYPH: dict[str, str] = { + "GOOD": "✓", # ✓ + "BAD": "✗", # ✗ + "TIMEOUT": "⧖", # ⧖ + "ERROR": "!", + "UNKNOWN": "?", +} + + +def _notice_headline(payload: dict) -> str: + """The persistent-callout headline for a notice event: its one-line rendering, + prefixed with an outcome glyph when the payload carries one.""" + body = _render_event(payload) + glyph = _OUTCOME_GLYPH.get(payload.get("outcome", "")) if isinstance(payload.get("outcome"), str) else None + return f"{glyph} {body}" if glyph else body + + +class GenericRustTaskHandler(MultiJobTaskHandler[None], NullEventHandler): + """Per-task handler that streams the app's declared domain events into a + collapsible log under the task panel.""" + + def __init__( + self, + task_id: str, + label: str, + panel: VerticalScroll, + host: TaskHost, + tool_config: ToolDisplayConfig, + event_kinds: set[str], + notice_kinds: AbstractSet[str] = frozenset(), + ): + super().__init__(task_id, label, panel, host, tool_config) + self._event_kinds = event_kinds + # Notice kinds are surfaced as a persistent callout (post_notice) instead of a + # buried log line; ``event_kinds`` here is the streaming (non-notice) remainder. + self._notice_kinds = notice_kinds + self._event_log: RichLog | None = None + + def format_hitl_prompt(self, ty: None) -> list[Text | str]: + raise NotImplementedError("Rust applications do not use HITL interrupts") + + async def _ensure_event_log(self) -> RichLog: + if self._event_log is None: + log = RichLog(highlight=True, markup=False) + log.styles.min_height = 12 + self._event_log = log + await self._mount_to(self._panel, Collapsible(log, title="Events")) + return self._event_log + + @override + async def handle_event(self, payload: dict, path: list[str], checkpoint_id: str) -> None: + kind = payload.get("type") + if kind in self._notice_kinds: + # A one-shot important result — a persistent callout in the panel + a toast, + # visible without expanding the events log. + await self.post_notice(_notice_headline(payload)) + elif kind in self._event_kinds: + log = await self._ensure_event_log() + log.write(f"[{kind}] {_render_event(payload)}") + + +class GenericRustApp(MultiJobApp[Any, GenericRustTaskHandler]): + """Textual TUI for a Rust application.""" + + def __init__( + self, + *, + phase_labels: dict[Any, str], + section_order: list[str], + header_text: str, + event_kinds: set[str], + notice_kinds: AbstractSet[str] = frozenset(), + ): + super().__init__( + phase_labels=phase_labels, section_order=section_order, header_text=header_text + ) + self._event_kinds = event_kinds + self._notice_kinds = notice_kinds + + def create_task_handler( + self, panel: VerticalScroll, info: TaskInfo[Any] + ) -> GenericRustTaskHandler: + return GenericRustTaskHandler( + info.task_id, info.label, panel, self, ToolDisplayConfig(), + self._event_kinds, self._notice_kinds, + ) + + def create_event_handler( + self, handler: GenericRustTaskHandler, info: TaskInfo[Any] + ) -> EventHandler: + return handler + + +class GenericRustConsoleHandler(MultiJobConsoleHandler[Any]): + """Stdout ``HandlerFactory`` for a Rust application.""" + + def __init__(self, event_kinds: set[str]): + super().__init__() + self._event_kinds = event_kinds + + @override + async def handle_event(self, payload: dict, path: list[str], checkpoint_id: str) -> None: + kind = payload.get("type") + if kind in self._event_kinds: + self._output(f"[{self._label(path)}] {kind}: {_render_event(payload)}") + + @override + async def handle_progress_event(self, payload: dict) -> None: + # Rust applications stream everything through Command::Emit (the custom + # channel); there are no progress-channel events. + pass diff --git a/composer/rustapp/host.py b/composer/rustapp/host.py new file mode 100644 index 00000000..1622175a --- /dev/null +++ b/composer/rustapp/host.py @@ -0,0 +1,299 @@ +"""Assemble a Rust wheel into a runnable AutoProver application. + +The declarative descriptor lets a single host synthesize what a hand-written +application spells out (phase enum, core-phase mapping, artifact store, labels, +section order) and hand the driver a ready :class:`PipelineBackend`. + +* :func:`run_rust_pipeline` — the pipeline wrapper (build backend + ``PipelineRun`` + + call the shared driver). This is the piece a generic entry point calls. +* :func:`build_application` — bundle everything a frontend / ``main()`` needs + (the synthesized phase enum, labels, section order, and a backend factory). + +Applications that need a non-default store or backend class (e.g. Crucible's crate +store) pass ``store_factory`` / ``backend_cls``; the **same** phase enum is shared +by the frontend and the pipeline. +""" + +import asyncio +import enum +import importlib +from dataclasses import dataclass, field +from typing import Any, Callable, cast + +from composer.io.multi_job import HandlerFactory +from composer.pipeline.core import ( + CorePhases, + CorePipelineResult, + PipelineRun, + run_pipeline, +) +from composer.pipeline.ecosystem import ECOSYSTEMS, Ecosystem +from composer.rustapp.adapter import RustBackend +from composer.rustapp.descriptor import AppDescriptor, CoreSlot +from composer.rustapp.result import RustFormalResult +from composer.rustapp.store import RustArtifactStore +from composer.sandbox.command import DEFAULT_TIMEOUT_S +from composer.sandbox.config import SandboxConfig +from composer.spec.artifacts import ArtifactStore +from composer.spec.context import SourceCode, WorkflowContext +from composer.spec.service_host import ServiceHost + +#: Build an artifact store for a run from the source + descriptor. +StoreFactory = Callable[[SourceCode, AppDescriptor], ArtifactStore[Any, RustFormalResult]] + + +@dataclass +class BackendOptions: + """Mutable run options closed over by :meth:`RustApplication.make_backend`. + + The CLI can adjust these (e.g. the sandbox) after building the application but + before :func:`run_application`, keeping one phase enum. Backend-specific tuning knobs + (e.g. a fuzz budget) travel as descriptor-declared args in :attr:`declared_args`. + """ + + command_timeout_s: int = DEFAULT_TIMEOUT_S + sandbox: SandboxConfig | None = None + #: Parsed values of the descriptor's declared CLI args, threaded into the backend and + #: injected into every component's ``AuthorInput.context``. Set by the entry point. + declared_args: dict[str, Any] = field(default_factory=dict) + + +def load_module(module_name: str) -> Any: + """Import a Rust application's compiled module by name (e.g. ``"echoprover"``).""" + return importlib.import_module(module_name) + + +def load_descriptor(module: Any) -> AppDescriptor: + """Parse a module's ``descriptor()`` JSON into an :class:`AppDescriptor`.""" + return AppDescriptor.model_validate_json(module.descriptor()) + + +def resolve_ecosystem(descriptor: AppDescriptor) -> Ecosystem[Any, Any, Any]: + """Resolve the descriptor's declared ecosystem against the registry.""" + eco = ECOSYSTEMS.get(descriptor.ecosystem) + if eco is None: + raise ValueError( + f"application {descriptor.name!r} selects ecosystem {descriptor.ecosystem!r}, " + f"which is not registered. Available: {sorted(ECOSYSTEMS)}." + ) + return eco + + +def build_phase_enum(descriptor: AppDescriptor) -> type[enum.Enum]: + """Synthesize the pipeline's phase enum from the descriptor. Safe: the code + only ever uses phase members for ``.name`` and as dict keys (no isinstance / + identity checks against a static class).""" + ordered = descriptor.ordered_phases() + name = "".join(part.capitalize() for part in descriptor.name.split("_")) + "Phase" + # enum.Enum's functional API is typed as returning an ``Enum`` instance, not the new + # class; it does return a class at runtime. + return cast(type[enum.Enum], enum.Enum(name, {p.key: p.key for p in ordered})) + + +def build_core_phases( + descriptor: AppDescriptor, phase: type[enum.Enum] +) -> CorePhases: + """Map the descriptor's core-slot declarations onto the synthesized enum. Every + slot must be filled — the driver tags all four.""" + slot_to_key = descriptor.core_slot_map() + missing = [s.value for s in CoreSlot if s not in slot_to_key] + if missing: + raise ValueError( + f"descriptor {descriptor.name!r} is missing core phase(s): {missing}. " + "Every application must map analysis/extraction/formalization/report." + ) + return CorePhases( + analysis=phase[slot_to_key[CoreSlot.ANALYSIS]], + extraction=phase[slot_to_key[CoreSlot.EXTRACTION]], + formalization=phase[slot_to_key[CoreSlot.FORMALIZATION]], + report=phase[slot_to_key[CoreSlot.REPORT]], + ) + + +def _default_store(source: SourceCode, descriptor: AppDescriptor) -> RustArtifactStore: + return RustArtifactStore( + source.project_root, + descriptor.artifact_layout, + deliverable_mode=descriptor.deliverable_mode, + program=str(source.contract_name), + ) + + +def build_backend( + module: Any, + descriptor: AppDescriptor, + source: SourceCode, + *, + phase: type[enum.Enum] | None = None, + core_phases: CorePhases | None = None, + store_factory: StoreFactory | None = None, + backend_cls: type[RustBackend] = RustBackend, + options: BackendOptions | None = None, +) -> RustBackend: + """Construct a :class:`RustBackend` (phase enum + core phases + store + ecosystem). + + Prefer :func:`build_application` + :meth:`RustApplication.make_backend` so the + frontend and pipeline share one phase enum. This is the headless path. + """ + opts = options or BackendOptions() + ph = phase if phase is not None else build_phase_enum(descriptor) + core = core_phases if core_phases is not None else build_core_phases(descriptor, ph) + sf = store_factory or _default_store + return backend_cls( + module=module, + descriptor=descriptor, + _phase=ph, + _core_phases=core, + artifact_store=sf(source, descriptor), + ecosystem=resolve_ecosystem(descriptor), + command_timeout_s=opts.command_timeout_s, + sandbox=opts.sandbox, + declared_args=opts.declared_args, + ) + + +async def run_rust_pipeline( + module_name: str, + source_input: SourceCode, + ctx: WorkflowContext[None], + handler_factory: HandlerFactory, + env: ServiceHost, + *, + max_concurrent: int = 4, + max_bug_rounds: int = 3, + interactive: bool = False, +) -> CorePipelineResult[RustFormalResult]: + """Build the backend from ``module_name`` and run the shared driver — the Rust + analogue of ``run_autoprove_pipeline`` / ``run_foundry_pipeline``. + + This synthesizes a *fresh* phase enum internal to the backend. It is the right + entry for headless callers whose handler ignores phases; for a TUI/console + frontend, build a :class:`RustApplication` once and use :func:`run_application` + so the frontend's labels and the backend's phases share one enum object.""" + app = build_application(module_name) + return await run_application( + app, + source_input, + ctx, + handler_factory, + env, + max_concurrent=max_concurrent, + max_bug_rounds=max_bug_rounds, + interactive=interactive, + ) + + +async def run_application( + app: "RustApplication", + source_input: SourceCode, + ctx: WorkflowContext[None], + handler_factory: HandlerFactory, + env: ServiceHost, + *, + max_concurrent: int = 4, + max_bug_rounds: int = 3, + interactive: bool = False, +) -> CorePipelineResult[RustFormalResult]: + """Run a pre-built :class:`RustApplication`. The backend is constructed from the + app's already-synthesized phase enum, so the ``TaskInfo`` phases the driver emits + are the *same* enum members the frontend's ``phase_labels`` are keyed by — the + identity the frontend's label lookup relies on.""" + backend = app.make_backend(source_input) + run = PipelineRun( + ctx=ctx, source=source_input, _handler_factory=handler_factory, + _semaphore=asyncio.Semaphore(max_concurrent), env=env, + ) + return await run_pipeline( + backend, run, ecosystem=app.ecosystem, interactive=interactive, threat_model=None, max_bug_rounds=max_bug_rounds + ) + + +@dataclass +class RustApplication: + """Everything a frontend / ``main()`` needs, synthesized from the descriptor. + + ``phase_labels`` is keyed by the synthesized enum members (member identity + drives the frontend's label lookup), and ``section_order`` lists every phase + label in declared order — the two inputs a ``MultiJobApp`` frontend consumes. + + ``options`` is mutable so the CLI can apply parsed flags (timeouts, sandbox) + before :func:`run_application` without rebuilding the phase enum. + """ + + descriptor: AppDescriptor + module: Any + ecosystem: Ecosystem[Any, Any, Any] + phase: type[enum.Enum] + core_phases: CorePhases + phase_labels: dict[Any, str] + section_order: list[str] + options: BackendOptions = field(default_factory=BackendOptions) + store_factory: StoreFactory = field(default=_default_store) + backend_cls: type[RustBackend] = RustBackend + + @property + def name(self) -> str: + return self.descriptor.name + + @property + def header_text(self) -> str: + return self.descriptor.header_text + + def validate_preconditions(self, args: dict) -> str | None: + """Delegate to the Rust precondition hook; return an error string or None.""" + import json + + return self.module.validate_preconditions(json.dumps(args)) + + def make_backend(self, source: SourceCode) -> RustBackend: + """Build the backend for this run — same phase enum as :attr:`phase_labels`.""" + return build_backend( + self.module, + self.descriptor, + source, + phase=self.phase, + core_phases=self.core_phases, + store_factory=self.store_factory, + backend_cls=self.backend_cls, + options=self.options, + ) + + +def build_application( + module_name: str, + *, + store_factory: StoreFactory | None = None, + backend_cls: type[RustBackend] = RustBackend, + command_timeout_s: int = DEFAULT_TIMEOUT_S, + sandbox: SandboxConfig | None = None, +) -> RustApplication: + """Load a Rust wheel and synthesize a :class:`RustApplication`. + + ``store_factory`` / ``backend_cls`` let an application supply a specialized + store or prepared-system path (Crucible) while keeping one phase enum for the + frontend and the pipeline. + """ + module = load_module(module_name) + descriptor = load_descriptor(module) + ecosystem = resolve_ecosystem(descriptor) + phase = build_phase_enum(descriptor) + core = build_core_phases(descriptor, phase) + ordered = descriptor.ordered_phases() + phase_labels = {phase[p.key]: p.label for p in ordered} + section_order = [p.label for p in ordered] + + return RustApplication( + descriptor=descriptor, + module=module, + ecosystem=ecosystem, + phase=phase, + core_phases=core, + phase_labels=phase_labels, + section_order=section_order, + options=BackendOptions( + command_timeout_s=command_timeout_s, + sandbox=sandbox, + ), + store_factory=store_factory or _default_store, + backend_cls=backend_cls, + ) diff --git a/composer/rustapp/result.py b/composer/rustapp/result.py new file mode 100644 index 00000000..43385c8f --- /dev/null +++ b/composer/rustapp/result.py @@ -0,0 +1,68 @@ +"""The Rust backend's result type (``FormT``) and artifact identifier. + +``RustFormalResult`` is a plain pydantic model — the design's rule is that the +result stays Python-native so the driver's type-keyed cache +(``cache_get(formalizer.formalized_type)`` / ``cache_put``) round-trips it +unchanged. Rust returns its fields as JSON (a ``Formalized``); the adapter +validates them into this model. It satisfies both ``FormalResult`` +(``artifact_text`` / ``commentary`` / ``property_units()``) and +``ReportableResult`` (``skipped`` / ``property_units()`` / ``output_link``) +structurally. +""" + +from dataclasses import dataclass + +from pydantic import BaseModel, Field + +from composer.spec.cvl_generation import SkippedProperty + + +class RustFormalResult(BaseModel): + """A successful Rust formalization. ``units`` holds the property→unit-names + map as JSON-friendly lists; ``property_units()`` re-tuples it for the + protocols. The field is *not* named ``property_units`` to avoid clashing with + that required method.""" + + commentary: str = "" + artifact_text: str = "" + units: list[tuple[str, list[str]]] = Field(default_factory=list) + skipped: list[SkippedProperty] = Field(default_factory=list) + output_link: str | None = None + # Per-unit verdicts baked in at formalize time by a self-contained backend + # (unit name -> the Rust ``Verdict`` dict: {outcome, line?, duration_seconds?, + # unit_file?}). Empty for run-service-backed backends (they use fetch_verdicts). + verdicts: dict[str, dict] = Field(default_factory=dict) + + def property_units(self) -> list[tuple[str, list[str]]]: + return [(title, list(names)) for title, names in self.units] + + @classmethod + def from_formalized(cls, formalized: dict) -> "RustFormalResult": + """Build from a Rust ``Formalized`` dict (the payload of ``Command::Publish``).""" + return cls( + commentary=formalized.get("commentary", ""), + artifact_text=formalized.get("artifact_text", ""), + units=[(t, list(u)) for t, u in formalized.get("property_units", [])], + skipped=[SkippedProperty(**s) for s in formalized.get("skipped", [])], + output_link=formalized.get("output_link"), + verdicts=dict(formalized.get("verdicts", {})), + ) + + +@dataclass(frozen=True) +class RustArtifact: + """Artifact identifier for a Rust backend — ``{prefix}_{slug}.{extension}``. + Prefix/extension come from the descriptor's ``ArtifactLayout`` so naming lives + in one place.""" + + slug: str + prefix: str + extension: str + + @property + def stem(self) -> str: + return f"{self.prefix}_{self.slug}" + + @property + def artifact_file(self) -> str: + return f"{self.stem}.{self.extension}" diff --git a/composer/rustapp/results.py b/composer/rustapp/results.py new file mode 100644 index 00000000..95f85be1 --- /dev/null +++ b/composer/rustapp/results.py @@ -0,0 +1,103 @@ +"""Human-facing rollup of a Rust backend's per-unit verdicts for the console / TUI. + +The canonical results artifact is ``report.json`` (the shared report phase). But — as with the +CVL and Foundry backends — the console/TUI otherwise surface only a counts block, so a completed +run reads as "success" with no visible verdicts. This turns the per-unit verdicts baked into the +pipeline result (:attr:`RustFormalResult.verdicts`, published by ``validate``) into a compact +tally + per-unit listing, using the report's own outcome labels so the wording matches the HTML +report. + +Backend-agnostic: the outcome wording is parametrized by the descriptor's ``backend_tag`` (was +hard-coded ``crucible`` when this lived in ``composer/crucible/results.py``), so any Rust app whose +results carry verdicts gets the same summary. +""" + +from collections import Counter +from dataclasses import dataclass + +from composer.pipeline.core import CorePipelineResult, Delivered +from composer.rustapp.result import RustFormalResult +from composer.spec.source.report.render import outcome_label +from composer.spec.source.report.schema import Outcome, ReportBackend + +# Tally display order — mirrors render.py's ``_OUTCOME_ORDER`` so the console and the HTML report +# list outcomes in the same sequence. +_ORDER = [Outcome.GOOD, Outcome.BAD, Outcome.TIMEOUT, Outcome.ERROR, Outcome.UNKNOWN] + +# Per-verdict glyph — mirrors the TUI's lifecycle indicators (✓/✗) so a GOOD/BAD scans at a glance. +_GLYPH: dict[Outcome, str] = { + Outcome.GOOD: "✓", + Outcome.BAD: "✗", + Outcome.TIMEOUT: "⧖", + Outcome.ERROR: "!", + Outcome.UNKNOWN: "?", +} + + +@dataclass(frozen=True) +class UnitVerdict: + """One unit's outcome: its display name and the neutral ``Outcome``.""" + + name: str + outcome: Outcome + + +@dataclass(frozen=True) +class VerdictSummary: + """The delivered units' verdicts, in pipeline order, plus the report backend tag for wording.""" + + verdicts: list[UnitVerdict] + backend_tag: ReportBackend + + @property + def counts(self) -> dict[Outcome, int]: + """Occurrence count per outcome, in display order, omitting absent outcomes.""" + c = Counter(v.outcome for v in self.verdicts) + return {o: c[o] for o in _ORDER if c.get(o)} + + @property + def tally(self) -> str: + """A one-line ``"10 No counterexample, 1 Counterexample"`` summary (backend labels).""" + return ", ".join( + f"{n} {outcome_label(self.backend_tag, o)}" for o, n in self.counts.items() + ) + + +def _parse_outcome(raw: str) -> Outcome: + try: + return Outcome(raw) + except ValueError: + return Outcome.UNKNOWN + + +def summarize_verdicts( + result: CorePipelineResult[RustFormalResult], backend_tag: ReportBackend +) -> VerdictSummary: + """Extract the per-unit verdicts baked into a completed run's ``outcomes``. + + Only *delivered* units carry a verdict; give-ups / exceptions are already surfaced in + ``result.failures`` and skipped here. Each delivered unit bakes a single verdict, so we + read the one entry (falling back to UNKNOWN if a delivered result somehow carries none).""" + verdicts: list[UnitVerdict] = [] + for o in result.outcomes: + if not isinstance(o.result, Delivered): + continue + baked = o.result.result.verdicts + outcome = ( + _parse_outcome(next(iter(baked.values()))["outcome"]) if baked else Outcome.UNKNOWN + ) + verdicts.append(UnitVerdict(o.feat.display_name, outcome)) + return VerdictSummary(verdicts, backend_tag) + + +def format_verdict_lines(summary: VerdictSummary, *, indent: str = " ") -> list[str]: + """The ``Verdicts:`` tally line plus a per-unit listing, in the console counts-block style. + Empty when no unit was delivered (the counts/failures block already conveys that).""" + if not summary.verdicts: + return [] + lines = [f"{indent}Verdicts: {summary.tally}"] + for v in summary.verdicts: + lines.append( + f"{indent} {_GLYPH[v.outcome]} {v.name} — {outcome_label(summary.backend_tag, v.outcome)}" + ) + return lines diff --git a/composer/rustapp/store.py b/composer/rustapp/store.py new file mode 100644 index 00000000..542d958b --- /dev/null +++ b/composer/rustapp/store.py @@ -0,0 +1,63 @@ +"""Artifact store for a Rust application. + +A thin :class:`composer.spec.artifacts.ArtifactStore` subclass: the base already +writes everything identical across backends (``properties.json``, +``commentary.md``, the property→units map, ``token_usage.json``) and materializes +the artifact bytes from ``result.artifact_text``. All this subclass supplies is +the on-disk layout, taken from the descriptor's :class:`ArtifactLayout`. +""" + +from pathlib import Path +from typing import override + +from composer.rustapp.descriptor import ArtifactLayout, DeliverableMode +from composer.rustapp.result import RustArtifact, RustFormalResult +from composer.spec.artifacts import ArtifactStore +from composer.spec.util import ensure_dir + + +class RustArtifactStore(ArtifactStore[RustArtifact, RustFormalResult]): + """Persist a Rust backend's deliverables under the descriptor's layout. + + ``deliverable_mode`` selects how the *source* deliverable is written: + ``per_component`` (the default) writes one ``{prefix}_{slug}.{ext}`` file per component from + its ``artifact_text``; ``callout`` writes no per-component source — the wheel's ``finalize`` + renders the whole deliverable (e.g. Crucible's one shared crate). Either way the shared + metadata (``commentary.md`` / the property→units map) is written per component.""" + + def __init__( + self, + project_root: str, + layout: ArtifactLayout, + *, + deliverable_mode: DeliverableMode = DeliverableMode.PER_COMPONENT, + program: str = "", + ): + self._layout = layout + self._deliverable_mode = deliverable_mode + self._program = program + super().__init__( + project_root, + layout.property_suffix, + deliverable_dir=layout.deliverable_dir, + internal_dir=layout.internal_dir, + report_dir=layout.report_dir, + ) + + @override + def _artifact_dir(self) -> Path: + return ensure_dir(Path(self._project_root) / self._layout.artifact_dir) + + @override + def write_artifact(self, i: RustArtifact, artifact: RustFormalResult) -> Path: + """In ``callout`` mode, write only the shared metadata and return the (whole-deliverable) + report link — the source files come from the wheel's ``finalize``. Otherwise defer to the + base one-file-per-component writer.""" + if self._deliverable_mode is not DeliverableMode.CALLOUT: + return super().write_artifact(i, artifact) + self._write_commentary(i.stem, artifact.commentary) + self._write_property_map( + i.stem, self._property_suffix, {k: v for k, v in artifact.property_units()} + ) + primary = self._layout.deliverable_primary + return Path(primary.format(program=self._program) if primary else self._layout.deliverable_dir) diff --git a/composer/spec/solana/build.py b/composer/spec/solana/build.py new file mode 100644 index 00000000..e59b1e9b --- /dev/null +++ b/composer/spec/solana/build.py @@ -0,0 +1,134 @@ +"""Build a Solana program to sBPF (and, optionally, its IDL). + +The shared "Solana build capability" (``docs/crucible-application.md`` §5.1): +``source → .so [+ IDL]``. It is deliberately backend-agnostic — the Crucible +backend calls it in *no-munge* mode (build the program as-is), and a future +Certora-Prover/CVLR backend will call it in *munge-and-rebuild* mode (rewrite the +source first). Both route through the same :func:`run_local_command` choke point +the ``RunCommand`` effect uses, so phase-6 sandboxing (§7.4) wraps one path. +""" + +import logging +from dataclasses import dataclass +from pathlib import Path + +from composer.sandbox.command import CommandResult, run_local_command +from composer.sandbox.config import SandboxConfig + +_log = logging.getLogger(__name__) + +DEFAULT_BUILD_TIMEOUT_S = 600 + + +class BuildError(RuntimeError): + """A build step (``cargo-build-sbf`` / ``anchor idl``) failed.""" + + def __init__(self, step: str, result: CommandResult): + self.step = step + self.result = result + # Keep the tail of stderr — the actionable part of a cargo error. + super().__init__(f"{step} failed (exit {result.exit_code}):\n{result.stderr[-2000:]}") + + +@dataclass(frozen=True) +class BuiltProgram: + """Where the build left its outputs.""" + + program: str + so_path: Path + idl_path: Path | None + + +async def warm_cargo_cache( + manifest_dir: str | Path, + *, + cargo_binary: str = "cargo", + cargo_home: str | Path | None = None, + timeout_s: int = DEFAULT_BUILD_TIMEOUT_S, +) -> CommandResult: + """Populate ``CARGO_HOME`` with the deps declared in ``manifest_dir/Cargo.toml``. + + Run **outside** any sandbox (with network) so a later *sandboxed*, + ``CARGO_NET_OFFLINE`` build finds every dep warm (``docs/command-sandbox.md`` §5). + ``cargo fetch`` downloads but never runs build scripts, so no untrusted code + executes here — the code-exec build happens confined + offline. Best-effort: a + fetch failure is logged (the offline build will surface a hard error if a dep is + genuinely missing), not raised. + + ``cargo_home`` fetches into a specific (per-run, private) ``CARGO_HOME`` — it must + be the *same* home the sandboxed build will use, or the offline build won't find + the deps. Defaults to the ambient ``CARGO_HOME`` when omitted. + """ + overlay = {"CARGO_HOME": str(cargo_home)} if cargo_home is not None else None + res = await run_local_command( + cargo_binary, ["fetch"], {}, workdir=Path(manifest_dir), + timeout_s=timeout_s, env_overlay=overlay, + ) + if res.exit_code != 0: + _log.warning( + "cargo fetch in %s failed (exit %s); a sandboxed offline build may fail. stderr:\n%s", + manifest_dir, + res.exit_code, + res.stderr[-500:], + ) + return res + + +async def build_program( + project_root: str | Path, + program: str, + *, + build_binary: str = "cargo-build-sbf", + anchor_binary: str = "anchor", + with_idl: bool = False, + timeout_s: int = DEFAULT_BUILD_TIMEOUT_S, + sandbox: SandboxConfig | None = None, +) -> BuiltProgram: + """Compile ``program`` in the workspace at ``project_root`` to + ``target/deploy/.so``. If ``with_idl``, also try ``anchor idl build`` + (best-effort — not every project has an ``Anchor.toml``; the same-version + harness path depends on the program crate directly and needs no IDL). + + ``cargo-build-sbf`` runs the user program's ``build.rs`` natively, so it is + confined by ``sandbox`` when one is supplied (``docs/command-sandbox.md``); + ``None`` runs it unsandboxed (trusted input only). + + Raises :class:`BuildError` if the ``.so`` is not produced. + """ + root = Path(project_root) + provider = policy = None + if sandbox is not None and sandbox.enabled: + provider = sandbox.resolve_provider() + policy = sandbox.build_policy(root) + assert policy is not None # enabled config ⇒ build_policy returns a real policy + # Warm the registry with network BEFORE the sandboxed, offline build (§5), + # into the SAME private CARGO_HOME the sandboxed build will read (the policy's). + await warm_cargo_cache(root, cargo_home=policy.env_allowlist.get("CARGO_HOME"), timeout_s=timeout_s) + + res = await run_local_command( + build_binary, [], {}, workdir=root, timeout_s=timeout_s, provider=provider, policy=policy + ) + so = root / "target" / "deploy" / f"{program}.so" + if res.exit_code != 0 or not so.is_file(): + raise BuildError("cargo-build-sbf", res) + + idl_path: Path | None = None + if with_idl: + out_rel = f"target/idl/{program}.json" + idl_res = await run_local_command( + anchor_binary, ["idl", "build", "-o", out_rel], {}, workdir=root, + timeout_s=timeout_s, provider=provider, policy=policy, + ) + candidate = root / out_rel + if idl_res.exit_code == 0 and candidate.is_file(): + idl_path = candidate + else: + _log.warning( + "IDL build did not produce %s (exit %s); continuing without an IDL " + "(same-version harnesses depend on the program crate directly). stderr tail:\n%s", + candidate, + idl_res.exit_code, + idl_res.stderr[-500:], + ) + + return BuiltProgram(program=program, so_path=so, idl_path=idl_path) diff --git a/composer/spec/source/report/collect.py b/composer/spec/source/report/collect.py index ddff5b8e..08b5064b 100644 --- a/composer/spec/source/report/collect.py +++ b/composer/spec/source/report/collect.py @@ -69,10 +69,14 @@ class Verdict: line: int | None = None duration_seconds: float | None = None unit_file: str | None = None + #: Human-readable explanation of a non-GOOD outcome (a counterexample / assertion message for + #: a BAD, error text for an ERROR). Provenance/diagnostics only; ``None`` when the backend + #: gives no detail (the prover/foundry fetchers don't). + message: str | None = None def merge(self, other: "Verdict | None") -> "Verdict": """Combine two results for one unit within a run: higher-priority outcome wins, - line/duration/unit_file kept from whichever side has them.""" + line/duration/unit_file/message kept from whichever side has them.""" if other is None: return self hi, lo = ( @@ -85,6 +89,7 @@ def merge(self, other: "Verdict | None") -> "Verdict": hi.line if hi.line is not None else lo.line, hi.duration_seconds if hi.duration_seconds is not None else lo.duration_seconds, hi.unit_file or lo.unit_file, + hi.message or lo.message, ) @@ -162,7 +167,7 @@ def _ref(unit_name: str) -> RuleRef: if key not in rules_by_key: rules_by_key[key] = RuleVerdict( name=unit_name, spec_file=key[0], outcome=v.outcome, line=v.line, - duration_seconds=v.duration_seconds, prover_link=run_link, + duration_seconds=v.duration_seconds, prover_link=run_link, message=v.message, ) # A referenced unit with no verdict still needs an (UNKNOWN) entry to render. diff --git a/composer/spec/source/report/render.py b/composer/spec/source/report/render.py index 25986c51..e7f1ab59 100644 --- a/composer/spec/source/report/render.py +++ b/composer/spec/source/report/render.py @@ -57,6 +57,10 @@ Outcome.GOOD: "Successful test", Outcome.BAD: "Failing test", Outcome.ERROR: "Error", Outcome.TIMEOUT: "Timeout", Outcome.UNKNOWN: "Unknown", }, + "crucible": { + Outcome.GOOD: "No counterexample", Outcome.BAD: "Counterexample", Outcome.ERROR: "Error", + Outcome.TIMEOUT: "Timeout", Outcome.UNKNOWN: "Unknown", + }, } _GROUP_LABELS: dict[ReportBackend, dict[GroupStatus, str]] = { "prover": { @@ -67,6 +71,10 @@ GroupStatus.GOOD: "All tests passing", GroupStatus.BAD: "Has failing test", GroupStatus.PARTIAL: "Partial", GroupStatus.UNKNOWN: "No results", }, + "crucible": { + GroupStatus.GOOD: "No counterexamples", GroupStatus.BAD: "Has counterexample", + GroupStatus.PARTIAL: "Partial", GroupStatus.UNKNOWN: "No results", + }, } @@ -89,6 +97,10 @@ class ReportTerms(TypedDict): title="Foundry test report", unit_singular="test", unit_plural="tests", unit_cap="Test", outcomes_label="Test outcomes", ), + "crucible": ReportTerms( + title="Crucible fuzzing report", unit_singular="property", unit_plural="properties", + unit_cap="Property", outcomes_label="Property outcomes", + ), } # Chip display order for the header outcome counts. @@ -123,6 +135,9 @@ class RowView(TypedDict): line: int | None link: LinkView descriptions: list[str] + #: Backend diagnostic for a non-GOOD row (e.g. the fuzzer's counterexample / failed-assertion + #: message). ``None`` when the backend supplied none. + message: str | None class GroupView(TypedDict): @@ -151,6 +166,16 @@ class ReportTemplateParams(TypedDict): _REPORT_TEMPLATE = TypedTemplate[ReportTemplateParams]("autoprove_report.html.j2") +def outcome_label(backend: ReportBackend, outcome: Outcome) -> str: + """The human word an auditor reads for an ``Outcome`` under a backend (e.g. a + ``crucible`` ``GOOD`` → "No counterexample", a ``prover`` ``GOOD`` → "Verified"). + + The report's HTML render is the primary consumer, but the console/TUI verdict + rollups reuse this so the same run reads with one vocabulary everywhere — this is + the single place the per-backend wording lives.""" + return _OUTCOME_LABELS[backend][outcome] + + def _is_url(link: str) -> bool: return link.startswith("http://") or link.startswith("https://") @@ -216,6 +241,7 @@ def _group_view( "line": rule.line if rule else None, "link": _link_view(rule.prover_link if rule else None), "descriptions": descriptions[ref], + "message": rule.message if rule else None, }) return { "slug": group.slug, diff --git a/composer/spec/source/report/schema.py b/composer/spec/source/report/schema.py index a791ac7e..e269678c 100644 --- a/composer/spec/source/report/schema.py +++ b/composer/spec/source/report/schema.py @@ -84,6 +84,11 @@ class RuleVerdict(BaseModel): line: int | None = None duration_seconds: float | None = None prover_link: str | None = None + message: str | None = Field( + default=None, + description="Human-readable explanation of a non-GOOD outcome (e.g. the fuzzer's " + "counterexample / failed-assertion message). Diagnostics only; may be absent.", + ) @property def ref(self) -> RuleRef: @@ -151,10 +156,13 @@ class CoverageReport(BaseModel): warnings: list[str] = Field(default_factory=list) -type ReportBackend = Literal["prover", "foundry"] +type ReportBackend = Literal["prover", "foundry", "crucible"] """Which pipeline produced this report. Provenance only — every backend fills the same fields; -this tag just lets the renderer pick the right outcome labels ("Verified" vs "Successful test") -for a report.json it reads cold.""" +this tag just lets the renderer pick the right outcome labels ("Verified" vs "Successful test" +vs "No counterexample") for a report.json it reads cold. The backends are the CVL prover +(``"prover"``), Foundry (``"foundry"``), and the Rust/Crucible fuzzer (``"crucible"``, produced by +``composer.rustapp``/``composer.crucible``). The set is closed: every backend lives in this repo, +so a new one adds its literal here (and its labels in ``report/render.py``).""" class AutoProverReport(BaseModel): diff --git a/docs/formalization-abstraction.md b/docs/formalization-abstraction.md new file mode 100644 index 00000000..de1c37bd --- /dev/null +++ b/docs/formalization-abstraction.md @@ -0,0 +1,586 @@ +# Design Doc — The Formalization Abstraction + +> Detailed design of how AutoProver turns *extracted properties* into *verified +> artifacts*, the abstraction that makes it backend-agnostic, and a concrete +> walk-through of the CVL (Certora Prover) implementation. +> +> Companion to [ARCHITECTURE.md](../ARCHITECTURE.md). Where that document maps the +> whole system, this one zooms into a single seam: the contract between the generic +> pipeline driver and a verification backend. + +--- + +## 1. Problem & motivation + +The pipeline has two kinds of work: + +- **Shared work** that is identical no matter what you generate — analyze the system + into components, infer a list of properties per component, cache expensive results, + and assemble a final report. +- **Backend-specific work** — *how* a property becomes a checkable artifact, and *how* + that artifact's pass/fail verdict is obtained. For the CVL backend this is "author a + `.spec`, run the Certora Prover, revise on counterexamples." For the Foundry backend + it is "write a `.t.sol`, run `forge test`." + +The **formalization abstraction** is the seam between the two. It lets the driver in +[composer/pipeline/core.py](../composer/pipeline/core.py) own all the shared work while +delegating every backend-specific decision through a small, typed protocol. The CVL and +Foundry backends are two implementations of that protocol; the driver never imports +either. + +### Design goals + +1. **The driver inspects nothing backend-specific.** It moves opaque `FormT` values + around; only the backend ever looks inside them. +2. **No half-initialized state.** Each phase yields an immutable object that is the + constructor input to the next, so ordering is enforced by the type system, not by + call-order discipline. +3. **One result type threads everything.** A single generic parameter `FormT` keys the + cache, the artifact store, the verdict fetcher, and the report — so they cannot drift + out of agreement. +4. **Concurrency is structural.** Pre-formalization setup overlaps property extraction; + per-component formalization fans out — all expressed in the driver, inherited by every + backend for free. + +--- + +## 2. The phase chain + +Formalization is the tail of a three-link immutable chain. Each arrow is a method whose +return type is the input to the next link: + +``` + PipelineBackend ──prepare_system──▶ PreparedSystem ──prepare_formalization──▶ Formalizer + (config, analysis spec, (.main: located main (formalize / verdicts / + artifact store) contract; backend setup) report inputs / finalize) +``` + +The driver ([core.py:230](../composer/pipeline/core.py)) sequences them: + +```python +# 1. shared: analyze source → SourceApplication (always the same type) +analyzed = await run.runner(TaskInfo(SYSTEM_ANALYSIS_TASK_ID, ...), lambda: run_component_analysis(...)) + +# 2. backend transform: prover lifts to a harnessed app; foundry is identity +prepared = await backend.prepare_system(analyzed, run) + +# 3. pre-formalization setup runs CONCURRENTLY with property extraction +formalizer_task = asyncio.create_task(prepared.prepare_formalization(run)) +batches = await _extract_all(prepared.main, backend.backend_guidance, run, ...) +formalizer = await formalizer_task + +# 4. per-component formalization (parallel), cache-wrapped by the driver +settled = await asyncio.gather(*[_run(b) for b in batches], return_exceptions=True) + +# 5. shared: build + persist the report from the outcomes + backend verdicts +report = await build_report(..., fetch_verdicts=formalizer.fetch_verdicts) +``` + +The key structural point: `prepare_formalization` is launched as a task *before* +extraction is awaited. For the CVL backend, that is what overlaps the slow AutoSetup / +summary / structural-invariant work with per-component property inference — and it falls +out of the driver generically, so Foundry gets the same overlap with zero extra code. + +--- + +## 3. The contract + +Three protocols + two abstract bases define the entire seam. All live in +[composer/pipeline/core.py](../composer/pipeline/core.py) and +[composer/spec/types.py](../composer/spec/types.py). + +### 3.1 The result type: `FormT` + +Everything is generic over one type variable, the *backend result*. It is the +intersection of two narrow protocols: + +```python +# composer/pipeline/core.py +class BackendResult(FormalResult, ReportableResult, Protocol): ... +``` + +- **`FormalResult`** ([types.py:43](../composer/spec/types.py)) — what *persistence* + needs: `property_units()`, `commentary`, `artifact_text`. +- **`ReportableResult`** ([report/collect.py:25](../composer/spec/source/report/collect.py)) + — what the *report* needs: `skipped`, `property_units()`, `output_link`. + +A backend's concrete result (for CVL, `GeneratedCVL`) structurally satisfies both. The +driver only ever holds it as an opaque `FormT`; it never reads a field. + +### 3.2 `Formalizer[FormT]` — the heart of the abstraction + +```python +# composer/pipeline/core.py +@dataclass +class Formalizer[FormT: BackendResult](ABC): + formalized_type: type[FormT] # the concrete result class — the cache get/put key + backend_tag: ReportBackend # "prover" | "foundry", stamped into the report + + @abstractmethod + async def formalize(self, label, feat, props, ctx, run) -> FormT | GaveUp: ... + + @abstractmethod + async def fetch_verdicts(self, inp: ReportComponentInput[FormT]) -> dict[RuleName, Verdict]: ... + + def extra_report_inputs(self) -> list[ReportComponentInput[FormT]]: + return [] # synthetic report rows; default none + + async def finalize(self, outcomes, run) -> None: + return None # run-level artifacts from the full outcome set; default none +``` + +The contract is deliberately small — one required producer (`formalize`), one required +reader (`fetch_verdicts`), and two optional hooks. Crucially, a `Formalizer` is +**immutable and fully constructed** by `prepare_formalization`: it carries its prover +config, resources, and tool as constructor state, never set post-hoc. By the time +`formalize` runs, every dependency is already present. + +### 3.3 `PreparedSystem[FormT]` — the formalizer factory + +```python +# composer/pipeline/core.py +@dataclass +class PreparedSystem[FormT: BackendResult](ABC): + main: ContractInstance # located main contract + @abstractmethod + async def prepare_formalization(self, run) -> Formalizer[FormT]: ... +``` + +### 3.4 The outcome types the driver produces + +```python +@dataclass(frozen=True) +class Delivered[FormT]: # a success + the path it was written to + result: FormT + deliverable: Path + +class GaveUp(BaseModel): # the single unified give-up signal + reason: str + +@dataclass +class ComponentOutcome[FormT](BackendJob): + result: Delivered[FormT] | GaveUp | BaseException # success / declined / crashed +``` + +`ComponentOutcome` is a closed sum of the three things that can happen to one component: +it was `Delivered`, the agent `GaveUp` with a reason, or it raised. The driver's `_tally` +([core.py:351](../composer/pipeline/core.py)) folds these into the run result; the report +phase renders each. + +--- + +## 4. The CVL backend, method by method + +The prover implementation lives in +[composer/spec/source/pipeline.py](../composer/spec/source/pipeline.py). It declares: + +```python +@dataclass +class ProverBackend: # PipelineBackend[AutoProvePhase, GeneratedCVL, None, ComponentSpec] + backend_guidance = CERTORA_BACKEND_GUIDANCE + core_phases = CorePhases({"analysis": ..., "extraction": ..., "formalization": AutoProvePhase.CVL_GEN}) + analysis_spec = SystemAnalysisSpec("source-analysis") + artifact_store: ProverArtifactStore + _prover_opts: ProverOptions +``` + +So `FormT = GeneratedCVL`, the artifact id type `A = ComponentSpec`, and the phase enum is +`AutoProvePhase`. + +### 4.1 `prepare_system` — harness lift + +```python +async def prepare_system(self, analyzed: SourceApplication, run) -> PreparedSystem[GeneratedCVL]: + sys_desc = await run.runner(TaskInfo(HARNESS_TASK_ID, ...), lambda: run_harness_creation(...)) + harnessed = _lift_harnessed(analyzed, sys_desc) # SourceApplication → HarnessedApplication + prover_tool = get_prover_tool(run.env.llm_heavy(), run.source.contract_name, + run.source.project_root, prover_opts=self._prover_opts) + return ProverPrepared(main_instance(harnessed, run.source), self.artifact_store, + sys_desc, harnessed, prover_tool, self._prover_opts, analyzed) +``` + +It runs the harness-classification agent, folds the generated harnesses back into the app +as a `HarnessedApplication` ([pipeline.py:69](../composer/spec/source/pipeline.py)), builds +the shared `verify_spec` prover tool once, and packages everything the next phase needs into +an immutable `ProverPrepared`. (Foundry's `prepare_system` is an identity transform — no +harness, no tool.) + +### 4.2 `prepare_formalization` — the concurrent setup fan-out + +This is where the CVL backend does its expensive pre-work, and it is the richest method in +the abstraction. From [pipeline.py:178](../composer/spec/source/pipeline.py): + +```python +async def prepare_formalization(self, run) -> Formalizer[GeneratedCVL]: + # AutoSetup (+ custom summaries) ∥ structural-invariant formulation — both depend only + # on the harnessed app, so they run concurrently. + (setup_config, resources), invariants = await asyncio.gather( + self._autosetup(run), self._invariants(run), + ) + + invariant = None + if invariants.inv: + inv_props = [PropertyFormulation(title=inv.name, description=inv.description, sort="invariant") + for inv in invariants.inv] + self._store.write_properties(InvariantSpec(), inv_props) + + # Generate invariants.spec ONCE, with cache short-circuit + inv_cvl_ctx = run.ctx.child(INV_CVL_KEY) + cached = await inv_cvl_ctx.cache_get(GeneratedCVL) + if cached is not None: + inv_cvl = cached + else: + inv_result = await run.runner(TaskInfo(INVARIANT_CVL_TASK_ID, ...), + lambda: batch_cvl_generation(inv_cvl_ctx.abstract(CVLGeneration), + setup_config.prover_config, inv_props, None, resources, self._prover_tool, ...)) + if isinstance(inv_result, GaveUp): + raise RuntimeError(f"Structural invariant CVL generation gave up: {inv_result.reason}") + inv_cvl = inv_result + await inv_cvl_ctx.cache_put(inv_cvl) + + inv_path = self._store.write_artifact(InvariantSpec(), inv_cvl) + # Append invariants.spec to the resource set so EVERY per-component spec imports it. + resources = [*resources, CVLResource(path=inv_path, required=False, + description="Structural invariants that may be assumed as preconditions", sort="import")] + invariant = (inv_props, Delivered(inv_cvl, inv_path)) + + return ProverRunner(GeneratedCVL, "prover", self._store, self._prover_tool, + setup_config.prover_config, resources, invariant, make_prover_fetcher()) +``` + +Three things worth calling out: + +- **Concurrency inside the method.** AutoSetup+summaries and invariant *formulation* are + independent, so they `gather`. This nests under the driver-level overlap (this whole + method already runs concurrently with property extraction). +- **Structural invariants are formalized eagerly, here, not per-component.** They are + generated once into `invariants.spec`, then injected into `resources` so every later + per-component spec can `import` them as preconditions. The invariant CVL goes through the + exact same `batch_cvl_generation` path that components do (with `component=None`). +- **The returned `ProverRunner` is fully loaded.** Its config, resource set (now including + `invariants.spec`), prover tool, and the in-memory invariant result are all constructor + fields. `formalize` adds nothing — it only *reads* them. + +### 4.3 `formalize` — per-component authoring + verification loop + +The `Formalizer.formalize` impl is a thin adapter; all the work is in +`batch_cvl_generation`: + +```python +# ProverRunner.formalize (pipeline.py:114) +async def formalize(self, label, feat, props, ctx, run) -> GeneratedCVL | GaveUp: + return await batch_cvl_generation( + ctx.abstract(CVLGeneration), self._prover_config, props, feat, + self._resources, self._prover_tool, run.env, label, run.source, SPECS_DIR) +``` + +`batch_cvl_generation` ([author.py:334](../composer/spec/source/author.py)) builds a +dedicated LLM agent graph and runs it to a fixpoint. The agent is given: + +- the property batch + component context, rendered into the prompt; +- the resource set as `import` views, with paths made relative to `certora/specs/` so the + prover resolves CVL `import`s correctly ([author.py:349](../composer/spec/source/author.py)); +- a tool belt: CVL authoring tools, the `verify_spec` prover tool, config-edit tools, and the + completion/give-up/expectation tools (`PublishResultTool`, `GiveUpTool`, + `ExpectRuleFailure`/`ExpectRulePassage`). + +Two **hard validation gates** must both pass before the agent may publish +([author.py:404](../composer/spec/source/author.py)): + +```python +required_validations=[FEEDBACK_VALIDATION_KEY, PROVER_VALIDATION_KEY] +``` + +- the **prover gate** — the spec must actually run; +- the **feedback gate** — a separate `property_feedback_judge` agent + ([composer/spec/feedback.py](../composer/spec/feedback.py)) adjudicates whether each + property is genuinely covered, and the author may file evidence-backed `Rebuttal`s + (typecheck failure / counterexample / manual citation / reasoned) against prior feedback. + +The agent loop ends in exactly one of two states, mapped onto the abstraction's +`FormT | GaveUp` ([author.py:413](../composer/spec/source/author.py)): + +```python +if res_state["failed"]: + return GaveUp(reason=res_state["result"]) +return GeneratedCVL(commentary=..., cvl=..., skipped=..., property_rules=..., + config=res_state["config"], final_link=res_state.get("prover_link")) +``` + +Note `config` and `final_link` are captured into the result. That is deliberate: a later +cache hit skips the prover entirely, so the result must carry enough to rebuild +`certora/confs/` and keep the run link without re-running anything. + +### 4.4 `extra_report_inputs` — folding in the invariants + +Per-component outcomes are assembled by the driver. The structural invariants are a +*synthetic* component the backend contributes ([pipeline.py:136](../composer/spec/source/pipeline.py)): + +```python +def extra_report_inputs(self) -> list[ReportComponentInput[GeneratedCVL]]: + if self._invariant is None: + return [] + inv_props, inv = self._invariant + return [ReportComponentInput(name="Structural Invariants", props=inv_props, formalized=inv)] +``` + +This is the report-side payoff of formalizing invariants in `prepare_formalization`: the +in-memory `Delivered[GeneratedCVL]` is replayed straight into the report with no special +casing in the driver. + +### 4.5 `fetch_verdicts` — pass/fail per rule + +```python +async def fetch_verdicts(self, inp) -> dict[RuleName, Verdict]: + return await self._fetch(inp) # make_prover_fetcher(): queries ProverOutputUtility off-thread +``` + +The fetcher resolves each spec's prover run (via `inp.formalized.run_link`) and rolls per-rule +outcomes into `Verdict`s. The `collect` step +([report/collect.py:104](../composer/spec/source/report/collect.py)) then keys rules by +`(unit_file, name)` so a structural invariant imported into several component specs collapses +to one entry, and uses `Verdict.merge` (priority `BAD > ERROR > TIMEOUT > UNKNOWN > GOOD`) to +roll up multiple results for one rule. Foundry's fetcher instead reads pass/fail straight off +the result with no run service — same protocol, different source. + +### 4.6 `finalize` — run-level artifact + +```python +async def finalize(self, outcomes, run) -> None: + runs = {ComponentSpec(o.feat.slugified_name).run_key: o.result.run_link + for o in outcomes if isinstance(o.result, Delivered) and o.result.run_link} + if self._invariant and self._invariant[1].run_link: + runs[InvariantSpec().run_key] = self._invariant[1].run_link + self._store.write_component_runs(runs) # → components_to_prover_runs.json +``` + +`finalize` is the one hook that sees the *entire* outcome set at once — used here to emit the +`{spec → prover-run link}` map. Foundry omits it (default no-op). + +--- + +## 5. The result type as the central key + +`GeneratedCVL` ([cvl_generation.py:125](../composer/spec/cvl_generation.py)) is the concrete +`FormT`. It satisfies `BackendResult` structurally — note nothing declares +`class GeneratedCVL(BackendResult)`; the protocols match by shape: + +```python +class GeneratedCVL(BaseModel): + commentary: str + cvl: str + skipped: list[SkippedProperty] = Field(default_factory=list) + property_rules: list[PropertyRuleMapping] = Field(default_factory=list) + config: dict | None = None + final_link: str | None = None + + def property_units(self) -> list[tuple[str, list[str]]]: # FormalResult + ReportableResult + return [(m.property_title, m.rules) for m in self.property_rules] + + @property + def artifact_text(self) -> str: # FormalResult: bytes to write + return self.cvl + + @property + def output_link(self) -> str | None: # ReportableResult: run link + return _output_link(self.final_link) # /jobStatus/ → /output/ +``` + +The same value is the key for four otherwise-independent subsystems, which is what keeps them +from disagreeing: + +| Consumer | Uses | Via | +|---|---|---| +| **Cache** | the *type* `GeneratedCVL` | `formalizer.formalized_type` → `cache_get`/`cache_put` | +| **Artifact store** | `artifact_text`, `commentary`, `property_units()` | `ArtifactStore.write_artifact` | +| **Report** | `skipped`, `property_units()`, `output_link` | `ReportableResult` | +| **Run-link map** | the persisted `final_link` | `finalize` | + +--- + +## 6. Persistence: the artifact store + +`Delivered` pairs a result with the path it was written to — and those two always travel +together because the path *exists only because the result did* +([core.py:98](../composer/pipeline/core.py)). The write happens in the driver's `_run` +closure: + +```python +backend.artifact_store.write_properties(result_key, batch.props) # before generation +... +Delivered(result, backend.artifact_store.write_artifact(result_key, result)) # after success +``` + +The store is generic over `(ArtifactIdentifier, FormalResult)` +([artifacts.py:32](../composer/spec/artifacts.py)). The base writes everything that is +identical across backends — `properties.json`, `commentary.md`, the +`{property title → demonstrating units}` map — keyed off the identifier's `stem`. The CVL +subclass [ProverArtifactStore](../composer/spec/source/artifacts.py) adds the +CVL-specific bundle: it overrides `write_artifact` to also emit a `.conf` (base config + +fixed run overlay) alongside the `.spec`. + +The artifact id is itself a small sum type, so naming conventions live in one place rather +than being interpolated at call sites: + +```python +@dataclass(frozen=True) +class ComponentSpec: # autospec_.spec + slug: str + @property + def stem(self): return f"autospec_{self.slug}" + @property + def run_key(self): return self.slug + +@dataclass(frozen=True) +class InvariantSpec: # invariants.spec + @property + def stem(self): return "invariants" +``` + +`ProverBackend.to_artifact_id(component)` maps a component instance to its `ComponentSpec`; +the driver uses it both to write properties before generation and to write the artifact after. + +The resulting on-disk layout (all under the project's `certora/`): + +``` +certora/specs/autospec_.spec # per-component CVL +certora/specs/invariants.spec # structural invariants (imported by the above) +certora/confs/.conf # prover config per spec +certora/properties/.properties.json # inferred properties +certora/properties/.property_rules.json # property → [rule names] +certora/properties/.commentary.md # author's commentary +certora/ap_report/report.json # final cross-referenced report +.certora_internal/autoProve/components_to_prover_runs.json # finalize() output +``` + +--- + +## 7. Caching wraps formalization (driver-owned) + +A backend never writes cache logic — the driver does, keyed by +`formalizer.formalized_type` ([core.py:271](../composer/pipeline/core.py)): + +```python +async def _run(batch): + result_key = backend.to_artifact_id(batch.feat) + backend.artifact_store.write_properties(result_key, batch.props) + child = await batch.feat_ctx.child(_batch_cache_key(batch.props), {...}) + cached = await child.cache_get(formalizer.formalized_type) # ← type comes from the formalizer + if cached is None: + result = await run.runner(TaskInfo(formalize_task_id(...)), + lambda: formalizer.formalize(label, batch.feat, batch.props, child, run)) + if not isinstance(result, GaveUp): + await child.cache_put(result) + else: + result = cached + ... +``` + +The cache key is the hash of the *property batch* (`_batch_cache_key`), under the component's +context, under the `properties` context — the hierarchical scheme described in +[ARCHITECTURE.md §7](../ARCHITECTURE.md). Because the result type carries `config` and +`final_link`, a cache hit can rebuild the `.conf` and keep the run link without touching the +prover. The structural-invariant CVL has its own cache short-circuit inside +`prepare_formalization` (`INV_CVL_KEY`, §4.2) for the same reason. + +--- + +## 8. Failure handling + +The abstraction encodes three distinct failure modes, each handled differently: + +| Failure | Representation | Driver behavior | +|---|---|---| +| Agent declines a component | `formalize` returns `GaveUp(reason)` | recorded as a `ComponentOutcome`, surfaced in `failures`, rendered in report as a gap; **not cached** | +| Component crashes | `formalize` raises | `asyncio.gather(..., return_exceptions=True)` captures it into `ComponentOutcome.result` | +| Invariant CVL gives up | `prepare_formalization` raises `RuntimeError` | **fatal** — invariants are a shared precondition, so the whole run aborts | +| Report build fails | exception in `build_report` | best-effort: logged, run still succeeds ([core.py:318](../composer/pipeline/core.py)) | + +The asymmetry is intentional: a single component giving up is a normal, reportable outcome, +but the shared invariant spec failing would silently weaken every downstream component, so it +fails loud. + +--- + +## 9. Extending: what a new backend must provide + +To add a backend you implement the protocol and the three phase objects — nothing in the +driver changes. The Foundry backend +([composer/foundry/pipeline.py](../composer/foundry/pipeline.py)) is the proof: it reuses +system analysis, property extraction, caching, and the report, and contributes only: + +| Abstraction member | CVL backend | Foundry backend | +|---|---|---| +| `FormT` | `GeneratedCVL` | `GeneratedFoundryTest` | +| `prepare_system` | harness lift + prover tool | identity | +| `prepare_formalization` | AutoSetup ∥ summaries ∥ invariants | trivial (pre-built formalizer) | +| `formalize` | author CVL, run prover, revise | author tests, run `forge test` | +| `fetch_verdicts` | query prover output off-thread | read ran/expected tests off the result | +| `extra_report_inputs` | synthetic "Structural Invariants" | none | +| `finalize` | `components_to_prover_runs.json` | none | +| artifact bundle | `.spec` + `.conf` | `.t.sol` + metadata | + +A backend author's checklist: + +1. Define a result type satisfying `FormalResult` + `ReportableResult` (`artifact_text`, + `commentary`, `property_units()`, `skipped`, `output_link`). +2. Subclass `ArtifactStore` for the on-disk bundle; define an `ArtifactIdentifier` sum type. +3. Implement `PipelineBackend` (`prepare_system`, `to_artifact_id`, `backend_guidance`, + `core_phases`, `analysis_spec`, `artifact_store`). +4. Implement `PreparedSystem.prepare_formalization` returning a fully-constructed + `Formalizer`. +5. Implement `Formalizer.formalize` + `fetch_verdicts`; override `extra_report_inputs` / + `finalize` only if needed. + +--- + +## 10. End-to-end trace (CVL backend) + +Putting it together, one run of the CVL backend over a component: + +``` +run_autoprove_pipeline +└─ run_pipeline(ProverBackend, run) + 1. run_component_analysis ──────────────▶ SourceApplication + 2. ProverBackend.prepare_system + run_harness_creation ─▶ SystemDescriptionHarnessed + _lift_harnessed ─▶ HarnessedApplication + get_prover_tool ─▶ verify_spec tool + ▶ ProverPrepared(main=located main contract, ...) + 3. ┌ create_task: ProverPrepared.prepare_formalization + │ gather( _autosetup → (config, [summaries]) , _invariants → [BaseInvariant] ) + │ batch_cvl_generation(component=None) ─▶ invariants.spec (cached under INV_CVL_KEY) + │ resources += invariants.spec + │ ▶ ProverRunner(config, resources, invariant, fetch) + └ _extract_all ─▶ [ _Batch(component, props) , ... ] # runs concurrently with the above + 4. for each batch (parallel, semaphore-bounded): + write_properties(ComponentSpec(slug), props) + cache_get(GeneratedCVL)? ── hit ─▶ reuse + └ miss ─▶ ProverRunner.formalize + batch_cvl_generation(component=feat) + author CVL ⇄ verify_spec ⇄ feedback judge (loop) + gates: PROVER_VALIDATION + FEEDBACK_VALIDATION + ─▶ GeneratedCVL | GaveUp + cache_put(result) + write_artifact ─▶ autospec_.spec (+ .conf) ⇒ Delivered(result, path) + ─▶ ComponentOutcome + ProverRunner.finalize(outcomes) ─▶ components_to_prover_runs.json + 5. build_report( per-component inputs + extra_report_inputs(), + fetch_verdicts=ProverRunner.fetch_verdicts ) ─▶ certora/ap_report/report.json +``` + +--- + +## 11. Key files + +| Concern | File | +|---|---| +| Driver + abstraction definitions | [composer/pipeline/core.py](../composer/pipeline/core.py) | +| Result protocols (`FormalResult`, `ArtifactIdentifier`) | [composer/spec/types.py](../composer/spec/types.py) | +| `ReportableResult`, `Verdict`, `VerdictFetcher`, `collect` | [composer/spec/source/report/collect.py](../composer/spec/source/report/collect.py) | +| CVL backend (the three phase objects) | [composer/spec/source/pipeline.py](../composer/spec/source/pipeline.py) | +| CVL authoring agent (`batch_cvl_generation`) | [composer/spec/source/author.py](../composer/spec/source/author.py) | +| CVL result type (`GeneratedCVL`) | [composer/spec/cvl_generation.py](../composer/spec/cvl_generation.py) | +| Artifact store base / CVL subclass | [composer/spec/artifacts.py](../composer/spec/artifacts.py) · [composer/spec/source/artifacts.py](../composer/spec/source/artifacts.py) | +| Foundry backend (contrast) | [composer/foundry/pipeline.py](../composer/foundry/pipeline.py) | +``` diff --git a/docs/rust-applications.md b/docs/rust-applications.md new file mode 100644 index 00000000..3d464e52 --- /dev/null +++ b/docs/rust-applications.md @@ -0,0 +1,324 @@ +# Design Doc — Rust Applications via PyO3 + +> How to stand up a whole new AutoProver *application* — phase enum, entry point, pipeline, +> frontend, artifact store, and `main()` — when the formalization backend is written in Rust. +> Where does the Python/Rust line fall across the five pieces, and how little bespoke Python +> can an extension author get away with writing? +> +> Companion to [application-abstraction.md](./application-abstraction.md) (the five pieces of +> an application) and [rust-formalization-backends.md](./rust-formalization-backends.md) (the +> backend seam in Rust, incl. the Tier-2 inversion-of-control authoring loop). This doc +> assumes both: it reuses the backend design wholesale and only addresses the *rest of the +> vertical* an application needs. + +--- + +## 1. The dividing line + +An application is [five pieces plus a `main()`](./application-abstraction.md): a phase enum, +an entry point/Executor, a pipeline+backend, a frontend, and an artifact store. The instinct +"write the application in Rust" is the wrong framing, because these pieces are not +symmetric — they fall cleanly into two camps: + +- **Imperative async Python shell** — the entry point (live Postgres pools, a RAG DB, a + background event drainer, `ContextVar`-scoped handler installation, the `composer.bind` + import-time DI/monkeypatch bootstrap), the frontend (a Textual TUI / stdout console owning + the event loop), and `main()`. None of this is data; it is service lifecycle and UI. It + **must stay Python**. +- **Pure logic + declarations** — the backend's decisions (covered in the backend doc), the + on-disk artifact *format*, and a handful of *declarations* (what phases exist, what CLI + flags exist, what the deliverable layout is). This is what Rust is good for. + +So the same principle that governs the backend seam governs the whole application: + +> **Rust declares and decides; Python wires and does.** Rust never owns an event loop, a DB +> connection, a Textual widget, or an `async with`. It contributes data (a descriptor) and +> pure step functions (the backend). Python owns every imperative, stateful, async edge. + +Under that principle, "a Rust application" is really **a Rust backend + a Rust +*descriptor*, both consumed by a reusable Python host.** The rest of this doc defines that +descriptor and the host. + +--- + +## 2. Where each piece lands + +| Piece | Camp | Rust contributes | Python owns | +|---|---|---|---| +| **Phase enum `P`** | declaration | list of phase names + which map to the 4 core slots | synthesizes the `enum.Enum` at runtime from that list | +| **Entry point / Executor** | Python shell | a declarative arg schema, a sync precondition-validation hook, a RAG-DB default | argparse, all service setup, `WorkflowContext`, `composer.bind`, the `runner` closure | +| **Pipeline wrapper** | Python shell | — | the thin `run__pipeline` that builds backend + `PipelineRun` + calls `run_pipeline` | +| **Backend** | Rust core | the `PipelineBackend`/`PreparedSystem`/`Formalizer` logic (see [backend doc](./rust-formalization-backends.md)) | the adapter shim + the Tier-2 effect loop | +| **Frontend** | Python shell | domain-event *content* (pushed across FFI), phase labels + section order | the `MultiJobApp`/console handler, `get_stream_writer()` emission, all rendering | +| **Artifact store** | mixed | the format-specific bundle formatter, the deliverable layout paths | the `ArtifactStore` shell + base writes (`properties.json`, `commentary.md`, …) | +| **`main()`** | Python shell | — | the ~20-line glue; a *generic* one parameterized by the descriptor | + +The striking result of grounding this in the code: **only the backend and the artifact +*formatter* are genuinely Rust; everything else is either a declaration Rust hands over as +data, or Python the extension author does not have to write at all** — if we build the host +in §3. + +--- + +## 3. The `AppDescriptor` and the generic host + +The leverage move is to make the entire non-backend surface **declarative**, so a single +reusable Python "app host" can synthesize the phase enum, the argparse, the entry point, the +frontend, and `main()` from one struct the Rust wheel exports. + +### 3.1 What Rust exports + +```rust +pub struct AppDescriptor { + name: String, // "myprover" + header_text: String, // TUI header + phases: Vec, // ordered; each: { key, label, core_slot: Option } + args: Vec, // extra CLI flags beyond the 3 positional inputs + rag_db_default: Option, // override the --rag-db default (cf. foundry cheatcodes DB) + event_kinds: Vec, // domain events the frontend should render (see §4.4) + artifact_layout: ArtifactLayout, // deliverable dir conventions (see §4.6) + // the backend itself: + backend: RustBackendHandle, // constructs the PipelineBackend (backend doc) +} + +enum CoreSlot { Analysis, Extraction, Formalization, Report } + +struct PhaseSpec { key: String, label: String, order: u32, core_slot: Option } +struct ArgSpec { flag: String, help: String, default: ArgDefault, required: bool } +``` + +The whole thing serializes to JSON at load time; the four `CoreSlot` values are exactly the +[`CorePhases`](../composer/pipeline/core.py) TypedDict keys the driver tags, so `phases` both +defines the UI vocabulary and supplies the `core_phases` mapping. + +### 3.2 What the Python host does with it + +```python +# composer/rustapp/host.py (NEW, write-once, reused by every Rust app) +def build_application(desc: dict) -> Application: + Phase = enum.Enum(f"{desc['name']}Phase", # ← synthesized enum, safe (§4.1) + {p["key"]: p["key"] for p in desc["phases"]}) + labels = {Phase[p["key"]]: p["label"] for p in desc["phases"]} + core = CorePhases({slot: Phase[p["key"]] # analysis/extraction/formalization/report + for p in desc["phases"] if (slot := p.get("core_slot"))}) + return Application( + phase=Phase, labels=labels, section_order=[...], + make_entry_point=lambda summary: _generic_entry_point(desc, Phase, core, summary), + make_frontend=lambda: _GenericRustApp(desc, Phase, labels), # MultiJobApp subclass + make_backend=lambda store, run: RustBackendAdapter(desc["backend"], core, store), + ) +``` + +An extension author then ships **only** the Rust wheel (backend + descriptor) plus a +one-line registration; the generic `main()`/entry point/frontend come from the host. That is +the end state: **zero bespoke Python per application.** + +> **Scope decision.** The generic host is net-new infrastructure. It is worth building only +> once we expect ≥2 Rust applications. For the first one, hand-writing the ~100 lines of +> Python glue (per [application-abstraction.md §10](./application-abstraction.md)) around the +> Rust backend is faster and lower-risk. §7 phases this. + +--- + +## 4. Piece by piece + +### 4.1 Phase enum — Rust declares, Python synthesizes + +Grounded in the code: `P` is used at runtime **only** for `.name` (logging/labels, +[multi_job.py](../composer/io/multi_job.py)) and as a hashable dict key (`phase_labels`, +`CorePhases`). There are no `isinstance` checks on phases, no enum-identity comparisons, and +the generic bound is `enum.Enum`, not the concrete class (and PEP 695 bounds aren't enforced +at runtime anyway). So `enum.Enum(f"{name}Phase", {...})` synthesized from the descriptor is +safe — the *only* rule is that `phase_labels` must be keyed by those same synthesized members +(member identity drives the lookup at [multi_job_app.py](../composer/ui/multi_job_app.py)), +which §3.2 satisfies by construction. + +### 4.2 Entry point / Executor — stays Python, Rust declares its inputs + +The entry point is irreducibly imperative async Python: it opens `standard_connections` (four +Postgres-backed pools — checkpointer, store, pgvector indexed store, memory backend), a RAG +DB, the async tool context, and a thread logger under a nested `async with`; builds the +`ServiceHost` env + `WorkflowContext`; reads the design doc via the async `FileUploader`; and +runs `import composer.bind` for its import-time DI registration and test-tape monkeypatching +([autoprove_common.py](../composer/spec/source/autoprove_common.py), +[composer/bind.py](../composer/bind.py)). **A Rust backend slots in only at the leaf** — the +`Formalizer` the pipeline eventually calls. + +What Rust *does* contribute here is purely declarative, consumed by the generic entry point: + +- **Extra CLI args** — the `args: Vec` become `parser.add_argument(...)` calls; the + three positional inputs (`project_root`, `main_contract`, `system_doc`) are always present. + This replaces the per-app `AutoProveArgs`/`FoundryArgs` Protocol. +- **A precondition-validation hook** — foundry validates `foundry.toml` exists *in the entry + point before opening services* ([application-abstraction.md §5](./application-abstraction.md)). + Rust exposes a **sync** `fn validate_preconditions(args_json) -> Result<(), String>` the + generic entry point calls right after arg parsing. Sync is fine — it's pure filesystem + checks, no async needed. +- **The RAG-DB default** — `rag_db_default` overrides `--rag-db`'s default, exactly as + `FoundryRAGDBOptions` does today, without a new flag. + +### 4.3 Pipeline wrapper + backend — see the backend doc + +The `run__pipeline` wrapper stays a thin Python function (build backend + `PipelineRun` ++ call `run_pipeline`); the generic host provides it. The backend itself — `prepare_system`, +`prepare_formalization`, `formalize` (incl. the Tier-2 LLM authoring loop), `fetch_verdicts`, +`finalize` — is the subject of [rust-formalization-backends.md](./rust-formalization-backends.md) +and is not re-derived here. The one link back: the descriptor's `core_phases` mapping (§3.1) +is the backend's `core_phases` slot; the phase enum synthesized in §4.1 is what stamps +`TaskInfo`. + +### 4.4 Frontend — Python renders; Rust supplies event *content* + +The frontend is a Textual `MultiJobApp` / stdout console — it owns the event loop and every +widget, and it must stay Python. A generic `MultiJobApp` subclass driven by the descriptor's +`phase_labels`/`section_order` handles the structure for free. + +The subtlety is **domain event streaming**. Applications stream backend-specific events to +their task panels (`forge_test_run`, `prover_output`, `cloud_polling`). Grounded in the code, +these are emitted by calling LangGraph's `get_stream_writer()(payload)` from *inside a running +graph node/tool*, or `emit_custom_event(payload)` off-graph — both require being inside the +async `with_handler` scope, and both take a plain `dict` with a discriminating `type` key +([foundry/runner.py](../composer/foundry/runner.py), +[composer/prover/runner.py](../composer/prover/runner.py), +[composer/io/context.py](../composer/io/context.py)). **Rust cannot call `get_stream_writer()`.** + +The clean resolution reuses the backend doc's Tier-2 machinery. The Python effect loop that +drives the Rust decider *is* inside the handler scope, so add one fire-and-forget command to +the vocabulary: + +```rust +Command::Emit { kind: String, payload: Json }, // Rust → Python: "stream this to my panel" +``` + +The Python driver, being in-scope, does `get_stream_writer()({"type": cmd.kind, **cmd.payload})` +and immediately resumes the Rust decider with `Observation::Ack`. This mirrors exactly how the +Certora prover surfaces stdout lines through the `ProverEventCallbacks` shim today — Rust +controls the *content*, Python does the emission. The descriptor's `event_kinds` tells the +generic frontend how to render each `type` (which panel/log, and whether it's plain text), so +even the `handle_event` body is generated, not hand-written. + +### 4.5 Artifact store — Python shell, Rust formatter + +Same split as the backend doc: the `ArtifactStore` subclass stays a Python shell (the base +writes the shared `properties.json` / `commentary.md` / property→units map / `token_usage.json`), +and Rust supplies the format-specific bundle formatter and the deliverable-layout paths (the +descriptor's `artifact_layout`). This is where an app declares that, e.g., its deliverables go +under `/*.t.sol` with metadata under `certora//` — the "share a project root +without colliding" convention ([application-abstraction.md §8](./application-abstraction.md)). + +### 4.6 `main()` — generic, parameterized by the descriptor + +The two `main()` shapes (TUI worker vs console-direct) are ~20 lines each and differ only in +who owns the event loop. The host provides both, parameterized by the descriptor; the +extension author picks TUI/console at the CLI entry point. The one invariant to preserve: +`import composer.bind as _` runs first (import-time DI/tape bootstrap). + +--- + +## 5. New infrastructure this requires + +Most of the application already composes for free; the genuinely net-new pieces are: + +1. **The `AppDescriptor` JSON schema** — the versioned contract between a Rust wheel and the + host (phases, args, event kinds, artifact layout). Shared with the backend doc's + marshalling/ABI schemas. +2. **The generic Python host** (`composer/rustapp/host.py`) — synthesizes enum + argparse + + entry point + frontend + `main()` from a descriptor. This is the reusable payoff; write it + once. +3. **A generic `MultiJobApp` subclass** whose `create_task_handler` / `handle_event` are + data-driven by `event_kinds`, rather than a hand-written subclass per app. +4. **The `Command::Emit` extension** to the Tier-2 effect vocabulary (§4.4), so a Rust + backend can stream domain events without touching `get_stream_writer()`. +5. **A registration entry point** — how a Rust wheel advertises its descriptor to the host + (e.g. a Python `[project.entry-points]` group, or an explicit `register(desc)` call). + +Everything else — the driver, the seam (`HandlerFactory`), the `MultiJobApp` base, caching, +the report — is inherited unchanged. + +--- + +## 6. Hypothetical: a Rust "MyProver" application end to end + +Putting §§3–4 together, standing up a Rust application called `myprover`: + +``` +myprover (Rust wheel) composer host (Python, reusable) +├─ AppDescriptor { build_application(desc): +│ name: "myprover", Phase = enum.Enum("MyproverPhase", …) ← §4.1 +│ phases: [Analyze→analysis, Extract→ labels/section_order from desc +│ extraction, Prove→formalization, core_phases from desc.core_slot +│ Report→report, +Setup(no slot)], _generic_entry_point(desc): ← §4.2 +│ args: [--solver-timeout, --parallel], argparse from desc.args +│ rag_db_default: "myprover_kb", validate_preconditions(args) ↺ Rust (sync) +│ event_kinds: [solver_line, proof_step], open Postgres pools / RAG / ctx / bind +│ artifact_layout: certora/myproof/…, yield runner(handler): +│ } RustBackendAdapter(desc.backend, …) ← backend doc +├─ validate_preconditions() (sync) _GenericRustApp(desc): ← §4.4 +├─ backend: PipelineBackend phase panels from labels +│ ├─ prepare_system / prepare_formalization handle_event dispatches by event_kind +│ └─ formalize: Tier-2 decider ─────┐ generic main() (TUI or console) ← §4.6 +│ emits Command::Emit{solver_line}│ +└─ artifact bundle formatter └──────▶ Python effect loop calls + get_stream_writer()({type:"solver_line",…}) +``` + +The author writes Rust (a backend + a descriptor) and *nothing else*: no argparse, no Textual +code, no entry-point service wiring, no `main()`. The `Setup` phase with no core slot shows an +app contributing its own UI-only phase, exactly as autoprove's harness/autosetup phases do. + +--- + +## 7. Work breakdown + +### Phase A — one Rust application, hand-written Python glue +Prove the vertical before generalizing. Reuse the backend milestones from +[rust-formalization-backends.md §6](./rust-formalization-backends.md), then hand-write the +~100 lines of app glue per [application-abstraction.md §10](./application-abstraction.md): +a concrete phase enum, a concrete `_entry_point`, a concrete `MultiJobApp` subclass, a +`run__pipeline`, and both `main()`s. Wire domain events through a `ProverCallbacks`-style +shim. This ships a working Rust application and surfaces the real friction. + +### Phase B — extract the `AppDescriptor` + generic host +Once Phase A works, factor the hand-written glue into the descriptor schema (§3.1) and the +generic host (§5.1–3). Migrate the Phase-A app onto it; a second Rust app should then need +zero bespoke Python. + +### Phase C — event-emission ergonomics +Add `Command::Emit` (§4.4) and the data-driven `handle_event`, so streaming domain events is +declarative rather than a per-app Python callback. + +--- + +## 8. Open questions + +1. **Build the host, or hand-write glue per app?** The generic host pays off only at ≥2 Rust + apps. Until then Phase A's hand-written glue is cheaper. Decide based on the roadmap. +2. **Interactive applications (`H ≠ None`).** Both current apps are non-interactive + (`H = None`; handlers raise on HITL). An interactive Rust app would need the backend to + *await* a human response mid-loop — which, unlike everything else here, genuinely needs the + Tier-3 async bridge (or an `Emit`-style command whose observation is the human's answer, + routed through the Python HITL handler). The `Emit`-with-response route keeps us bridge-free + and is preferred. +3. **Descriptor registration.** Python entry-points group vs explicit `register()` — how does + the host discover a Rust wheel's descriptor, and how are version mismatches surfaced at + load time? +4. **Event rendering richness.** `event_kinds` as "write this text to this log" covers the + current apps. If an app wants a custom widget (progress bar, table), does it fall back to a + hand-written `MultiJobApp` subclass, or do we grow the descriptor? Start with text; grow + only on demand. + +--- + +## 9. Key files + +| Concern | File | +|---|---| +| The five pieces of an application | [application-abstraction.md](./application-abstraction.md) | +| The Rust backend seam (incl. Tier-2 loop) | [rust-formalization-backends.md](./rust-formalization-backends.md) | +| Phase-enum runtime use (`.name`, dict key) | [composer/io/multi_job.py](../composer/io/multi_job.py) · [composer/ui/multi_job_app.py](../composer/ui/multi_job_app.py) | +| Entry point / service wiring (must stay Python) | [composer/spec/source/autoprove_common.py](../composer/spec/source/autoprove_common.py) · [composer/foundry/entry.py](../composer/foundry/entry.py) | +| DI / tape bootstrap | [composer/bind.py](../composer/bind.py) | +| Domain-event emission (`get_stream_writer` / `emit_custom_event`) | [composer/io/context.py](../composer/io/context.py) · [composer/prover/runner.py](../composer/prover/runner.py) · [composer/foundry/runner.py](../composer/foundry/runner.py) | +| Frontend base + seam | [composer/ui/multi_job_app.py](../composer/ui/multi_job_app.py) · [composer/io/multi_job.py](../composer/io/multi_job.py) | +| `CorePhases` / driver | [composer/pipeline/core.py](../composer/pipeline/core.py) | +| Generic host (NEW) | `composer/rustapp/host.py` | diff --git a/docs/rust-backend-api.md b/docs/rust-backend-api.md new file mode 100644 index 00000000..2fa8c7cf --- /dev/null +++ b/docs/rust-backend-api.md @@ -0,0 +1,321 @@ +# A service-shaped backend API (no IoC loop, no async runtime in the wheel) + +Design note. A concrete alternative to the IoC decider loop +([rust-ioc-loop.md](./rust-ioc-loop.md)): recast the Rust backend as a **passive service** +that the **Python core pipeline** drives through the whole author→compile→judge→validate +loop. The backend answers pure questions (prompts, RAG) and owns the two "run the local +toolchain" steps (`compile`, `validate`) — which invoke `cargo`/`crucible` **directly**, each +through the `run-confined` sandbox launcher. No `RustSession`/`resume`, no +`Command`/`Observation` protocol, and no async runtime in the wheel. + +## 1. The reframing + +The backend's real job in formalization is small: + +- **know what to say to the model** — the authoring prompt and the optional judging prompt; +- **compile a candidate spec** — build it via the toolchain and report failures verbatim so + the model can fix them; +- **validate the spec** — run the checker (Crucible) and turn its output into verdicts. + +Everything else — running the LLM agent, retrying, streaming events, caching, and building the +RAG search tools — is generic orchestration the pipeline already does for the CVL/Foundry +backends. So the backend stops being a *driver* (the `resume` state machine) and becomes a +*service* the driver consults. (RAG is unchanged: the author's knowledge-base search tools are +still built the way they are today — e.g. Crucible's external `crucible_kb` corpus, imported +from a committed manifest via `composer.scripts.rag_import` — not supplied through this API.) + +The two slow steps are different from the pure ones: `compile` and `validate` actually run +untrusted native tools, so they **execute inside the sandbox**. The backend does that itself +by spawning [`run-confined`](../rust/run-confined/src/main.rs) — the standalone launcher that +applies Landlock + seccomp + rlimits + an env allowlist to itself and then `execve`s the tool +(fail-closed). The backend authors the command line; **Python authors the confinement policy** +(which paths are writable/readable, the private `CARGO_HOME`, caps) and hands it in, because +that is environment/recipe knowledge that lives in `composer/sandbox` +([command-sandbox.md](./command-sandbox.md)). + +## 2. The backend API + +Two tiers: + +- **Pure** callouts (`json → json`, no I/O): metadata, `units`, prompts. Same shape as today's + `descriptor`/`fetch_verdicts`. +- **Blocking** callouts: `compile`/`validate` spawn `run-confined` and wait. They are ordinary + synchronous functions that **release the GIL** while the child runs, so Python calls them + with `await asyncio.to_thread(...)` — non-blocking to the event loop, **no tokio, no + `pyo3-async`** (see §5). They are kept **clearly separate** (compile the whole spec once; + validate one unit at a time) — no fusing the dry-run into the first fuzz. + +```rust +pub trait Backend: Send + Sync + 'static { + // ---- metadata (unchanged, pure) -------------------------------------- + fn descriptor(&self) -> AppDescriptor; + fn validate_preconditions(&self, args: &Value) -> Result<(), String> { Ok(()) } + + // ---- authoring (pure) ------------------------------------------------ + /// The units this input formalizes — one per property — each a property title and its + /// backend-specific unit name (Crucible: `c_`, the test-fn / feature selector). + /// Pure and *pre-authoring*: the author prompt requires exactly these fn names, the + /// host validates each unit, and it is the report's property→unit map. `kind="setup"` + /// (the fixture) has no units. + fn units(&self, input: &AuthorInput) -> Vec; // {property, unit} + /// Instruction (+ optional system prompt) to author `input.kind`'s spec — covering all + /// its units. `failure = Some(..)` on a re-author after a compile failure or a judge + /// rejection, so one function covers the initial draft and every revision. + fn author_prompt(&self, input: &AuthorInput, failure: Option<&Failure>) -> Prompt; + /// Optional LLM review of a compiled spec, before validation. `None` (the default — + /// what every backend returns today) skips judging; `Some(prompt)` runs a judge turn + /// whose structured verdict (accept / reject + feedback) the host feeds back as a + /// `Failure` on reject. Present in the API so a backend can opt in without a reshape. + fn judge_prompt(&self, input: &AuthorInput, spec: &str) -> Option { None } + + // ---- gating: run the toolchain directly, inside run-confined --------- + /// Compile/typecheck the whole spec ONCE (every unit shares one build): materialize it + /// into `workdir`, build under `sandbox`, and report success or the errors to hand back + /// to the model. BLOCKING — releases the GIL while `run-confined` runs. + fn compile(&self, input: &AuthorInput, spec: &str, workdir: &Path, sandbox: &Sandbox) + -> CompileResult; // Ok | Failed { errors } + + /// Validate ONE unit against the (already-compiled) spec and bake its verdict — run the + /// checker for that unit only. Per-unit so the host owns enumeration and scheduling + /// (and can fan out); the backend never discovers units here. BLOCKING (GIL released). + fn validate(&self, input: &AuthorInput, spec: &str, unit: &str, + workdir: &Path, sandbox: &Sandbox) -> Verdict; // GOOD/BAD/… + + // ---- assembly (pure) ------------------------------------------------- + fn finalize(&self, outcomes: &Value) -> BTreeMap { BTreeMap::new() } +} +``` + +Supporting types: + +```rust +struct AuthorInput { kind: String, program: String, component: Value, props: Vec, context: Value } +struct Prompt { system: Option, instruction: String } +struct Failure { errors: String } // compile stderr or judge feedback, fed back to the model +enum CompileResult { Ok, Failed { errors: String } } +struct Unit { property: String, unit: String } // report row + fuzz target (e.g. c_) + +/// The confinement wrapper, authored by Python (never the LLM). Python owns the confinement +/// *intent* and lowers it to `argv_prefix` — an opaque argv the backend prepends to its command, +/// naming no sandbox mechanism. Empty prefix = the trusted/`none` path (exec directly). See +/// composer/sandbox/config.py::SandboxConfig.backend_spec. +struct Sandbox { + argv_prefix: Vec, // confinement wrapper up to `--`; empty → run directly (trusted) + timeout_s: u64, +} +``` + +`kind` lets one backend author more than one thing with the same primitives — Crucible's +program-wide **fixture** (`kind="setup"`) and each unit's **tests** (`kind="component"`) are +both "author a spec, then `compile` it"; only the component kind has a `validate` step, and +the fixture's compiled output feeds the components' `context`. + +### How `compile`/`validate` reach the sandbox — a shared SDK helper + +Both `compile` and `validate` run their tool the same way, so `autoprover-sdk` provides **one +helper** every backend calls — the launcher contract lives in exactly one place: + +```rust +// in autoprover-sdk +pub fn run_confined( + sandbox: &Sandbox, program: &str, args: &[String], + files: &BTreeMap, workdir: &Path, timeout: Duration, +) -> io::Result; // { exit_code, stdout, stderr } +``` + +It (1) materializes `files` into `workdir`, path-confining each write (reject absolute / `..`, +as `composer.sandbox.command._confined_target` does); (2) builds the argv +`[*sandbox.argv_prefix, program, *args]` — which collapses to `[program, *args]` when +`argv_prefix` is empty (the trusted/`none` path); (3) spawns it (std `Command`), +waits with `timeout`, and captures the streams. The prefix is authored wholesale by the Python +`launcher` provider (`argv_prefix`), so the launch is exactly — + +```text +run-confined --rw --rw <.sandbox_cargo> --ro --ro + --allow-env PATH --allow-env CARGO_HOME=… [--allow-network] + --rlimit-as … -- crucible run --release --mode explore --timeout N +``` + +— where everything up to and including `--` is Python's `argv_prefix` (opaque to the SDK, which +just prepends it), and the backend appends only the command. So `compile` builds the +`{program, args, files}` for a dry-run and calls `run_confined`; `validate` does the same for +one unit's fuzz run; neither re-implements sandbox plumbing. The **command after `--` is +backend-authored**; the **prefix before it is Python-authored** (`Sandbox.argv_prefix`); Landlock +grants only the `--rw` workdir, so even a bad file path can't escape. + +## 3. The Python side + +The formalizer is one generic loop in the core pipeline: + +```python +async def formalize(mod, input, env, sandbox, *, workdir, max_attempts, emit) -> Formalized | GaveUp: + units = mod.units(input) # ← backend (pure): the fuzz targets + spec, failure = None, None + for _ in range(max_attempts): # author → compile → judge (retry) + prompt = mod.author_prompt(input, failure) # ← backend (pure) + spec = await run_llm_agent(env, prompt, tools=env.rag_tools + env.source_tools) # ← Python: LLM + r = await asyncio.to_thread(mod.compile, input, spec, workdir, sandbox) # ← backend runs run-confined + if r.failed: + failure = Failure(r.errors); emit("build", r.errors); continue + if (jp := mod.judge_prompt(input, spec)) is not None: # ← backend (pure); default None → skip + review = await run_llm_agent(env, jp, structured=JUDGE) # ← Python: LLM + if not review.accept: + failure = Failure(review.feedback); continue + break + else: + return GaveUp(f"did not pass compile/judge in {max_attempts} attempts") + + # validate each unit — host owns enumeration + scheduling (serial today; see §5). + async def check(u): + v = await asyncio.to_thread(mod.validate, input, spec, u.unit, workdir, sandbox) + emit("verdict", {"unit": u.unit, **v}) # live per-unit notice + return u, v + results = [await check(u) for u in units] # or asyncio.gather to fan out + return Formalized(artifact_text=spec, + property_units=[(u.property, [u.unit]) for u, _ in results], + verdicts={u.unit: v for u, v in results}) +``` + +- The **author system prompt is already backend-definable** (`_llm_agent._split_prompt`), so + `Prompt.system` drops in. +- **RAG is unchanged**: the host builds the author's knowledge-base search tools as it does + today (Crucible's external `crucible_kb`, imported from the committed + `rust/crucible-app/crucible_kb.rag.json` via `composer.scripts.rag_import`) and passes them in + `env.rag_tools`. Moving the corpus into the wheel is a possible later step, not part of this change. +- **Sandbox policy** is built once by Python (`SandboxConfig.build_policy(workdir)`, the + existing recipe) and passed straight through as `Sandbox` — Python keeps ownership of the + *intent*; the backend only assembles it into a `run-confined` argv. +- **Events + caching are Python's** (it knows the phase and each result), so the `Emit` + command and the `Emitter` shim disappear and the pipeline's result cache subsumes the loop's + scratch cache. +- `fetch_verdicts` disappears for self-contained backends — verdicts come from `validate`. + +`CrucibleFormalizer.formalize` becomes: prepare the crate → run the `setup` artifact (author + +`compile`, no validate) to get the fixture → run the `component` artifact through the loop +above. Two readable `await`s over backend callouts; no state machine. + +## 4. How Crucible implements it + +- `units(input)` → one `Unit{ property: title, unit: "c_" }` per invariant (the current + `_unique_slugs` mapping, moved into the backend). `kind="setup"` ⇒ `[]`. +- `author_prompt(input, failure)` → `kind="setup"` ⇒ the fixture prompt; `kind="component"` ⇒ + the all-invariants prompt (listing the `units`' fn names); `failure` ⇒ append revise context, + dispatched on `failure.kind`: a `Compile` failure appends the prior draft + compiler errors + (`revise_suffix`), a `Judge` rejection appends the prior draft + review feedback framed as + *not* a build error (`judge_revise_suffix`). +- `judge_prompt` → overridden for `kind="component"` (skipped for the fixture): a reviewer turn + modeled on Foundry's feedback judge, retargeted to fuzzing — the load-bearing question is + reachability (can the fuzzer drive a state where the invariant could fail?). Emits the + `{accept, feedback}` JSON the host's `_parse_judge` reads. Whenever a wheel supplies a judge for an + input, the host runs it as a `request_review` **tool inside the author session** — the author + self-revises and can only finalize an accepted draft (see `docs/crucible-judge-in-loop.md`) — so + there is no separate post-authoring judge turn. A wheel with no judge (`judge_prompt → None`) gets + the plain single-shot author. +- `compile(input, spec, workdir, sandbox)` → `run_confined(sandbox, "crucible", ["run", program, + probe, "--release", "--dry-run"], files={"fuzz//src/main.rs": fixture+spec}, workdir)`; + `Failed{errors: tail}` if `is_build_error(out)` or nonzero exit, else `Ok`. +- `validate(input, spec, target, workdir, sandbox)` → `run_confined(sandbox, "crucible", ["run", + program, target, "--release", "--mode", "explore", "--timeout", n], files={main.rs: fixture+spec}, + workdir)`. `target` is the fuzz feature (Crucible's single `c_invariants`, shared by every + invariant — see `docs/crucible-unit-granularity.md`). The run is classified once (`[FUZZ_FINDING]` + → BAD, exit 0 → GOOD, build markers → `BuildFailed`) and **attributed by the backend** to a + `Verdict` per report unit the target covers (`ValidateOutcome::Verdicts`): a counterexample is + pinned to the property whose title the finding names (assertions are tagged `[]`), the rest + held GOOD. The host records these verbatim — it owns no verdict logic and never parses a finding. +- `finalize` → unchanged. + +Every piece is the body of a current `resume` arm turned into a pure/blocking function — +directly unit-testable in Rust (feed a spec + a fake `crucible` on `PATH`, assert the command +or the verdict). + +## 5. Why there is still no async runtime in the wheel + +`compile`/`validate` block on a child process. To keep them off the event loop **without** +tokio or a Python-await bridge: + +- the `#[pyfunction]` wraps its subprocess work in `Python::allow_threads(|| …)`, releasing the + GIL for the (minutes-long) build/fuzz; +- Python calls it with `await asyncio.to_thread(mod.compile, …)`. + +So the wheel stays **synchronous** — no tokio, no `future_into_py`/`into_future`, no +GIL-across-await marshaling, no contextvar-through-a-bridge risk (the earlier +async-into-Rust concern in [rust-ioc-loop.md]). The backend just spawns and waits; Python +just moves the wait to a thread. This is the whole reason to spawn `run-confined` *directly* +rather than await a Python runner: the sandbox is already a standalone binary, so the backend +needs nothing from Python at run time except the policy data. + +Because `validate` is **per-unit**, the host *can* fan the units out (`asyncio.gather` of +`to_thread` calls) for free at the Python layer — but actually running Crucible fuzz builds +concurrently against the **shared crate** hits the binary-name collision from +[crucible-unit-granularity.md §7](./crucible-unit-granularity.md) (every feature builds the +same `invariant_test`), so real parallelism still needs the `--binary-in` build/fuzz split. +The per-unit signature is the right shape regardless (the host owns enumeration/scheduling); +it runs **serial today** and becomes parallel when §7 is done — no API change either way. + +### Scope: self-contained vs run-service backends + +This shape fits a backend whose checker is a **local tool** (Crucible: `cargo`/`crucible`; +a future soroban twin). A backend whose "validate" is a **remote/Python service** (the Certora +prover) can't spawn it under `run-confined`; that path would keep a Python-side effect for the +service call (or the real prover stays the Python `ProverBackend` it already is, not a Rust +wheel). The echoprover demo's prover step is the one place this matters — it either keeps a +thin Python `run_prover` hook or drops the prover step; Crucible, the actual target, is fully +self-contained. + +## 6. What changes, concretely + +**Delete** (SDK + host): `Command`/`Observation`, `FormalizeSession`/`resume`, the +`RustSession` pyclass, `drive_session`, the `Emitter` shim, the loop's scratch cache, and the +`RealEffects` `run_command` routing (the backend now spawns `run-confined` itself). +**Keep**: `descriptor`/`validate_preconditions`/`finalize`, the sandbox *policy* layer +(`SandboxPolicy`/`SandboxConfig.build_policy`), `run-confined` itself, the existing RAG +mechanism (`crucible_kb`, imported via `composer.scripts.rag_import`, surfaced as `env.rag_tools`), `_llm_agent` +(now called directly by the pipeline), and `run_prover`/`run_feedback` only for the run-service +exception. +**Add**: the `Backend` callouts above (pure `descriptor`/`validate_preconditions`/`units`/ +`author_prompt`/`judge_prompt`/`finalize` + the two GIL-releasing blocking `compile`/`validate`), the shared +`autoprover_sdk::run_confined` helper (which just prepends Python's opaque `argv_prefix` — the +`SandboxPolicy` → `run-confined` argv assembly stays in the Python `launcher` provider), and one +generic `formalize` in the core pipeline. + +Net: the FFI goes from "a coroutine hand-compiled into a state machine + an 8-variant effect +protocol" to "pure prompt/unit callouts + `compile`/`validate` that run the toolchain via one +shared launcher helper." The control flow lives in one Python function that reads like the +procedure it is. + +## 7. Security invariant (unchanged, and clearer) + +The rule ([command-sandbox.md](./command-sandbox.md) §2/§7): the **command line** (`program` + +`args`) is authored by trusted compiled code; only file **contents** may derive from the LLM. +Here `compile`/`validate` construct `program`/`args` in Rust and place them after `--`; the +`Sandbox` policy (the `run-confined` flags before `--`) is authored by Python. The LLM's spec +is written into the `--rw` workdir and confined by Landlock. Two audit points, both trivial: +the pure command-building in the backend, and `SandboxConfig.build_policy` / +`LauncherProvider.argv_prefix` in Python. A test asserts the launched argv is +`[*argv_prefix, "crucible"/"cargo", …]` (the prefix being `[run-confined, …policy…, "--"]`) and +never contains LLM text. + +## 8. Decisions and deferrals + +Resolved: + +- **Shared launcher helper — yes.** `autoprover_sdk::run_confined` prepends the Python-authored + `Sandbox.argv_prefix` to the backend's command; every backend calls it (§2). +- **`compile` and `validate` stay separate.** No fusing the dry-run into the first fuzz — one + whole-spec compile, then per-unit validation (§2). +- **`validate` is per-unit.** The host enumerates units (`units(input)`) and calls `validate` + once per unit, so the backend never discovers units and the host owns scheduling; serial + today, parallel once the §7 build-collision split lands (§5). +- **Judge — API present, default no-op.** `judge_prompt` stays in the trait but defaults to + `None` (skip), so the loop has a judge step wired in and a backend can opt in later without + a reshape. No backend overrides it today. +- **Wheel-owned RAG — deferred.** RAG stays the external `crucible_kb` mechanism; a + `knowledge_base()` callout the host indexes is an additive later step, out of scope here. + +Still open: + +- Timeout mechanics in the SDK helper (std `Command` has no built-in timeout — a wait-thread + vs a small `wait-timeout`-style dependency). +- Where `units`' slug-uniqueness lives if two backends want to share it (SDK helper vs + per-backend), and whether `author_prompt` should take the `units` list explicitly rather + than re-deriving it internally. diff --git a/docs/rust-formalization-backends.md b/docs/rust-formalization-backends.md new file mode 100644 index 00000000..dc765f1f --- /dev/null +++ b/docs/rust-formalization-backends.md @@ -0,0 +1,552 @@ +# Design Doc — Rust Formalization Backends via PyO3 + +> How to implement an AutoProver formalization backend in Rust and plug it into the +> generic Python pipeline through PyO3, what the boundary looks like, the additional +> work required to let Rust call *back* into the async Python services, and a +> hypothetical sketch of the CVL prover backend rewritten in Rust. +> +> Companion to [formalization-abstraction.md](./formalization-abstraction.md), which +> defines the backend seam this leans on, and +> [application-abstraction.md](./application-abstraction.md), which covers how a backend +> is wired into a runnable application. Read the formalization doc first — this document +> assumes its vocabulary (`FormT`, `Formalizer`, `PreparedSystem`, the phase chain). For +> the *rest* of the vertical around a Rust backend — phase enum, entry point, frontend, +> `main()` — see [rust-applications.md](./rust-applications.md). + +--- + +## 1. Problem & motivation + +The formalization seam ([formalization-abstraction.md §3](./formalization-abstraction.md)) +is deliberately narrow: a backend is any object that structurally satisfies the +`PipelineBackend` protocol and hands the generic driver three immutable phase objects +(`PipelineBackend → PreparedSystem → Formalizer`). The driver in +[composer/pipeline/core.py](../composer/pipeline/core.py) never imports a concrete +backend — it moves opaque `FormT` values around and never reads a field. + +We want to author backends (or performance-critical parts of them) in **Rust**: a native +verification engine, a fast artifact transformer, a solver driver, or a +whole-backend reimplementation that only borrows the shared analysis/extraction/report +machinery. **PyO3** is the bridge — it lets a Rust crate expose functions and classes +that Python can call as if they were native. + +The seam being structural (a `Protocol`, not a base class you must subclass) is what makes +this tractable: nothing in the driver needs to know a backend is "really" Rust. The +question is entirely about the **boundary** — what crosses it, in which direction, and +synchronously or not. + +### Design goals + +1. **Confine the PyO3 surface.** The FFI boundary should be as small, synchronous, and + serde-friendly as the backend allows. Every awaitable, pydantic model, or deep object + graph that crosses the boundary is a cost. +2. **Reuse the driver unchanged.** Caching, the artifact store, the report, and the + concurrency structure are driver-owned; a Rust backend inherits them for free + ([formalization-abstraction.md §7](./formalization-abstraction.md)). +3. **Keep the main tree pure-Python.** The project builds with `setuptools` today + ([pyproject.toml](../pyproject.toml)); adding Rust should not force a build-system + rewrite of `ai-composer` itself. + +--- + +## 2. The boundary, and what crosses it + +The whole interface a backend must implement is five async methods plus a handful of +properties and one sync mapper ([formalization-abstraction.md §3](./formalization-abstraction.md)): + +| Member | Direction | Kind | +|---|---|---| +| `prepare_system` | driver → backend | `async` | +| `PreparedSystem.prepare_formalization` | driver → backend | `async` | +| `Formalizer.formalize` | driver → backend | `async` | +| `Formalizer.fetch_verdicts` | driver → backend | `async` | +| `Formalizer.finalize` | driver → backend | `async` (optional hook) | +| `to_artifact_id`, `extra_report_inputs`, the four properties | driver → backend | sync | + +Four properties of this boundary drive the entire design. + +### 2.1 It is thoroughly `async` + +Every real method is a coroutine, driven by `asyncio.create_task` / +`asyncio.gather(..., return_exceptions=True)` in the driver +([core.py](../composer/pipeline/core.py)). PyO3 does not make Rust `async fn` visible to +Python for free — see [§4](#4-the-async-problem-three-tiers). + +### 2.2 The result type must stay cacheable + +The driver keys the cache on `formalizer.formalized_type` and calls +`cache_put(result)` / `cache_get(type)` ([core.py](../composer/pipeline/core.py)). Both +existing results — `GeneratedCVL` ([cvl_generation.py](../composer/spec/cvl_generation.py)) +and `GeneratedFoundryTest` ([foundry/author.py](../composer/foundry/author.py)) — are +pydantic v2 `BaseModel`s that serialize cleanly. A raw `#[pyclass]` result would have to +satisfy *both* structural protocols (`FormalResult` + `ReportableResult`) **and** +round-trip through the cache's (de)serialization. + +> **Decision:** keep `FormT` a Python pydantic model. Rust does the work and returns +> plain data; the pydantic result is constructed on the Python side (or PyO3 instantiates +> the pydantic class). Caching, the artifact store, and the report all keep working +> unchanged. + +### 2.3 The inputs are a deep object graph + +`formalize` receives a `ContractComponentInstance` (a dataclass wrapping a pydantic +`SourceApplication` / `HarnessedApplication` graph — [system_model.py](../composer/spec/system_model.py)), +a `list[PropertyFormulation]`, a `WorkflowContext`, and a `PipelineRun`. Reading these +from Rust via GIL-bound attribute access is verbose and brittle. + +> **Decision:** marshal at the boundary. The thin Python adapter serializes the *slice* +> the Rust backend needs (`model_dump()` / JSON) and passes that in; Rust deserializes +> into its own `serde` structs and never touches Python objects directly. +> `PropertyFormulation` and the result models are trivially JSON-able. + +### 2.4 It is a `Protocol`, not a base class + +Neither `ProverBackend` nor `FoundryBackend` inherits from `PipelineBackend` — they match +by shape. So the "backend" the driver sees can be a **thin Python adapter** that +implements the protocol and delegates to the Rust extension. That adapter is where all the +async-wrapping, marshalling, and pydantic-construction live. + +--- + +## 3. Recommended architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ composer/pipeline/core.py (generic driver — UNCHANGED) │ +└───────────────┬─────────────────────────────────────────────────┘ + │ holds an opaque PipelineBackend[...] (structural) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ composer/rustbackend/adapter.py (thin PYTHON adapter) │ +│ • implements the async protocol methods │ +│ • async def formalize(...): │ +│ payload = _marshal(feat, props) # pydantic → JSON │ +│ raw = await asyncio.to_thread(_rs.formalize, payload) │ +│ return GeneratedRustResult.model_validate(raw) # → pydantic│ +│ • keeps FormT a pydantic BaseModel │ +└───────────────┬─────────────────────────────────────────────────┘ + │ sync, serde-friendly FFI calls + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ _rustbackend (PyO3 / maturin extension wheel) │ +│ #[pyfunction] fn formalize(payload: &str) -> PyResult<String> │ +│ • serde_json::from_str → own structs │ +│ • py.allow_threads(|| heavy_rust_work()) │ +│ • returns serde_json::to_string(&result) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +This confines the entire PyO3 surface to **synchronous, `&str`-in / `String`-out +functions**, sidestepping three problems at once: no Tokio↔asyncio bridge, no +pydantic-in-Rust, no cache-serialization of a `#[pyclass]`. + +### 3.1 Packaging + +Two options; the first is strongly preferred. + +- **(a) Separate wheel — recommended.** The Rust crate is its own maturin project + producing a `_rustbackend` extension wheel. `ai-composer` gains one dependency; its + `[build-system]` stays on setuptools. Least invasive, independently versioned/CI'd. +- **(b) Mixed maturin build for `ai-composer` itself.** Rewrites `[build-system]` and + `[tool.setuptools.*]`. Only worth it if the Rust and Python are co-developed in + lockstep. + +`requires-python = ">=3.12"` is a hard floor (the seam uses PEP 695 generics), so build +abi3 wheels for `cp312+`. The project is managed with `uv`; add the crate as a `uv` +source (path dep during development, published wheel in CI). + +### 3.2 Two small wiring changes (not PyO3-specific) + +These are the same steps any new backend takes ([application-abstraction.md](./application-abstraction.md)): + +1. Add a wrapper `run_<rust>_pipeline` that constructs the adapter backend and calls the + generic `run_pipeline` — mirror [foundry/pipeline.py](../composer/foundry/pipeline.py), + the simplest reference backend, plus a `[project.scripts]` CLI entry. +2. Widen the `ReportBackend` literal — currently a closed `Literal["prover","foundry"]` + at [report/schema.py](../composer/spec/source/report/collect.py) — to include the new + backend tag. + +### 3.3 GIL and errors + +- Release the GIL (`py.allow_threads(|| ...)`) around heavy Rust work, so the + `asyncio.to_thread` offload in the adapter yields real concurrency across the + semaphore-bounded per-component fan-out. +- Map Rust `Err`/panics to Python exceptions (`PyResult`, `catch_unwind` at the FFI edge). +- For the *declined* outcome, return the existing `GaveUp(BaseModel)` (`{reason: str}`) + from the adapter — the driver treats it as a normal, reportable result, **not** a crash + ([formalization-abstraction.md §8](./formalization-abstraction.md)). Reserve raised + exceptions for genuine failures the driver should capture via `return_exceptions=True`. + +--- + +## 4. The async problem, three tiers + +Extensions **will** need LLM authoring loops — a backend that authors an artifact, runs a +verifier, reads the feedback, and revises, turn after turn, is the whole point. That work +is inherently a dance with async Python services (the LLM, the prover tool, the feedback +judge, the cache). The naive way to give Rust that capability is to let Rust `await` Python +coroutines directly — the full async FFI bridge. **We can avoid that**, because of one fact +about how this codebase is already built. + +### 4.0 The enabling fact: the loop already separates *decide* from *do* + +The authoring loop is a langgraph `StateGraph` (`initial → tools ⇄ tool_result → __end__`), +but underneath, every node is a **pure generator** ([graphcore/graph.py](../graphcore/graphcore/graph.py)): + +```python +type PureFunctionGenerator[ResT] = Generator[list[AnyMessage], BaseMessage, ResT] +``` + +A node *yields* the messages it wants sent to the LLM and *receives* the reply via +`.send()`. A tiny adapter, `_stitch_async_impl`, performs the one awaited effect +(`res = await llm_impl(d)`) between the yield and the send. **The "decide next action" logic +is already pure and synchronous; only the effect in the middle is async.** The routing +predicates that end the loop — `should_end`, `ai_message_router`, `check_completion` — +are ordinary side-effect-free functions of the state dict +([cvl_generation.py](../composer/spec/cvl_generation.py)). + +That split is exactly what we relocate across the FFI boundary. Rust owns the pure decider; +Python keeps owning the async effects. No bridge required. + +### 4.1 Tier 1 — self-contained Rust (`asyncio.to_thread`) + +If the Rust work does its own thing (spawns a solver, shells out, computes an artifact) and +needs no Python callbacks, the adapter wraps a **synchronous** Rust call in a thread: + +```python +async def formalize(self, label, feat, props, ctx, run) -> FormT | GaveUp: + payload = _marshal(feat, props, self._config) + raw = await asyncio.to_thread(_rs.formalize, payload) # sync Rust, off the loop + obj = json.loads(raw) + if obj["kind"] == "gave_up": + return GaveUp(reason=obj["reason"]) + return GeneratedRustResult.model_validate(obj["result"]) +``` + +This is exactly the pattern the CVL backend already uses for its off-thread prover query +([formalization-abstraction.md §4.5](./formalization-abstraction.md)). No new +infrastructure. Good for pure computation, useless for an LLM loop. + +### 4.2 Tier 2 — inversion of control: Rust decides, Python does the I/O ⭐ + +**This is the recommended way to give an extension an LLM authoring loop.** It requires +**no async bridge at all** — the FFI stays 100% synchronous. + +Python owns the async event loop and *every* effect (LLM call, prover run, feedback judge, +cache). Rust is a **pure, synchronous state machine**. Python calls one sync FFI function, +`resume(handle, observation) -> Command`; Rust decides the next action and returns a +`Command`; Python performs that async effect and calls `resume` again with the outcome. +This is the classic **sans-I/O** pattern, and it mirrors the `PureFunctionGenerator` split +above one-to-one — the generator's `yield` becomes a returned `Command`, its `.send()` +becomes the next `resume` argument. + +**The command vocabulary** (a closed enum, JSON across the wire) is read straight off the +real loop's effects: + +```rust +enum Command { // Rust → Python: "please perform this effect" + CallLlm { messages: Json }, // an LLM turn (initial / tool_result node) + RunProver { spec: String, config: Json, rules: Option<Vec<String>> }, + RunFeedback { spec: String, skipped: Json, rebuttals: Json }, // nested judge agent + CacheGet { key: String }, + CachePut { key: String, value: Json }, + Summarize { messages: Json }, // history-compaction LLM turn + Publish { result: Json }, // ⇒ FormT ; loop ends + GiveUp { reason: String }, // ⇒ GaveUp ; loop ends +} + +enum Observation { // Python → Rust: "here is the result" + LlmReply(Json), ProverResult(Json), FeedbackResult(Json), + Cached(Option<Json>), Ack, Start, +} +``` + +The Python driver is a plain `while` loop with **no bridge, no Tokio, no `pyo3-async-runtimes`**: + +```python +async def formalize(self, label, feat, props, ctx, run) -> FormT | GaveUp: + handle = _rs.new_session(_marshal(feat, props, self._config)) # sync: build Rust state + obs = _rs.START + while True: + cmd = json.loads(_rs.resume(handle, obs)) # sync FFI: Rust decides + match cmd["kind"]: + case "call_llm": obs = await self._llm(cmd["messages"]) # async, in PYTHON + case "run_prover": obs = await self._verify_spec(cmd) # async, in PYTHON + case "run_feedback": obs = await self._feedback_judge(cmd) # async, in PYTHON + case "cache_get": obs = await ctx.cache_get_raw(cmd["key"]) # async, in PYTHON + case "cache_put": await ctx.cache_put_raw(cmd["key"], cmd["value"]); obs = _rs.ACK + case "publish": return GeneratedRustResult.model_validate(cmd["result"]) + case "give_up": return GaveUp(reason=cmd["reason"]) +``` + +Because Python owns the loop, the extension inherits everything for free: `run.runner(...)` +task/telemetry wrapping, the existing `verify_spec` prover tool, the +`property_feedback_judge` sub-agent, the hierarchical cache, cancellation +(`asyncio.CancelledError` just unwinds the Python loop), and structured-concurrency +isolation under `gather(return_exceptions=True)`. Rust contributes only the *policy*: prompt +construction, response interpretation, the validation-gate check, and the publish/give-up +decision. + +**Ergonomics for the Rust author — two flavors:** + +- **Explicit state machine.** Author writes `fn resume(&mut self, obs) -> Command` over an + explicit state enum. Simple, zero dependencies, but verbose for a rich loop. +- **Self-driven coroutine (recommended).** Author writes *linear, idiomatic* `async fn` + Rust, but `await`s custom `HostCall` futures whose leaf `poll` suspends a **Rust-owned, + single-threaded** executor and hands a `Command` out through `resume`. Python resumes by + feeding the `Observation` back in. The executor never leaves Rust and never touches + asyncio, so there is still **no cross-language async bridge** — it's a coroutine whose + "syscalls" happen to be Python async operations. This gives the write-it-linearly feel of + the full bridge at the cost of a ~200-line effect runtime, entirely in Rust. + +**What Tier 2 must reproduce that langgraph gives for free:** + +- **State threading.** The loop state is a flat, reducer-merged dict — `curr_spec`, + `skipped`, `property_rules`, `validations: dict[str,str]`, `required_validations`, + `rule_skips`, `config`, `prover_link`, `messages` ([author.py](../composer/spec/source/author.py)). + Rust holds this as its session struct; each `Command`'s observation merges in exactly as + the langgraph reducers do today. +- **The validation gates.** Publication is gated by `check_completion`: a content digest of + `(spec, skipped)` must match a stored digest for each of `required_validations` + (`["feedback","prover"]`), and **any spec edit invalidates both** by changing the digest. + Rust owns this predicate — it is pure — and only emits `Publish` when it passes; otherwise + it emits another `CallLlm` with the rejection reason, exactly as `PublishResultTool` does + today. +- **A turn budget.** langgraph's `recursion_limit` bounds the loop; Rust owns the counter and + emits `GiveUp` when it's exhausted. +- **Injection.** Today tools reach state/identity via langgraph `InjectedState` / + `InjectedToolCallId` and a `contextvars` runtime. Across FFI there is no ambient context — + the adapter passes what the effect needs explicitly in each `Command`. + +The one genuine constraint: **effects must be coarse-grained** — one `resume` per LLM turn +or per tool call, not per token. That is already the loop's natural granularity, so it costs +nothing here. Treat `RunFeedback` as a single opaque effect (Python runs the whole nested +judge agent) rather than recursing the state machine. + +### 4.3 Tier 3 — the full async bridge (only if Rust must *drive*) + +If Rust must genuinely `await` Python coroutines from inside deeply nested Rust `async` +code — e.g. it spawns its own concurrent Tokio tasks that each need to call Python +mid-flight — then and only then do you need `pyo3-async-runtimes` (Python awaitable ↔ Rust +`Future` via `into_future` / `future_into_py`), a pinned asyncio-loop ↔ Tokio-runtime +pairing, two-way cancellation translation, and panic-isolation so a Tokio task abort +doesn't kill the process. This roughly triples the effort and the test surface. + +> **Recommendation.** Deliver LLM authoring loops at **Tier 2** — inversion of control. +> It gives extensions the full author→verify→revise loop with none of the bridge's cost, +> and it fits the codebase's existing decide/do seam exactly. Reach for Tier 3 only if a +> concrete backend must drive concurrent async Python from within Rust — a need none of the +> current backends have. + +--- + +## 5. Hypothetical: the CVL prover backend in Rust + +To make the tiers concrete, here is how the CVL backend +([formalization-abstraction.md §4](./formalization-abstraction.md)) *might* be structured +in Rust. This is illustrative, not a proposal to rewrite it. + +### 5.1 What maps cleanly (Tier 1 candidates) + +The CVL backend's heavy, self-contained steps are natural Rust: + +- **`fetch_verdicts`** — resolves each spec's prover run and rolls per-rule outcomes into + `Verdict`s ([formalization-abstraction.md §4.5](./formalization-abstraction.md)). Pure + data-in/data-out over the prover output; already runs off-thread today. In Rust: + + ```rust + #[derive(Deserialize)] + struct ReportInput { name: String, final_link: Option<String>, /* ... */ } + #[derive(Serialize)] + struct RuleVerdict { rule: String, outcome: String, line: Option<u32>, + duration_seconds: Option<f64>, unit_file: Option<String> } + + #[pyfunction] + fn fetch_verdicts(payload: String) -> PyResult<String> { + let inp: ReportInput = serde_json::from_str(&payload)?; + let verdicts: Vec<RuleVerdict> = query_prover_output(&inp)?; // native HTTP/parse + Ok(serde_json::to_string(&verdicts)?) + } + ``` + + The adapter turns the returned list back into `dict[RuleName, Verdict]`. + +- **`finalize`** — builds the `{spec → prover-run link}` map and writes + `components_to_prover_runs.json` ([formalization-abstraction.md §4.6](./formalization-abstraction.md)). + Trivial serde + file write. + +- **The artifact bundle** — emitting the `.spec` + rendering the `.conf` (base config + + fixed run overlay) is string/JSON assembly, a good fit for a Rust `ArtifactStore` + helper, though the `ArtifactStore` object itself can stay Python and call a Rust + formatter. + +- **`GeneratedCVL` as `FormT`** stays a Python pydantic model + ([cvl_generation.py](../composer/spec/cvl_generation.py)); Rust returns its fields as + JSON and the adapter does `GeneratedCVL.model_validate(...)`. Its protocol methods + (`property_units()`, `artifact_text`, `output_link`) remain Python one-liners so the + cache/report keep working. + +### 5.2 The authoring loop — Tier 2, inversion of control + +`formalize` for CVL is the interesting case: it is *not* self-contained. `batch_cvl_generation` +([author.py](../composer/spec/source/author.py)) runs an **LLM agent graph to a fixpoint**, +interleaving: + +- LLM authoring turns, +- the `verify_spec` prover tool (an async service call), +- the `property_feedback_judge` agent ([feedback.py](../composer/spec/feedback.py)), +- two hard validation gates (`PROVER_VALIDATION_KEY`, `FEEDBACK_VALIDATION_KEY`) before the + agent may publish. + +Under [§4.2](#42-tier-2--inversion-of-control-rust-decides-python-does-the-io-), Rust owns +this loop as a **synchronous decider** while Python performs each async effect. The Rust +side is a plain state machine over the same state the langgraph loop threads today: + +```rust +// Rust: a pure step function. No async, no PyO3 awaitables — just decide the next effect. +fn resume(session: &mut Author, obs: Observation) -> Command { + match obs { + Observation::Start => Command::CallLlm { messages: session.opening_prompt() }, + Observation::LlmReply(msg) => session.interpret(msg), // draft edit? verify? publish? + Observation::ProverResult(r) => { session.record_prover(r); session.next() } + Observation::FeedbackResult(f) => { session.record_feedback(f); session.next() } + Observation::Cached(hit) => session.after_cache(hit), + Observation::Ack => session.next(), + } +} + +// session.next() enforces the SAME publish gate check_completion does today: +fn next(&mut self) -> Command { + if self.turns_left == 0 { return Command::GiveUp { reason: "turn budget exhausted".into() }; } + match self.gate() { // digest(spec, skipped) vs required keys + Gate::NeedProver => Command::RunProver { spec: self.spec(), config: self.conf(), rules: None }, + Gate::NeedFeedback => Command::RunFeedback { spec: self.spec(), skipped: self.skipped(), + rebuttals: self.rebuttals() }, + Gate::Ready(res) => Command::Publish { result: res }, // both digests fresh ⇒ publish + Gate::KeepAuthoring(reason) => Command::CallLlm { messages: self.reprompt(reason) }, + } +} +``` + +The Python adapter's driver loop ([§4.2](#42-tier-2--inversion-of-control-rust-decides-python-does-the-io-)) +maps each `Command` onto the *existing* async services — `self._llm`, the real `verify_spec` +tool, `property_feedback_judge`, `ctx.cache_*` — so Rust reuses all of them without ever +awaiting. The validation gates stay in Rust because `check_completion` is already pure: a +content digest of `(spec, skipped)` that must match a stored digest per `required_validation`, +with any spec edit invalidating both. **No `pyo3-async-runtimes`, no Tokio, no bridge.** + +### 5.3 `prepare_formalization` — orchestration stays in Python + +CVL's `prepare_formalization` ([formalization-abstraction.md §4.2](./formalization-abstraction.md)) +runs AutoSetup ∥ summaries ∥ structural-invariant formulation concurrently, then generates +`invariants.spec` once (with a cache short-circuit) and folds it into the resource set. This +is async orchestration of Python services — easiest left in the Python adapter. The +invariant *formulation logic* (once it has its inputs) and the invariant CVL authoring can +each be a Tier-1 helper or reuse the Tier-2 loop, but the `gather`/cache dance stays Python. + +### 5.4 The pragmatic split + +A realistic Rust CVL backend would be **hybrid**: + +| Method | Tier | Where it lives | +| --- | --- | --- | +| `prepare_system` (harness lift, prover-tool build) | — | Python adapter | +| `prepare_formalization` (concurrency + cache orchestration) | — | Python adapter | +| `formalize` **decider** (prompt policy, gate check, publish/give-up) | 2 | **Rust** state machine | +| `formalize` **effects** (LLM, `verify_spec`, feedback judge, cache) | 2 | Python adapter loop | +| `fetch_verdicts` (prover-output parse → verdicts) | 1 | **Rust** | +| `finalize` (run-link map) | 1 | **Rust** | +| artifact formatting (`.spec`/`.conf`) | 1 | **Rust** helper, Python `ArtifactStore` shell | +| `GeneratedCVL` (`FormT`) | — | Python pydantic | + +Every native-speed win (verdict parsing, artifact assembly, and now the authoring *policy* +itself) lands in Rust, and the LLM authoring loop works end-to-end — all without the Tier-3 +async bridge. + +--- + +## 6. Work breakdown + +### Phase 0 — spike (Tier 1, throwaway) +- Stand up a maturin crate producing a `cp312` abi3 wheel; import it from `ai-composer`. +- Prove the round-trip: JSON payload → Rust → `serde` structs → JSON result → + `pydantic.model_validate`. +- Confirm `py.allow_threads` + `asyncio.to_thread` gives real concurrency under the driver. + +### Phase 1 — a self-contained Rust backend (Tier 1) +- Thin Python adapter implementing the async protocol; all callbacks stay in Python. +- Rust owns whichever methods are self-contained (e.g. a native `fetch_verdicts`/`finalize`, + or a whole self-contained verifier). +- Marshalling helpers (`_marshal` / result validation); keep `FormT` pydantic. +- Wiring: `run_<rust>_pipeline`, CLI entry, widen `ReportBackend`. +- Tests: cache hit/miss round-trips, `GaveUp` path, exception → `ComponentOutcome`. + +### Phase 2 — the LLM authoring loop via inversion of control (Tier 2) + +This is the milestone that unlocks the capability extensions actually need. **No async +bridge.** + +- Define the `Command` / `Observation` JSON enums (the effect vocabulary of one turn). +- Rust: the `new_session` / `resume` sync FFI pair — the pure decider, holding the + loop state (`curr_spec`, `skipped`, `validations`, `rule_skips`, `config`, turn counter), + with the `check_completion` digest gate reproduced in Rust. +- Python: the adapter driver loop mapping each `Command` onto the *existing* async services + (`self._llm`, `verify_spec`, `property_feedback_judge`, `ctx.cache_*`). +- Decide the Rust-author ergonomics: ship the explicit-state-machine API first; add the + self-driven-coroutine effect runtime if authors want linear code. +- Tests: a full author→verify→feedback→publish trace; gate staleness on spec edit; + turn-budget give-up; `CancelledError` unwinds cleanly. + +### Phase 3 — full async bridge (Tier 3, only if a backend must *drive*) +- Adopt `pyo3-async-runtimes`; pin the asyncio-loop/Tokio-runtime pairing. +- Two-way cancellation translation; panic isolation so a Tokio abort ≠ process abort. +- Verify structured-concurrency parity (per-component isolation under + `gather(return_exceptions=True)`). + +### Cross-cutting + +- Build/CI: cross-platform abi3 wheels, `uv` source wiring, reproducible Rust toolchain. +- Docs: fold the final `Command`/`Observation` ABI and the marshalling schemas into + [formalization-abstraction.md §9](./formalization-abstraction.md)'s "new backend" checklist. + +--- + +## 7. Open questions + +1. **Do we ever need Tier 3?** Tier 2 (inversion of control) delivers the LLM authoring + loop with no bridge. Tier 3 only pays off if a concrete backend must drive concurrent + async Python from within Rust — decide per backend, not up front. +2. **Rust-author ergonomics for Tier 2** — ship the explicit `resume` state machine, or + invest in the self-driven-coroutine effect runtime so authors write linear `async` Rust? + Start with the former; graduate to the latter only if the loops get complex enough to + warrant it. +3. **Wheel vs mixed build** — does any consumer need `ai-composer` itself to remain a pure + sdist-installable package? If so, the separate-wheel path is mandatory. +4. **ABI stability** — the `Command`/`Observation` enums and the marshalling JSON schemas + become a versioned contract between the wheel and `ai-composer`. Where does that schema + live, and how is it version-checked at load time? +5. **Effect granularity** — the IoC loop assumes one `resume` per turn/tool-call. Is any + effect an extension might want finer-grained than that (e.g. streaming LLM tokens)? If so + it must still be batched to a turn boundary, or it forces Tier 3. +6. **Observability** — the driver's `run.runner(TaskInfo(...))` wrapping gives per-task + telemetry/UI rows. A Tier-1 Rust method invoked via `asyncio.to_thread` still sits inside + one `runner` task (good); a Tier-2 loop's per-turn effects should each be wrapped by the + Python adapter in `run.runner(...)` so they stay visible in the TUI. + +--- + +## 8. Key files + +| Concern | File | +|---|---| +| Driver + abstraction definitions | [composer/pipeline/core.py](../composer/pipeline/core.py) | +| Result protocols (`FormalResult`, `ArtifactIdentifier`) | [composer/spec/types.py](../composer/spec/types.py) | +| `ReportableResult`, `Verdict`, `collect` | [composer/spec/source/report/collect.py](../composer/spec/source/report/collect.py) | +| CVL backend (reference for the sketch) | [composer/spec/source/pipeline.py](../composer/spec/source/pipeline.py) | +| CVL authoring loop + completion tools (`batch_cvl_generation`, `PublishResultTool`) | [composer/spec/source/author.py](../composer/spec/source/author.py) | +| `PureFunctionGenerator` decide/do seam + graph topology (the IoC precedent) | [graphcore/graphcore/graph.py](../graphcore/graphcore/graph.py) | +| `check_completion` validation-gate predicate + loop state (`FormT`, gate digest) | [composer/spec/cvl_generation.py](../composer/spec/cvl_generation.py) | +| Foundry backend (simplest backend to copy) | [composer/foundry/pipeline.py](../composer/foundry/pipeline.py) | +| `ReportBackend` literal to widen | [composer/spec/source/report/collect.py](../composer/spec/source/report/collect.py) | +| Packaging | [pyproject.toml](../pyproject.toml) | +| The seam this builds on | [formalization-abstraction.md](./formalization-abstraction.md) | diff --git a/docs/rust-ioc-loop.md b/docs/rust-ioc-loop.md new file mode 100644 index 00000000..f5f7a15f --- /dev/null +++ b/docs/rust-ioc-loop.md @@ -0,0 +1,241 @@ +# The Rust IoC loop: what it does, why it exists, and how to remove it + +Design note. Describes the inversion-of-control (IoC) effect loop that connects the +Python host (`composer/rustapp`) to a Rust backend wheel (`autoprover-sdk` + +`crucible-app`/`example-app`), why it is shaped the way it is, and a concrete option for +**eliminating it** in favour of a small set of *pure* Rust callouts driven directly by the +Python core pipeline. No code change yet — this is to decide the shape. + +## 1. What the loop is (mechanics) + +A Rust backend does not run its own control flow. It is a **pure synchronous decider**: +a state machine that, given the result of the last effect, returns the next *command* to +perform. Python owns the actual work (LLM turns, subprocesses, caching, events) and the +async event loop. + +The FFI surface a wheel exports (`export_app!`, [autoprover-sdk/src/lib.rs](../rust/autoprover-sdk/src/lib.rs)): + +| Function | Kind | Role | +|---|---|---| +| `descriptor() -> json` | **pure** | phases, event kinds, args, artifact layout, ecosystem, backend tag | +| `validate_preconditions(args) -> str?` | **pure** | e.g. "is there a `Cargo.toml`?" | +| `new_session(input) -> RustSession` | **stateful** | build a formalization state machine | +| `new_setup_session(input) -> RustSession?` | **stateful** | build the program-wide setup state machine (Crucible fixture) | +| `RustSession.resume(obs_json) -> cmd_json` | **stateful** | **the loop step** — one decision per call | +| `fetch_verdicts(input) -> json` | **pure** | per-unit report verdicts | +| `finalize(outcomes) -> json?` | **pure** | run-level artifact files | + +Everything except the **session** (`new_session`/`new_setup_session` + `resume`) is +already a pure, stateless callout. The loop is the one stateful exception. + +### The step protocol + +The Rust session speaks two closed enums ([lib.rs](../rust/autoprover-sdk/src/lib.rs)): + +- **`Command`** (Rust → Python): `CallLlm`, `RunProver`, `RunCommand`, `RunFeedback`, + `CacheGet`, `CachePut`, `Emit`, and the terminals `Publish` / `GiveUp`. +- **`Observation`** (Python → Rust): `Start`, `LlmReply`, `ProverResult`, + `FeedbackResult`, `Cached`, `CommandResult`, `Ack`. + +The Python driver `drive_session` ([composer/rustapp/loop.py](../composer/rustapp/loop.py)) +is the whole loop: + +```python +observation = {"kind": "start"} +for _ in range(max_steps): + command = json.loads(session.resume(json.dumps(observation))) # sync FFI hop + match command["kind"]: + case "call_llm": text = await effects.call_llm(command["messages"]); observation = {"kind": "llm_reply", "text": text} + case "run_command": r = await effects.run_command(...); observation = {"kind": "command_result", ...} + case "emit": await effects.emit(...); observation = {"kind": "ack"} + case "publish": return RustFormalized(command["result"]) # terminal + case "give_up": return GaveUp(command["reason"]) # terminal + ... +``` + +`Effects` is a protocol (`call_llm`, `run_command`, `run_prover`, `run_feedback`, +`cache_get/put`, `emit`), so a fake drives the decider in tests and `RealEffects` +([adapter.py](../composer/rustapp/adapter.py)) drives it in production. + +### A session in practice + +Crucible's `BatchSession` ([crucible-app/src/lib.rs](../rust/crucible-app/src/lib.rs)) is a +`{Start, AwaitDraft, AwaitFuzz, Done}` state machine holding `test_src`, `attempts`, `cur`, +`verdicts`. One conceptual procedure — + +> author all tests → for each feature: build+fuzz → interpret → (retry the *whole* harness +> on a build error) → publish the verdicts + +— is spread across `resume` arms, one per effect boundary, with the state threaded by hand +between calls. What is logically a `for` loop with a retry is expressed as a resumable +coroutine hand-compiled into an enum. + +## 2. Why it exists (the rationale — all still real) + +1. **Decide/do split across the FFI, with no async bridge.** All async I/O (LLM, subprocess, + Postgres, event streaming) lives in Python; Rust stays pure and synchronous. Every + `resume` is a fast blocking call, so there is **no `pyo3-async`/tokio bridge** and no Rust + async runtime. This mirrors the `PureFunctionGenerator` decide/do split the CVL author + already uses in Python — relocated across the language boundary. +2. **Testability.** Because effects are a protocol, the Rust decider's logic runs against a + fake with canned command results — no LLM, no toolchain + ([test_crucible_events.py](../tests/test_crucible_events.py)). +3. **The command-line security invariant.** `RunCommand` carries a **decider-authored** + `program` + `args`; only file *contents* may be LLM-derived. The IoC boundary makes this + explicit and enforceable: the LLM never chooses what runs (see the `RunCommand` doc + comment and [command-sandbox.md](./command-sandbox.md)). **Any replacement must preserve + this.** +4. **A backend-agnostic host.** The generic Python host drives *any* wheel through the same + loop + descriptor, so "a new backend is just a wheel." The `Command`/`Observation` protocol + is the uniform contract. + +## 3. What it costs + +- **State-machine bookkeeping.** Each backend re-expresses a straight-line author-gate + procedure as a resumable enum: explicit stages, hand-threaded fields, an `emit`-queue + shim (`Emitter`) to fire events *between* real commands. `BatchSession` is ~150 lines of + plumbing for a loop. +- **Two orchestration layers.** The Python core pipeline (`composer/pipeline/core.py`) + already orchestrates phases (analysis → extraction → formalize → report) with real async + and a result cache. The IoC loop is a *second*, nested orchestration inside `formalize`, + with its own mini-cache and event channel. Two models to understand. +- **The control flow is invisible where you look for it.** The interesting logic — author, + gate with a CLI, retry on failure, publish — is scattered across `resume` arms in Rust and + a dispatch `match` in Python, joined only by JSON round-trips. Neither side reads as the + procedure it is. +- **Ceremony per step.** Every decision is a JSON serialize → FFI hop → deserialize, plus a + `Command`/`Observation` variant to define and thread on both sides. + +## 4. The key observation + +**The decider carries no state that Python couldn't hold, and needs no control flow Python +can't express.** All real work is already in Python; the Rust "decisions" are pure functions +of the accumulated state (the draft, the attempt count, the command outputs). Resumability +is not intrinsic — it is an artifact of expressing an *author→gate→retry→publish* loop as a +coroutine that happens to suspend at each effect. Move the loop to Python and Rust needs only +a handful of **pure functions** at the decision points. + +And the shape is uniform across today's backends: + +| Backend / session | author | gate command(s) | interpret | publish | +|---|---|---|---|---| +| Crucible setup | fixture | `crucible run … --dry-run` | build error? → revise | fixture source | +| Crucible batch | all invariant tests | `crucible run <program> <feat>` per feature | `[FUZZ_FINDING]`→BAD else GOOD; build error→revise all | verdicts + property map | +| echoprover (demo) | spec (or cache hit) | `RunProver` | verified? | rules | + +All three are the same template: **author (with bounded revise-on-failure) → run +decider-authored gating command(s) → interpret results → publish or give up** — exactly the +author-gate loop the CVL/foundry Python backends already run. + +## 5. Proposal: replace the state machine with pure callouts + a Python driver + +Keep the four already-pure callouts (`descriptor`, `validate_preconditions`, +`fetch_verdicts`, `finalize`). **Delete** `new_session`/`new_setup_session`/`resume`, the +`RustSession` pyclass, the `Command`/`Observation` enums, and `drive_session`. **Add** a +small pure-callout contract per session kind, and drive it from one generic Python loop that +lives in the core pipeline. + +### The callouts (pure `json → json`, no session object, no state) + +1. `prompt(input, attempt, draft?, error?) -> {system?, instruction}` + The author prompt for attempt *N* — initial when `attempt == 0`, else the revise prompt + built from the prior `draft` + `error`. (Absorbs `author_prompt` + `revise_suffix` + + cheat sheets.) +2. `gate(input, draft) -> [Effect]` + The **decider-authored** gating commands for a draft, where + `Effect = Shell{program, args, files} | Prover{spec, config, rules} | Feedback{…}`. + *This is where the command-line security invariant lives* — `program`/`args` come from + compiled Rust, never the LLM. (Absorbs `fuzz_command` / `RunProver`.) +3. `interpret(input, draft, results) -> {status: ok | retry | give_up, error?, reason?}` + Classify the gate results: pass, retry (with the error text to feed the next `prompt`), + or give up. (Absorbs `is_build_error`, `[FUZZ_FINDING]` detection, the attempt policy is + Python's.) +4. `publish(input, draft, results) -> Formalized` + Assemble `artifact_text` + `property_units` + `verdicts` from the winning draft and its + results. (Absorbs `publish`.) + +### The generic Python driver (in the core pipeline) + +```python +async def author_gate(mod, session_input, effects, *, max_attempts, emit) -> Formalized | GaveUp: + draft, error = None, None + for attempt in range(max_attempts): + draft = await effects.call_llm(mod.prompt(session_input, attempt, draft, error)) # async: Python + results = [await run_effect(effects, e) for e in mod.gate(session_input, draft)] # subprocess+sandbox: Python + verdict = mod.interpret(session_input, draft, results) # pure: Rust + if verdict.status == "ok": return mod.publish(session_input, draft, results) # pure: Rust + if verdict.status == "give_up": return GaveUp(verdict.reason) + error = verdict.error # retry + return GaveUp(f"did not converge in {max_attempts} attempts") +``` + +`run_effect` dispatches a `Shell`/`Prover`/`Feedback` effect to the existing +`RealEffects.run_command`/`run_prover`/`run_feedback` — unchanged, still exec-not-shell, +still sandboxed. Progress **events and caching move to Python**: the driver already knows the +phase and each command's result, so it emits the `verdict`/`build`/`fuzz` notices itself +(the wheel no longer needs an `Emit` command or the `Emitter` shim), and the pipeline's +existing result cache subsumes the loop's scratch `CacheGet`/`CachePut`. + +`CrucibleFormalizer.formalize` then becomes: prepare the crate, then `author_gate(...)` — the +control flow is one readable Python function instead of a Rust enum plus a JSON dispatcher. + +### What each side ends up owning + +- **Rust:** prompts, the command line (security), result interpretation, result assembly — + all pure functions, unit-testable in Rust with no Python. +- **Python:** the author-gate loop, retries/budget, all async effects, sandboxing, caching, + events — one orchestration model, shared with the CVL/foundry backends. + +## 6. Trade-offs and risks + +- **Loss of arbitrary per-backend control flow.** The IoC loop is Turing-complete; the + callout model fixes one template (author → gate → interpret → publish, with bounded + revise). All *current* sessions fit it, but a future backend wanting a genuinely different + flow (multi-phase, branch-and-minimize a counterexample, adaptive strategies) would need a + new template callout, not a free-form state machine. **This is the real decision:** is the + author-gate template enough for every backend we foresee? If yes, the loop is + over-general; if we expect exotic flows, the loop earns its keep. +- **Incremental emission is slightly coarser.** Today Crucible emits a live verdict as *each* + feature finishes fuzzing. With `gate` returning all commands and `interpret` classifying + them together, Python would emit verdicts after the batch — unless we keep a tiny + `classify(one_result) -> outcome` callout so the driver can emit per-command as it goes + (a fifth pure function; cheap). +- **Migration touches both languages.** Delete the enums/loop/pyclass, add the callouts, + rewrite `RealEffects` into a small `run_effect` dispatcher, and re-express both Crucible + sessions and the echoprover demo as callouts. Bounded, but not a one-liner. +- **`example-app` cache short-circuit.** echoprover's `CacheGet → maybe skip LLM` becomes a + Python-side "check the pipeline cache before authoring" — arguably clearer, and removes the + redundant second cache. +- **The security invariant must be re-audited** at its new home (`gate`), with a test that + the LLM draft cannot influence `program`/`args`. + +## 7. Recommendation + +The four pure callouts already outnumber the one stateful surface, and every current session +is an author-gate loop wearing a state-machine costume. Moving the loop into the Python core +(where the sibling CVL/foundry backends already orchestrate the identical shape) removes a +whole protocol, both `Emitter`/scratch-cache shims, and the hand-compiled enums — at the cost +of committing to the author-gate template. Recommended **if** we accept that template as the +backend contract. + +Suggested staging (each shippable): + +1. **Add the callouts alongside the loop.** Implement `prompt`/`gate`/`interpret`/`publish` + for Crucible's `BatchSession` and `SetupSession` as pure functions *next to* the existing + `resume` (they can share code). No behaviour change yet. +2. **Add `author_gate` to the core pipeline** and switch `CrucibleFormalizer` to it behind a + flag; verify against the e2e gate (identical verdicts). +3. **Port echoprover**, then **delete** `resume`/`RustSession`/`Command`/`Observation`/ + `drive_session` and trim `Effects` to `call_llm` + `run_effect`. + +## 8. Open questions + +- Is the author-gate template sufficient for the backends we actually plan (Crucible, + soroban, a CVL-in-Rust prover)? Any that need branching multi-phase flow? +- Keep the per-result `classify` callout for live per-unit emission, or accept batch-end + emission? +- Should `gate` return **all** commands up front (enables Python to parallelize them — + ties into the `--binary-in` fuzz-parallelism lever in + [crucible-unit-granularity.md §7](./crucible-unit-granularity.md)) or one at a time? +- Does any backend need Rust to hold state *between* gate commands that isn't recoverable + from `(input, draft, results)`? (None does today.) diff --git a/docs/rust-pure-app.md b/docs/rust-pure-app.md new file mode 100644 index 00000000..265f0b4c --- /dev/null +++ b/docs/rust-pure-app.md @@ -0,0 +1,400 @@ +# Design Doc — Defining an AutoProver application *purely* in Rust + +> Today a Rust *backend* is a passive wheel ([rust-backend-api.md](./rust-backend-api.md)) and +> a generic Python host ([rust-applications.md](./rust-applications.md)) turns any wheel into a +> runnable application — phase enum, argparse, entry point, frontend, store, `main()`. That +> works end-to-end for a *simple* app: `echoprover` ships as `console_main("echoprover")` with +> **zero bespoke Python**. +> +> Crucible does not. It carries a whole `composer/crucible/` package that *forks* the generic +> host in ~10 specific places (a crate-shaped deliverable, a shared setup fixture, dependency +> warming, an `.so` pre-build, a RAG env, sandbox grants, a verdict summary). This doc +> inventories every one of those forks and proposes a Rust-side seam for each, so that Crucible +> becomes what echoprover already is: a wheel + a descriptor, launched by the generic host with +> no application-specific Python. +> +> Companion to [rust-applications.md](./rust-applications.md) (§4.5 already anticipated "Python +> shell, Rust formatter" for the store — this doc discharges that and the rest) and +> [rust-backend-api.md](./rust-backend-api.md) (the callout surface we extend). + +--- + +## 1. Goal and non-goal + +**Goal.** An application is *defined* entirely by its Rust wheel (`rust/<app>-app`) and the +`AppDescriptor` it exports. Standing up a new verifier — even one as involved as Crucible — +requires **no** new Python package. `console-<app>` / `tui-<app>` are two-line shims over the +generic `composer.rustapp.cli`. + +**Non-goal — the ecosystem stays shared Python.** "Pure Rust *app*" does not mean "pure Rust +*everything*". The pipeline's **front half** (system analysis + property extraction) is +parametric over an *ecosystem* ([ecosystem.py](../composer/pipeline/ecosystem.py)), and the +`solana` ecosystem — its `SolanaApplication` model, j2 prompts, `locate_main`, global-extraction +strategy — is **chain-specific, not app-specific**. It is legitimately shared by any Solana +backend (Crucible today, a future Solana app tomorrow) and stays Python. The wheel *selects* an +ecosystem by tag (`descriptor.ecosystem = "solana"`); it does not reimplement it. + +So the line this doc draws is: + +> Everything downstream of "which ecosystem" that is specific to **this verifier** moves into +> the wheel. Everything that is shared **service lifecycle** (Postgres, the TUI event loop, +> `composer.bind`) or shared **chain** logic (the ecosystem) stays Python. + +**Security invariant (unchanged, load-bearing).** The LLM never controls a command line; only +file *contents* may be LLM-derived. Today the *trusted wheel* assembles every `crucible …` +argv and Python authors the `Sandbox` *policy* ([command-sandbox.md](./command-sandbox.md) §2). +Every new seam below preserves this exactly: new toolchain steps are still wheel-authored argv +run under a Python-authored policy. See §7. + +--- + +## 2. The gap inventory — where Crucible forks the generic host + +Every item below is Python that exists *only* because the generic host can't yet express what +Crucible needs. Each maps to a seam in §3–§5. + +| # | Crucible-specific Python | Location | What it does | Seam | +|---|---|---|---|---| +| 1 | `CrucibleArtifactStore`, `CrucibleHarness`, `CrucibleDep` | [store.py](../composer/crucible/store.py), [harness.py](../composer/crucible/harness.py) | Assemble **one Cargo crate** (deps + shared fixture + one feature-gated test section per property) instead of one file per component | §3.1 deliverables callout | +| 2 | Setup-fixture authoring | [backend.py](../composer/crucible/backend.py) `CruciblePreparedSystem.prepare_formalization` | Author + compile-gate a shared `kind="setup"` artifact once, before per-component formalization | §3.2 declared setup step | +| 3 | Context injection | [backend.py](../composer/crucible/backend.py) `CrucibleFormalizer._context` | Thread the fixture + `fuzz_timeout` into each component's `AuthorInput.context` | §3.3 generic context | +| 4 | Crate scaffolding | `_before_formalize` → `store.prepare_component` | Pre-place `Cargo.toml` with cumulative feature declarations before each unit builds | §4 (subsumed by prep + per-run manifest) | +| 5 | Toolchain serialization | `CrucibleFormalizer(command_sem=Semaphore(1))` | Serialize compile/validate (one shared crate / target dir) | §3.4 descriptor flag | +| 6 | Dependency warming | [store.py](../composer/crucible/store.py) `warm_dependencies` + `write_setup_manifest` | Network `cargo fetch` **outside** the sandbox into the private `CARGO_HOME`, so the confined build runs offline | §4 workspace_prep | +| 7 | Program `.so` pre-build | [pipeline.py](../composer/crucible/pipeline.py) `run_crucible_pipeline` → `build_program` | `cargo-build-sbf` / `anchor build` before the pipeline; the harness loads the `.so` | §4 workspace_prep | +| 8 | RAG env | [pipeline.py](../composer/crucible/pipeline.py) `build_crucible_env` | Wire the `crucible_kb` RAG search tools onto the author env | §5.1 descriptor-driven env | +| 9 | Repo resolution + sandbox grants + default provider | [pipeline.py](../composer/crucible/pipeline.py) `resolve_crucible_repo`, `crucible_sandbox` | Resolve `$CRUCIBLE_REPO`; grant it + the `crucible` binary as sandbox `extra_ro`; default to the `launcher` provider | §5.2 grants callout + descriptor flag | +| 10 | CLI entry points + verdict summary | [cli.py](../composer/crucible/cli.py), [results.py](../composer/crucible/results.py) | `console-crucible` / `tui-crucible`; print a per-invariant verdict tally | §5.3 generic summary | + +The current generic path (what echoprover uses) is +[`composer/rustapp/cli.py`](../composer/rustapp/cli.py) `console_main` / `tui_main` → +[`entry.py`](../composer/rustapp/entry.py) `rust_entry_point` → +[`host.py`](../composer/rustapp/host.py) `run_application` → +[`adapter.py`](../composer/rustapp/adapter.py) `RustFormalizer`. Crucible's package is a fork of +exactly this chain. The seams below let Crucible re-join it. + +--- + +## 3. Formalization seams (the loop) + +### 3.1 Deliverable assembly → a `finalize`-shaped callout + +**Today.** The base [`ArtifactStore`](../composer/spec/artifacts.py) writes the *shared* +metadata every backend produces — `properties.json`, `commentary.md`, the property→units map, +`token_usage.json` — and materializes the artifact bytes as `{prefix}_{slug}.{ext}`, one file +per component. `CrucibleArtifactStore` overrides `write_artifact` to instead fold each +component's section into a `CrucibleHarness` and re-render a whole crate (`Cargo.toml` + +`src/main.rs`). `CrucibleHarness`/`CrucibleDep` duplicate, in Python, crate rendering the wheel +*also* does in Rust (`one_file`, and the dep list the harness pins). + +**Proposal.** Split the store's job cleanly: + +- **Metadata stays generic.** `properties.json` / `commentary.md` / the property map / + token usage are not app-specific — keep them in the base store, unchanged, for every app. +- **Source deliverable becomes a callout.** Add a descriptor field + `deliverable_mode: "per_component" | "callout"` (default `per_component` = today's + echoprover behavior). In `callout` mode the base store writes **no** per-component source + file; instead the wheel renders the whole deliverable from the full result set. + +The natural callout is the one that already exists: `finalize`. It already receives the outcome +set and returns `{relpath: contents}` ([lib.rs](../rust/autoprover-sdk/src/lib.rs) `ffi_finalize` +→ the host writes each file, [adapter.py](../composer/rustapp/adapter.py) `RustFormalizer.finalize`). +We enrich its input so the wheel has everything the crate needs: + +```jsonc +// finalize input (per outcome), extended: +{ "name": "...", "delivered": true, "unit_file": "...", "run_link": "...", + "artifact_text": "<the authored test section>", // NEW + "property_units": [["<title>", ["c_slug"]]], // NEW + "setup": "<the shared fixture source>" } // NEW (run-level, see §3.2) +``` + +`crucible_app::finalize` then renders `fuzz/<program>/Cargo.toml` + `src/main.rs` from the +fixture + the per-property sections — the `CrucibleHarness` logic, but in Rust, as the **single +source of truth** for crate layout (it already half-lives there as `one_file`). `CrucibleDep`'s +pinned dependency stack moves into the wheel too; it reads `$CRUCIBLE_REPO` directly (§5.2). + +**Tradeoff.** The crate lands on disk only at finalize, not incrementally per component. That's +acceptable: the crate is only *runnable* once complete, and `validate` already materializes a +transient copy for each fuzz run via `run_confined`'s `files` map. We note this in the doc so a +future "stream partial deliverables" need is a known, deliberate follow-up. + +**Deletes:** `harness.py` entirely; `CrucibleArtifactStore` (the generic `RustArtifactStore` +in `callout` mode suffices — see §4 for why even the manifest pre-placement goes away). + +### 3.2 Shared setup artifact → a declared step + +**Today.** `CruciblePreparedSystem.prepare_formalization` authors a `kind="setup"` artifact +(the fixture) via `author_and_compile`, once, before per-component formalization, and stashes +it on the store. The wheel *already* handles `kind=="setup"` in `units` (→ empty), `author_prompt` +(→ fixture prompt), and `compile` (→ probe dry-run). Only the *orchestration* is Python. + +**Proposal.** Make the setup step declarative on the descriptor: + +```rust +setup: Option<SetupSpec> // { phase_key: "build_harness", label: "Build Harness", + // context_key: "fixture" } +``` + +When present, the generic `RustPreparedSystem.prepare_formalization` (in +[adapter.py](../composer/rustapp/adapter.py)) runs the existing `author_and_compile` for a +`kind="setup"` input under `setup.phase_key`, and stashes the compiled spec on the formalizer. +This lifts `CruciblePreparedSystem` into the host verbatim — no new logic, just a descriptor +gate. Apps with no setup (echoprover) omit the field and skip the step. + +### 3.3 Context injection → generic + +**Today.** `_context` returns `{program, fixture, fuzz_timeout}`; the base returns `{program}`. + +**Proposal.** The host always injects into every component's `AuthorInput.context`: +(a) the setup result under `setup.context_key` (if a setup step ran), and (b) each **declared +CLI arg** value (so `fuzz_timeout` — already a descriptor `ArgSpec` — is present). The wheel +reads them exactly as it does now (`ctx_str(input, "fixture")`, `ctx_u64(input, "fuzz_timeout")`). +`_context` and the `CrucibleFormalizer` override disappear. + +### 3.4 Toolchain serialization → a descriptor flag + +**Today.** `CrucibleFormalizer` passes `command_sem=asyncio.Semaphore(1)` because all +compile/validate runs share one crate / target dir. + +**Proposal.** Descriptor flag `serialize_toolchain: bool` (default `false`). When `true`, the +generic formalizer constructs the `Semaphore(1)` itself and threads it into `_run_blocking` +(the plumbing already exists — `RustFormalizer.__init__(command_sem=...)`). Crucible sets it +`true`; echoprover leaves it `false` (its `validate` is a no-op with no shared state). + +--- + +## 4. Workspace preparation — a pure plan the host executes + +Items **4, 6, 7** (Cargo manifest placement, dependency warming, the `.so` pre-build) are all +the same shape: a **toolchain step that must run before formalization**, part of it needing +**network** (the dependency fetches). Today they're three ad-hoc Python steps +(`write_setup_manifest`, `warm_dependencies`, `build_program`). + +**Design constraint discovered in the code.** The command sandbox *never* gives a confined +process network access — `rust_build_policy` hardcodes `network=False`, and the only network +step, `warm_cargo_cache`, runs **unconfined** ([command-sandbox.md](./command-sandbox.md) §5). +So a "prep sandbox with `network: true`" (an earlier draft of this section) would be a brand-new +security capability — a confined process with a socket — which the codebase deliberately avoids. +The right seam therefore does **not** hand the wheel a confined-with-network policy. + +**Proposal.** One new **pure** callout that returns a *plan*, executed by the **host** with the +existing shared helpers: + +```rust +fn workspace_prep(&self, input: &AuthorInput) -> WorkspacePrep { + // { files: {relpath: contents}, // e.g. the harness Cargo.toml (deps only the wheel knows) + // warm_dirs: [String], // dirs to `cargo fetch` (unconfined, network) + // build_program: Option<String> // build this program to its platform binary } +} +``` + +- The host writes `files` (path-confined via `confined_join`), runs `warm_cargo_cache` on each + `warm_dirs` (**unconfined, network** — a fetch runs no untrusted code), and, if + `build_program` is set, calls the shared `build_program` capability (which itself warms the + *program* crate then builds it **confined + offline**). +- **Posture unchanged and Python-owned end to end**: fetches unconfined, code-executing builds + confined+offline. The wheel touches no command line — it contributes only file *contents* and + declarative intent (which dirs, which program). Strictly within the existing trust model; no + new capability. +- `build_program` is the shared Solana build capability + ([solana/build.py](../composer/spec/solana/build.py)); the generic host invokes it lazily when + the plan requests it (shared ecosystem capability, not app-specific Python). + +**Bonus simplification — the cumulative-feature manifest race disappears (item 4).** The reason +`prepare_component` reserves features *cumulatively* on a shared on-disk `Cargo.toml` is that +concurrent per-component sessions each rewrite it and could drop each other's feature. But with +`serialize_toolchain: true` (§3.4), runs are serialized, and the wheel can materialize +`Cargo.toml` (deps + exactly the one feature this build needs — features are inert `f = []` +entries that don't affect dep resolution) in the **`files` map of each `compile`/`validate` run**. +No shared-manifest mutation across runs ⇒ no race ⇒ no `reserve_features` / `_reserved` / +`prepare_component` machinery at all. The `workspace_prep` plan places only the deps-only manifest that +warming needs. This is the last thing keeping the `CrucibleArtifactStore` alive; with it gone, +§3.1's generic `callout` store fully suffices. + +**Deletes:** `write_setup_manifest`, `warm_dependencies`, the `build_program` pre-step call, and +the whole feature-reservation path in `harness.py`. + +--- + +## 5. Entry-point seams (services & UI) + +These stay Python (service lifecycle / event loop — the "shell" of +[rust-applications.md](./rust-applications.md) §1), but are made **descriptor-driven** so no +Crucible fork is needed. + +### 5.1 RAG env from the descriptor + +**Today.** `build_crucible_env` builds the `crucible_kb` RAG tools and is passed as +`env_builder=` everywhere. The generic `build_neutral_env` builds *no* RAG even though the +descriptor already declares `rag_db_default: "crucible_kb"`. + +**Proposal.** Fold `build_crucible_env`'s logic into the generic env builder, gated on +`descriptor.rag_db_default`: when set, open that RAG DB and add its search tools (falling back +to no-RAG on failure, exactly as `build_crucible_env` does today); when `None`, the neutral env. +The `env_builder=` override parameter can stay for exotic cases but Crucible stops needing it. + +### 5.2 Sandbox grants + default confinement + +**Today.** `crucible_sandbox` resolves `$CRUCIBLE_REPO`, grants it + `which("crucible")` as +`extra_ro`, and defaults the provider to `launcher` (fail-closed). `resolve_crucible_repo` +validates the checkout. + +**Proposal.** +- **Grants → a pure callout** `sandbox_grants(args) -> { extra_ro: [String], extra_env: [String] }`. + The wheel resolves `$CRUCIBLE_REPO` and scans `$PATH` for `crucible` in Rust (it already scans + `$PATH` in `on_path`). The host unions the returned grants into its `SandboxConfig`. +- **Default confinement → a descriptor flag** `confine_by_default: bool` (true for any wheel + with real toolchain callouts). The generic entry builds the `launcher` `SandboxConfig` when + set (still overridable by `COMPOSER_SANDBOX_PROVIDER=none`), replacing the hardcoded default in + `crucible_sandbox`. +- **Repo validation → `validate_preconditions`.** The wheel already validates the workspace + there (`Cargo.toml` present, required binaries on `$PATH`); add the `$CRUCIBLE_REPO` / + `crates/crucible-fuzzer` check. `resolve_crucible_repo` disappears; the `--crucible-repo` flag + becomes a descriptor `ArgSpec` the wheel reads from `args`/env. + +### 5.3 Verdict summary → generic + +**Today.** `results.py` (`summarize_verdicts`, `format_verdict_lines`) turns +`RustFormalResult.verdicts` into a console tally; `crucible/cli.py` prints it. But `verdicts` is +**already a generic field** on the generic result type. + +**Proposal.** Move `results.py` into `composer/rustapp/` and have the generic `console_main` / +`tui_main` print the verdict tally whenever the results carry verdicts (empty ⇒ prints nothing, +exactly as today for echoprover). Parametrize the outcome wording by `descriptor.backend_tag` +(the report's `outcome_label(tag, …)` already takes the tag). One nicety: make the component +noun a descriptor field `component_noun: "instruction"` (default `"component"`) so Crucible's +"Instructions:" line and echoprover's "Components:" line come from the same code. + +--- + +## 6. What Crucible collapses to + +After §3–§5: + +- **Deleted:** `composer/crucible/` in its entirety — `backend.py`, `store.py`, `harness.py`, + `pipeline.py`, `results.py`, `cli.py`. +- **`pyproject.toml`:** + ```toml + console-crucible = "composer.crucible_launch:console_crucible" # 2-line shim, or: + console-crucible = "composer.rustapp.cli:console_main" # via a --module arg + tui-crucible = "composer.rustapp.cli:tui_main" + ``` + (A thin `crucible_launch.py` = `def console_crucible(): return console_main("crucible_app")` + keeps the bare `console-crucible` command with no positional module arg. This is the *only* + Python left, and it's shared-shaped — echoprover has the identical shim.) +- **The wheel (`rust/crucible-app`)** grows: `workspace_prep`, `sandbox_grants`, a richer + `finalize` (crate rendering, absorbing `CrucibleHarness`/`CrucibleDep`), the repo precondition, + and a descriptor carrying `setup`, `deliverable_mode: "callout"`, `serialize_toolchain: true`, + `confine_by_default: true`, `component_noun: "instruction"`. It reads `$CRUCIBLE_REPO` itself. +- **Everything downstream of "ecosystem = solana"** is the shared front half — unchanged. + +The proof obligation: `console-crucible <project> <program> <doc> --fuzz-timeout N` produces a +byte-identical deliverable + report to today, at parity runtime (the e2e Vault gate — 16 GOOD, +~41 min baseline). + +--- + +## 7. Security invariant — preserved, and audited per seam + +> The LLM controls file *contents* only; the trusted wheel controls every argv; Python authors +> every sandbox *policy*. + +| Seam | New capability | Who controls argv | Who authors policy | Net | +|---|---|---|---|---| +| §3.1 finalize deliverable | writes files under project root | — (host writes, path-confined via `confined_join`) | n/a | same as today's store | +| §4 workspace_prep | warm dirs + build a program | — (host runs the shared `warm_cargo_cache` / `build_program`; wheel supplies only file *contents* + which dirs/program) | **Python** `SandboxConfig` | **identical** to today (fetch unconfined, build confined+offline) | +| §5.2 sandbox_grants | adds `extra_ro`/`extra_env` | n/a (data) | Python unions into its policy | same grants, now wheel-declared | + +No seam gives the *LLM* argv control, and no seam lets the *wheel* invent a sandbox policy. §4 is +a *pure declaration* — the host runs the same shared warm/build helpers it does today, so the +network posture (fetch unconfined, code-executing build confined + offline) is byte-for-byte the +current behavior. Nothing here weakens or "tightens" confinement; it only moves *who declares the +plan* into the wheel. + +--- + +## 8. New surface, summarized + +**Descriptor (`AppDescriptor`, mirrored in [descriptor.py](../composer/rustapp/descriptor.py)):** + +```rust +setup: Option<SetupSpec>, // { phase_key, label, context_key } (§3.2) +deliverable_mode: DeliverableMode, // PerComponent | Callout, default PerComponent (§3.1) +serialize_toolchain: bool, // default false (§3.4) +confine_by_default: bool, // default false (§5.2) +component_noun: Option<String>, // default "component" (§5.3) +``` + +**New callouts on the `Backend` trait (both pure):** + +```rust +fn workspace_prep(&self, input) -> WorkspacePrep { default } // { files, warm_dirs, build_program } (§4) +fn sandbox_grants(&self, args: &serde_json::Value) -> SandboxGrants { default } // { extra_ro, extra_env } (§5.2) +// finalize's input gains artifact_text / property_units / setup (§3.1) — signature unchanged. +``` + +All are **defaulted**, so existing wheels (echoprover) keep working untouched — this is a +backward-compatible extension of the passive-backend API, not a new protocol. + +--- + +## 9. Work breakdown + +1. **Descriptor + defaults** — add the five fields to `AppDescriptor` (Rust + pydantic mirror), + all defaulted; `workspace_prep` / `sandbox_grants` pure trait methods with no-op defaults. + *No behavior change; echoprover + Crucible-via-Python still run.* +2. **Generic host honors them** — setup step, context injection, `serialize_toolchain`, + `callout` deliverable via enriched `finalize`, `workspace_prep` execution (write files → warm + → build), RAG-from-descriptor, `confine_by_default` + grants union, generic verdict summary. + Land behind the descriptor gates so echoprover is unaffected. +3. **Port `crucible_app`** — move `CrucibleHarness`/`CrucibleDep` rendering into `finalize`; + implement `workspace_prep` (harness `Cargo.toml` + warm dirs + program build) and + `sandbox_grants`; add the repo precondition; set the descriptor flags; materialize `Cargo.toml` + per confined run. +4. **Delete `composer/crucible/`** and repoint the console scripts. Update the gate tests + (`test_crucible_*`) to drive the wheel through the generic host. +5. **e2e parity** — run the Vault gate; confirm 16 GOOD at ~parity runtime and byte-identical + deliverable + report. (Posture is unchanged, so no new confinement risk to validate.) + +Steps 1–2 are the reusable investment (they benefit *every* future Rust app); 3–5 are the +Crucible port that proves the seam. + +--- + +## 10. Open questions / decisions to confirm + +1. **`finalize` vs. a dedicated `render_deliverables` callout.** Reusing `finalize` keeps the + surface minimal but overloads one method with "side-effect artifacts" and "the primary + deliverable." A separate `render_deliverables(results) -> {relpath: contents}` is clearer at + the cost of one more callout. *Leaning: reuse `finalize`, revisit if a second app wants both.* +2. **Where the Solana build capability lives.** `workspace_prep`'s `build_program` routes to the + shared `composer.spec.solana.build.build_program` — so the generic host gains one lazy import + of an ecosystem-specific helper. Acceptable (it's shared, not app-specific, and gated on the + plan), but the cleaner long-term home is a `build` capability on the `Ecosystem` itself. + *Leaning: lazy import now; promote to the ecosystem seam if a second ecosystem needs it.* +3. **Per-run `Cargo.toml` materialization vs. a stable on-disk crate.** Materializing the + manifest per confined run (§4) is what removes the feature-race, but it means the on-disk + crate between runs is whatever the last run wrote. Since the *authoritative* crate is produced + at `finalize`, this is fine — but if any tooling expects a stable mid-run crate dir, we'd keep + a single `workspace_prep`-placed manifest with all features (reintroducing a mild coupling). +4. **`--module`-style generic command vs. per-app shims.** Whether `console-crucible` is a 2-line + shim (`console_main("crucible_app")`) or the generic `console_main` takes the module as its + first positional. Shims keep the familiar command names; the generic form is one entry for all + wheels. *Leaning: shims, matching echoprover.* + +--- + +## 11. Key files + +- Wheel: [rust/crucible-app/src/lib.rs](../rust/crucible-app/src/lib.rs), + [rust/autoprover-sdk/src/lib.rs](../rust/autoprover-sdk/src/lib.rs) (trait + descriptor + `run_confined`). +- Generic host (the reuse target): [composer/rustapp/](../composer/rustapp/) — + `descriptor.py`, `adapter.py`, `host.py`, `entry.py`, `cli.py`, `frontend.py`, `store.py`. +- To be deleted: [composer/crucible/](../composer/crucible/) — + `backend.py`, `store.py`, `harness.py`, `pipeline.py`, `results.py`, `cli.py`. +- Shared, staying: [composer/pipeline/ecosystem.py](../composer/pipeline/ecosystem.py) (the + `solana` ecosystem), [composer/spec/solana/](../composer/spec/solana/) (the model), + [composer/sandbox/](../composer/sandbox/) (`SandboxConfig` policy authoring). +</content> +</invoke> diff --git a/pyproject.toml b/pyproject.toml index f8aac9e9..5bc8f443 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,23 @@ ragbuild = [ "markdown-it-py>=3.0", "mdit-py-plugins>=0.4", ] +dev = [ + "maturin>=1.14.1", +] +# The Rust/PyO3 application wheels (built via maturin from the `rust/` workspace), +# declared as editable path deps below so `uv sync --group apps` builds AND +# preserves them — otherwise a bare `uv sync` prunes any hand-built wheel as +# extraneous. Kept OUT of [project.dependencies] and out of the default groups so +# the container image (final stage has no Rust toolchain; syncs with UV_NO_DEV=1 +# --group ragbuild) never tries to compile them. `maturin-import-hook` transparently +# recompiles a changed crate on import, so editing Rust needs no manual `maturin` +# step either — activate it once per venv with: +# python -m maturin_import_hook site install +apps = [ + # crucible_app is added in PR3 (its crate lives in rust/crucible-app, which lands there). + "echoprover", + "maturin-import-hook>=0.3.0", +] [tool.uv] conflicts = [ @@ -102,6 +119,9 @@ conflicts = [ [tool.uv.sources] graphcore = { path = "./graphcore", editable = true } +# Rust/PyO3 wheels built from the local workspace (see the `apps` group above). +# crucible_app's source is added in PR3 (with its crate under rust/crucible-app). +echoprover = { path = "rust/example-app", editable = true } torch = [ { index = "pytorch-cpu", extra = "cpu" }, { index = "pytorch-cu128", extra = "cuda" }, @@ -132,6 +152,8 @@ console-autoprove = "composer.cli.console_autoprove:main" tui-autoprove = "composer.cli.tui_autoprove:main" console-foundry = "composer.cli.console_foundry:main" tui-foundry = "composer.cli.tui_foundry:main" +console-crucible = "composer.crucible_launch:console_crucible" +tui-crucible = "composer.crucible_launch:tui_crucible" autoprove-report-render = "composer.spec.source.report.render:main" tui-natspec = "composer.cli.tui_pipeline:main" cache-natspec = "composer.cli.cache_natspec:main" @@ -147,7 +169,7 @@ certora-fixconf = "certora_autosetup.fixconf:main" autosetup = "certora_autosetup.autosetup.cli:main" fixconf = "certora_autosetup.fixconf:main" -# Command-sandbox providers, discovered by composer.sandbox.SandboxConfig.resolve_provider. +# Command-sandbox providers, resolved by composer.sandbox.config.SandboxConfig.resolve_provider. # Adding a provider = adding a line here (and re-running the editable install so the # dist-info picks it up); the seam never imports a concrete mechanism itself. [project.entry-points."composer.sandbox_providers"] @@ -161,7 +183,7 @@ include = ["composer*", "sanity_analyzer*", "analyzer*", "certora_autosetup*"] "" = "." [tool.setuptools.package-data] -composer = ["templates/*.j2", "scripts/init-db.sql"] +composer = ["templates/*.j2", "templates/**/*.j2", "templates/*.js", "scripts/init-db.sql"] # Bundled CVL summaries, spec templates, mocks and conf templates the prover # reads directly off disk; ship them with the wheel. certora_autosetup = [ diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 00000000..ef6eb6fc --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,6 @@ +/target +**/*.rs.bk +Cargo.lock +# maturin / local build venvs +.venv/ +*.whl diff --git a/rust/Cargo.lock b/rust/Cargo.lock deleted file mode 100644 index e5575252..00000000 --- a/rust/Cargo.lock +++ /dev/null @@ -1,266 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "clap" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "landlock" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" -dependencies = [ - "enumflags2", - "libc", - "thiserror", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "run-confined" -version = "0.1.0" -dependencies = [ - "clap", - "landlock", - "libc", - "seccompiler", -] - -[[package]] -name = "seccompiler" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" -dependencies = [ - "libc", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index be0ad6b3..555cb160 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,21 +1,28 @@ -# Cargo workspace for AutoProver's Rust components. +# Cargo workspace for AutoProver's Rust extension framework. # -# In this PR the workspace contains a single member: +# * `autoprover-sdk` — the library new Rust-based applications import. It defines +# the ABI (serde types), the `Application` / `FormalizeSession` traits, the FFI +# helpers, and the `export_app!` macro that emits the PyO3 module. +# * `example-app` — a self-contained demonstration application built into a +# Python wheel via maturin, used by the framework's round-trip test. # -# * `run-confined` — the trusted command-sandbox launcher (Landlock filesystem + -# seccomp network/ptrace + rlimits + scrubbed env, then `execve`). The first -# `SandboxProvider` for the `RunCommand` effect — see docs/command-sandbox.md. -# -# Later PRs re-add the Rust *application framework* crates to `members`: -# `autoprover-sdk` (the ABI + `export_app!` macro) and `example-app` (its -# round-trip test wheel), then the `crucible-app` wheel. Those crates depend on -# the shared `[workspace.dependencies]` (serde / pyo3); `run-confined` does not, -# so this trimmed workspace declares none. +# A new application is its own crate (cdylib) that depends on `autoprover-sdk` +# and invokes `autoprover_sdk::export_app!`. It lives outside this workspace in +# real use; `example-app` is kept here so the framework has something to test. [workspace] resolver = "2" -members = ["run-confined"] +members = ["autoprover-sdk", "example-app", "run-confined"] [workspace.package] edition = "2021" version = "0.1.0" license = "MIT" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +pyo3 = "0.24" +# Compile-time Jinja-style templates (the `.j2` convention used by composer/templates/*.j2, +# here for the Rust prompt/crate-file templates). Templates are checked at build time and +# embedded in the wheel, so no runtime template loading or packaging. +askama = "0.13" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 00000000..0c1ea876 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,96 @@ +# AutoProver Rust framework + +Build AutoProver formalization backends / applications in Rust and run them +through the generic Python pipeline via PyO3. Design rationale: +[docs/rust-formalization-backends.md](../docs/rust-formalization-backends.md) and +[docs/rust-applications.md](../docs/rust-applications.md). + +## Layout + +| Crate | Role | +| --- | --- | +| [`autoprover-sdk`](autoprover-sdk) | The library a Rust application imports: the ABI (serde types), the `Application` / `FormalizeSession` traits, the FFI helpers, and the `export_app!` macro. | +| [`example-app`](example-app) | The `echoprover` demo — a complete, self-contained application built into a wheel and exercised by `tests/test_rustapp.py`. | + +The Python side is [`composer/rustapp`](../composer/rustapp): it loads a wheel, +synthesizes the pipeline's phase enum from the descriptor, and drives the Rust +decider through the inversion-of-control loop (Python owns every async effect — +LLM, prover, cache, event streaming — Rust only decides the next one). No +`pyo3-async` bridge is involved. + +## The FFI surface + +A wheel exports exactly (all synchronous, JSON strings across the boundary): + +```text +descriptor() -> str # the AppDescriptor +validate_preconditions(args_json) -> str|None +new_session(input_json) -> RustSession # .resume(observation_json) -> command_json +fetch_verdicts(input_json) -> str +finalize(outcomes_json) -> str|None +``` + +`export_app!` generates all of these. + +## Writing a new application + +1. New crate: `cdylib`, depending on `autoprover-sdk` and `pyo3` + (`features = ["extension-module", "abi3-py312"]`). See + [example-app/Cargo.toml](example-app/Cargo.toml). + +2. Implement `Application` (descriptor + `new_session` + `fetch_verdicts`) and a + `FormalizeSession` — a **pure synchronous decider** whose `resume(Observation)` + returns the next `Command` (`CallLlm` / `RunProver` / `CacheGet` / `Emit` / … + / `Publish` / `GiveUp`). See [example-app/src/lib.rs](example-app/src/lib.rs). + +3. Export the module (ident must match the wheel/module name): + + ```rust + autoprover_sdk::export_app!(my_app, MyApp); + ``` + +4. Add a maturin `pyproject.toml` (`module-name = "my_app"`), then build: + + ```sh + cd my-app && maturin develop # or: maturin build --out dist + ``` + +5. Ship a CLI. The generic entry point + frontend are synthesized from the + descriptor — a runnable app is a two-line `main()`: + + ```python + # my_app_cli.py + from composer.rustapp.cli import tui_main, console_main + def main() -> int: return tui_main("my_app") # Textual TUI + def main_console() -> int: return console_main("my_app") # stdout + ``` + + Register them in `pyproject.toml` under `[project.scripts]`. No bespoke + argparse, entry point, frontend, or `main()` to write — the descriptor drives + all of it (CLI flags, precondition validation, phase labels, event rendering, + artifact layout). + + For programmatic / headless use, the pipeline wrapper is also exposed directly: + + ```python + from composer.rustapp import run_rust_pipeline + result = await run_rust_pipeline("my_app", source_input, ctx, handler_factory, env) + ``` + +## Building & testing the demo + +```sh +cd rust/example-app && maturin develop # builds & installs the `echoprover` wheel +cd ../.. && python -m pytest tests/test_rustapp.py +``` + +## Notes + +* `FormalizeSession` is `Send + Sync` because PyO3 wraps it in a `#[pyclass]`; a + state machine over plain owned data satisfies this without effort. +* Keep effects coarse-grained — one `resume` per turn / tool-call, never per + token. +* A self-contained (Tier-1) backend that does verification inside Rust simply + never emits `RunProver`/`RunFeedback`; a run-service-backed one surfaces those + as effects and the deployment supplies the `prover=` / `feedback=` hooks to + `RustFormalizer` (see `composer/rustapp/adapter.py`). diff --git a/rust/autoprover-sdk/Cargo.toml b/rust/autoprover-sdk/Cargo.toml new file mode 100644 index 00000000..9153028b --- /dev/null +++ b/rust/autoprover-sdk/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "autoprover-sdk" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "SDK for building AutoProver formalization backends / applications in Rust, callable from the Python pipeline via PyO3." + +[lib] +name = "autoprover_sdk" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +# Re-exported (see `pub use pyo3`) so the `export_app!` macro can reference +# `$crate::pyo3::…` and the app crate needs no direct dependency edge for the +# macro body. The app crate still lists pyo3 to enable `extension-module` / +# `abi3-py312`; cargo feature-unifies the two. +pyo3 = { workspace = true } diff --git a/rust/autoprover-sdk/src/lib.rs b/rust/autoprover-sdk/src/lib.rs new file mode 100644 index 00000000..0851030a --- /dev/null +++ b/rust/autoprover-sdk/src/lib.rs @@ -0,0 +1,840 @@ +//! # autoprover-sdk +//! +//! The library a Rust-based AutoProver application imports. It defines the seam +//! between a Rust backend and the generic Python pipeline +//! (`composer/pipeline/core.py`), realized over a **synchronous, JSON** FFI +//! boundary — the service-shaped design in `docs/rust-backend-api.md`. +//! +//! The backend is a **passive service**, not a driver: the Python pipeline owns the +//! author→compile→judge→validate loop and every LLM turn, and calls the backend's +//! callouts. Most are pure ([`Backend::descriptor`], [`Backend::units`], +//! [`Backend::author_prompt`], [`Backend::judge_prompt`], [`Backend::finalize`]). The +//! two gating callouts ([`Backend::compile`], [`Backend::validate`]) run the toolchain +//! directly — each spawns the `run-confined` launcher via [`run_confined`] — and BLOCK; +//! the host calls them off the event loop (`asyncio.to_thread`) while the wheel releases +//! the GIL. There is no `async`/`pyo3-async` bridge and no `Command`/`Observation` resume +//! protocol on the Rust side. +//! +//! An application implements [`Backend`] and calls [`export_app!`] to emit the PyO3 +//! module the Python host loads. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Re-exported so [`export_app!`] can reference `$crate::pyo3::…`; an app crate +/// still depends on pyo3 directly to enable `extension-module` / `abi3-py312`. +pub use pyo3; + +// =========================================================================== +// Descriptor — the declarative spine the Python host consumes to synthesize the +// phase enum, argparse, frontend and artifact store (see rust-applications.md). +// =========================================================================== + +/// Which of the four driver-tagged core phases a declared phase fills. A phase +/// with no core slot is a UI-only phase (cf. autoprove's harness/autosetup). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CoreSlot { + Analysis, + Extraction, + Formalization, + Report, +} + +/// One task-grouping phase. `key` becomes the synthesized `enum.Enum` member +/// name; `label`/`order` drive UI grouping. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhaseSpec { + pub key: String, + pub label: String, + pub order: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub core_slot: Option<CoreSlot>, +} + +/// Default value for a declared CLI argument. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ArgDefault { + Str { value: Option<String> }, + Int { value: Option<i64> }, + Bool { value: bool }, +} + +/// A CLI flag the generic entry point adds beyond the three positional inputs +/// (`project_root`, `main_contract`, `system_doc`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArgSpec { + pub flag: String, + pub help: String, + pub default: ArgDefault, + #[serde(default)] + pub required: bool, +} + +/// A domain event kind the frontend should render (see `Command::Emit`). +/// +/// A `notice` kind is surfaced as a persistent, always-visible callout (plus a toast) +/// rather than a line in the collapsible per-task events log — for one-shot important +/// results such as a per-unit verdict. Ordinary kinds stream into the log. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventKind { + pub kind: String, + pub label: String, + #[serde(default)] + pub notice: bool, +} + +impl EventKind { + /// A streaming event kind — rendered as a line in the collapsible events log. + pub fn log(kind: impl Into<String>, label: impl Into<String>) -> Self { + Self { kind: kind.into(), label: label.into(), notice: false } + } + + /// A notice event kind — surfaced as a persistent callout + toast. + pub fn notice(kind: impl Into<String>, label: impl Into<String>) -> Self { + Self { kind: kind.into(), label: label.into(), notice: true } + } +} + +/// On-disk deliverable layout. All paths are project-root-relative. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactLayout { + pub deliverable_dir: String, + pub internal_dir: String, + pub report_dir: String, + /// Where the verification artifacts themselves are written. + pub artifact_dir: String, + /// Filename prefix for a per-component artifact (e.g. `autospec` → `autospec_<slug>.spec`). + pub artifact_prefix: String, + /// Artifact file extension, no dot (e.g. `spec`, `t.sol`). + pub artifact_extension: String, + /// The store's term for the property→units map file suffix (`property_rules`, `property_tests`). + pub property_suffix: String, + /// Under `DeliverableMode::Callout`, the project-relative path of the primary deliverable + /// file, `{program}`-templated (Crucible: `fuzz/{program}/src/main.rs`). Used only as each + /// component's report link — the actual files come from `finalize`. Ignored `PerComponent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deliverable_primary: Option<String>, +} + +/// A shared "setup" artifact authored once, before per-component formalization (Crucible's +/// shared fixture). When a descriptor carries one, the host runs the author→compile loop for a +/// `kind="setup"` [`AuthorInput`] under `phase_key`, then threads the compiled spec into every +/// component's `AuthorInput.context` under `context_key`. Absent → no setup step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetupSpec { + /// The descriptor phase key the setup task is grouped under (a UI-only phase). + pub phase_key: String, + /// The task label shown in the frontend. + pub label: String, + /// The `AuthorInput.context` key the compiled setup spec is injected under for components. + pub context_key: String, +} + +/// How the source deliverable is written to disk. `PerComponent` (the default): the generic +/// store writes one `{prefix}_{slug}.{ext}` file per component from its `artifact_text`. +/// `Callout`: the store writes no per-component source; the wheel's `finalize` renders the whole +/// deliverable (e.g. Crucible's one shared crate assembled from all sections + the fixture). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeliverableMode { + #[default] + PerComponent, + Callout, +} + +/// The complete declaration the Python host reads once at load time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppDescriptor { + pub name: String, + pub header_text: String, + /// The ecosystem (chain) tag: "evm" | "solana" | "soroban". Selects the shared front + /// half's system model + prompts; the Python host resolves it against its ecosystem + /// registry. Defaults to "evm" so a descriptor built before this field existed still loads. + #[serde(default = "default_ecosystem")] + pub ecosystem: String, + /// The report's backend tag (`AutoProverReport.backend`). + pub backend_tag: String, + /// Prose injected into the property-extraction prompt (verification-surface guidance). + pub backend_guidance: String, + /// The system-analysis cache key (`SystemAnalysisSpec.analysis_key`). + pub analysis_key: String, + pub phases: Vec<PhaseSpec>, + #[serde(default)] + pub args: Vec<ArgSpec>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rag_db_default: Option<String>, + #[serde(default)] + pub event_kinds: Vec<EventKind>, + pub artifact_layout: ArtifactLayout, + /// Optional shared-setup step run before per-component formalization (see [`SetupSpec`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub setup: Option<SetupSpec>, + /// How the source deliverable is written (see [`DeliverableMode`]). + #[serde(default)] + pub deliverable_mode: DeliverableMode, + /// Serialize the blocking toolchain callouts (`prepare_workspace`/`compile`/`validate`) on + /// one semaphore — set when the app shares a single build dir / target across units. + #[serde(default)] + pub serialize_toolchain: bool, + /// Default to the fail-closed `launcher` sandbox provider (still overridable by + /// `COMPOSER_SANDBOX_PROVIDER`). Set by any wheel that runs untrusted native toolchains. + #[serde(default)] + pub confine_by_default: bool, + /// Human noun for one formalized unit in the console/TUI summary ("instruction" for + /// Crucible). Defaults to "component". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub component_noun: Option<String>, +} + +fn default_ecosystem() -> String { + "evm".to_string() +} + +// =========================================================================== +// The service API — the data crossing the FFI. The backend is PASSIVE: the Python +// pipeline drives the author→compile→judge→validate loop and calls these callouts; +// nothing here holds state across calls (see docs/rust-backend-api.md). +// =========================================================================== + +/// One property to formalize (mirrors `composer.spec.types.PropertyFormulation`), plus a +/// host-assigned unique `slug` used to name its unit/artifact (Crucible: `c_<slug>`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Property { + pub title: String, + /// One of "attack_vector" | "safety_property" | "invariant". + pub sort: String, + pub description: String, + #[serde(default)] + pub slug: String, +} + +/// The input to the authoring/gating callouts for one artifact. `kind` selects what is being +/// authored ("setup" fixture vs "component" tests); `context` carries backend dependencies +/// (e.g. the shared fixture source a component builds on). `component`/`context` are opaque +/// JSON the backend interprets. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorInput { + pub kind: String, + pub program: String, + #[serde(default)] + pub component: serde_json::Value, + #[serde(default)] + pub props: Vec<Property>, + #[serde(default)] + pub context: serde_json::Value, +} + +/// An authoring instruction (+ optional backend-defined system prompt) for one LLM turn. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Prompt { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system: Option<String>, + pub instruction: String, +} + +/// Whether a draft was rejected by the toolchain or by the optional LLM judge — so a +/// re-author can frame the retry correctly (a judge rejection is NOT a build failure: the +/// draft compiled). Defaults to `Compile` for backends/hosts that predate the field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailureKind { + #[default] + Compile, + Judge, +} + +/// Why a draft was rejected — the failing `draft` plus the compiler errors / judge feedback +/// — fed into the next `author_prompt` as revise context. `draft` is carried because each +/// authoring turn is fresh (no LLM-side memory of the prior attempt). `kind` says which gate +/// rejected it so the revise prompt can distinguish compiler errors from review feedback. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Failure { + #[serde(default)] + pub draft: String, + pub errors: String, + #[serde(default)] + pub kind: FailureKind, +} + +/// The outcome of `compile`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum CompileResult { + Ok, + Failed { errors: String }, +} + +/// One report row: a property title and its backend-specific unit name (the rule the report keys +/// by). `target` is the *validation target the host runs* — several report units may share one +/// target (e.g. Crucible puts every invariant in one `c_invariants` target), so the host runs the +/// target once and the backend attributes the outcome back to each unit. `None` ⇒ the unit is its +/// own target (one run per unit, the default). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Unit { + pub property: String, + pub unit: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option<String>, +} + +impl Unit { + /// The validation target this unit is checked by — its own `unit` name unless it shares a + /// target with others. + pub fn target_or_unit(&self) -> &str { + self.target.as_deref().unwrap_or(&self.unit) + } +} + +/// A pure plan for preparing the workspace before formalization (Crucible: place the harness +/// `Cargo.toml`, warm its deps, build the program `.so`). The wheel *declares* the plan; the +/// **host executes it with the shared toolchain helpers**, so the standard network posture holds +/// without the wheel touching a command line: dependency fetches run *unconfined* (network, no +/// untrusted code), and the code-executing build runs *confined + offline* +/// (`docs/command-sandbox.md` §5). This keeps warming out of confinement — the codebase never +/// gives a confined process network — while still letting a pure-Rust app own its layout. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkspacePrep { + /// Files to write under the workdir (path-confined) before warming — e.g. the harness + /// `Cargo.toml` (whose deps only the wheel knows). Contents only; no command line. + #[serde(default)] + pub files: BTreeMap<String, String>, + /// Project-relative manifest dirs to `cargo fetch` (unconfined, network) so a later + /// confined + offline build finds every dep warm in the private `CARGO_HOME`. + #[serde(default)] + pub warm_dirs: Vec<String>, + /// If set, build this program to its platform binary before formalization, via the host's + /// shared build capability (Solana: `cargo-build-sbf` → `target/deploy/<program>.so`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_program: Option<String>, +} + +/// Extra sandbox grants a wheel needs unioned into the host-authored policy (Crucible: the +/// crucible checkout + the `crucible` binary dir as read-only). Pure data — the wheel declares +/// grants, Python decides the policy; the wheel never invents confinement. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SandboxGrants { + /// Extra read-only paths. + #[serde(default)] + pub extra_ro: Vec<String>, + /// Extra `NAME=VALUE` env entries to pass through confinement. + #[serde(default)] + pub extra_env: Vec<String>, +} + +/// The result of `validate` — the fused build+check for one validation **target**. Either the +/// build failed (so the whole spec must be re-authored — the build is shared), or it built and +/// produced a `Verdict` **per report unit the target covers** (`(unit, verdict)`). A target may +/// cover several units (e.g. Crucible runs every invariant in one target), and the backend — which +/// owns its own result/failure format — attributes the run to those units; the host records the +/// verdicts verbatim (it does no verdict logic). Fusing the build gate into `validate` (rather than +/// a separate `compile` dry-run) is the component path's efficiency win (docs/rust-backend-api.md). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ValidateOutcome { + BuildFailed { errors: String }, + Verdicts { verdicts: Vec<(String, Verdict)> }, +} + +/// A per-unit outcome (mirrors `composer…report.collect.Verdict`). `outcome` is one of +/// GOOD | BAD | ERROR | TIMEOUT | UNKNOWN. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Verdict { + pub outcome: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option<u32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_seconds: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit_file: Option<String>, + /// Human-readable explanation of a non-GOOD outcome — the failure detail (a counterexample / + /// assertion message) for a `BAD`, or the error text for an `ERROR`. Surfaced live and persisted + /// to the report so a verdict is self-explaining (otherwise a bare `BAD` gives no clue why). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option<String>, +} + +impl Verdict { + pub fn with_outcome(outcome: impl Into<String>) -> Self { + Verdict { + outcome: outcome.into(), line: None, duration_seconds: None, unit_file: None, + detail: None, + } + } +} + +// =========================================================================== +// Sandbox — the confinement wrapper (Python-authored) + the shared launcher helper. +// =========================================================================== + +/// The confinement wrapper for a command, authored by Python +/// (`SandboxConfig.backend_spec`) and passed to `compile`/`validate`. The backend never +/// invents policy or names a sandbox mechanism: Python owns the confinement *intent* and +/// translates it into `argv_prefix`, an opaque argv the backend simply prepends to its +/// command — `[*argv_prefix, program, *args]` (see [`run_confined`]). +/// +/// `argv_prefix` is **empty** for a passthrough (`provider="none"`) spec — the command runs +/// directly (the trusted path). Otherwise it is a full `run-confined <flags…> --` wrapper +/// (mirrors `composer/sandbox/launcher.py::LauncherProvider.argv_prefix`); its first element +/// is the launcher binary. Because the prefix is opaque, swapping the sandbox mechanism never +/// changes this shape. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Sandbox { + #[serde(default)] + pub argv_prefix: Vec<String>, + #[serde(default = "default_timeout")] + pub timeout_s: u64, +} + +fn default_timeout() -> u64 { + 600 +} + +/// The captured result of a (confined) command. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +/// Exit code synthesized when the program isn't found (mirrors shells' 127). +const NOT_FOUND_EXIT: i32 = 127; + +/// Reject absolute paths / `..` traversal (mirrors `composer.sandbox.command._confined_target`). +fn confined_join(workdir: &std::path::Path, rel: &str) -> Result<std::path::PathBuf, String> { + use std::path::{Component, Path}; + let p = Path::new(rel); + if p.is_absolute() || p.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(format!("unsafe file path {rel:?}: absolute or traverses outside the workdir")); + } + Ok(workdir.join(p)) +} + +/// Materialize `files` into `workdir` (path-confined), then run `program args` there behind +/// `sandbox.argv_prefix` — i.e. `[*argv_prefix, program, *args]` (or `program args` directly, +/// when `argv_prefix` is empty). Blocks on the child; **call from within +/// `Python::allow_threads`**. Enforces `sandbox.timeout_s`. +/// +/// The **command line (`program`/`args`) is authored by the trusted backend**; only file +/// *contents* may derive from the LLM (`docs/command-sandbox.md` §2). When present, the prefix's +/// `run-confined` confines *itself* (Landlock+seccomp+rlimits+env scrub) and `execve`s the tool. +pub fn run_confined( + sandbox: &Sandbox, + program: &str, + args: &[String], + files: &BTreeMap<String, String>, + workdir: &std::path::Path, +) -> Result<CommandOutput, String> { + use std::io::Read; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + // 1. Materialize untrusted files (contents may be LLM-derived; the command line is not). + std::fs::create_dir_all(workdir).map_err(|e| e.to_string())?; + for (rel, contents) in files { + let target = confined_join(workdir, rel)?; + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&target, contents).map_err(|e| e.to_string())?; + } + + // 2. Build argv by prepending the (opaque, Python-authored) confinement prefix: + // `[*argv_prefix, program, *args]`. When the prefix is empty this is `program args` + // run directly; otherwise its first element is the launcher binary to spawn. + let (launch, mut launch_args): (&str, Vec<String>) = match sandbox.argv_prefix.split_first() { + Some((bin, rest)) => (bin, rest.to_vec()), + None => (program, Vec::new()), + }; + if !sandbox.argv_prefix.is_empty() { + // The prefix ends at its `--`; the wrapped command follows it. + launch_args.push(program.to_string()); + } + launch_args.extend_from_slice(args); + let mut cmd = Command::new(launch); + cmd.args(&launch_args); + cmd.current_dir(workdir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // 3. Spawn + capture with a timeout. Reader threads avoid a pipe-buffer deadlock. + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(CommandOutput { + exit_code: NOT_FOUND_EXIT, + stdout: String::new(), + stderr: format!("{launch}: not found"), + }) + } + Err(e) => return Err(e.to_string()), + }; + let mut out = child.stdout.take().expect("piped stdout"); + let mut err = child.stderr.take().expect("piped stderr"); + let t_out = std::thread::spawn(move || { + let mut s = Vec::new(); + let _ = out.read_to_end(&mut s); + s + }); + let t_err = std::thread::spawn(move || { + let mut s = Vec::new(); + let _ = err.read_to_end(&mut s); + s + }); + + let deadline = Instant::now() + Duration::from_secs(sandbox.timeout_s.max(1)); + let mut timed_out = false; + let status = loop { + match child.try_wait().map_err(|e| e.to_string())? { + Some(st) => break Some(st), + None => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + timed_out = true; + break None; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + }; + let stdout = String::from_utf8_lossy(&t_out.join().unwrap_or_default()).into_owned(); + let stderr = String::from_utf8_lossy(&t_err.join().unwrap_or_default()).into_owned(); + if timed_out { + return Ok(CommandOutput { + exit_code: -1, + stdout, + stderr: format!("{stderr}\ncommand timed out after {}s", sandbox.timeout_s), + }); + } + Ok(CommandOutput { + exit_code: status.and_then(|s| s.code()).unwrap_or(-1), + stdout, + stderr, + }) +} + +// =========================================================================== +// The trait an application implements. +// =========================================================================== + +/// A Rust AutoProver backend — a **passive service** the Python pipeline drives. One instance +/// per wheel; construct it in [`export_app!`]. Metadata/authoring callouts are pure; `compile` +/// and `validate` run the toolchain (via [`run_confined`]) and BLOCK — the host calls them off +/// the event loop (`asyncio.to_thread`) while the wheel releases the GIL. +pub trait Backend: Send + Sync + 'static { + /// The declaration the Python host reads at load time. + fn descriptor(&self) -> AppDescriptor; + + /// Validate application-specific preconditions before any service opens. `Err(msg)` aborts. + fn validate_preconditions(&self, _args: &serde_json::Value) -> Result<(), String> { + Ok(()) + } + + /// The units this input formalizes — one per property — each a property title and its + /// unit name (Crucible: `c_<slug>`). Pure and pre-authoring: the prompt requires exactly + /// these fn names, the host validates each, and it is the report's property→unit map. + fn units(&self, input: &AuthorInput) -> Vec<Unit>; + + /// The instruction (+ optional system prompt) to author `input.kind`'s spec, covering all + /// its units. `failure = Some(..)` on a re-author after a compile failure / judge rejection. + fn author_prompt(&self, input: &AuthorInput, failure: Option<&Failure>) -> Prompt; + + /// Optional LLM review of a compiled spec, before validation. `None` (the default) skips + /// judging — the compiler + checker are the judges. + fn judge_prompt(&self, _input: &AuthorInput, _spec: &str) -> Option<Prompt> { + None + } + + /// Compile/typecheck the whole spec once (all units share one build). BLOCKING. + fn compile( + &self, + input: &AuthorInput, + spec: &str, + workdir: &std::path::Path, + sandbox: &Sandbox, + ) -> CompileResult; + + /// Build + check ONE unit against the spec (the fused build gate — no separate compile for + /// components). Returns [`ValidateOutcome::BuildFailed`] to trigger a re-author of the whole + /// spec (the build is shared across units), or a per-unit [`Verdict`]. Per-unit so the host + /// owns enumeration/scheduling. BLOCKING. + fn validate( + &self, + input: &AuthorInput, + spec: &str, + unit: &str, + workdir: &std::path::Path, + sandbox: &Sandbox, + ) -> ValidateOutcome; + + /// Extra sandbox grants to union into the host's policy (see [`SandboxGrants`]). Pure; called + /// once before any confined step. Default: no extra grants. + fn sandbox_grants(&self, _args: &serde_json::Value) -> SandboxGrants { + SandboxGrants::default() + } + + /// Declare the pre-formalization workspace prep (see [`WorkspacePrep`]). Pure — the host + /// executes the returned plan with the shared warm/build helpers, so the network posture + /// stays Python-owned. Default: nothing to prepare. + fn workspace_prep(&self, _input: &AuthorInput) -> WorkspacePrep { + WorkspacePrep::default() + } + + /// Optional run-level artifacts from the full outcome set, as `{relpath: contents}`. + /// + /// Under [`DeliverableMode::Callout`] this renders the whole source deliverable (Crucible's + /// one crate); the host enriches the outcome set with each component's `artifact_text` / + /// `property_units` and the `setup` result so the wheel has everything the deliverable needs. + fn finalize(&self, _outcomes: &serde_json::Value) -> BTreeMap<String, String> { + BTreeMap::new() + } +} + +// =========================================================================== +// FFI helpers — the sync, JSON-string boundary. `export_app!` wraps these in +// #[pyfunction]s (compile/validate release the GIL); also unit-testable without Python. +// =========================================================================== + +fn parse<T: serde::de::DeserializeOwned>(json: &str, what: &str) -> Result<T, String> { + serde_json::from_str(json).map_err(|e| format!("invalid {what} JSON: {e}")) +} + +/// `descriptor() -> str` (JSON). +pub fn ffi_descriptor(b: &dyn Backend) -> String { + serde_json::to_string(&b.descriptor()) + .unwrap_or_else(|e| format!("{{\"error\":\"descriptor serialize: {e}\"}}")) +} + +/// `validate_preconditions(args_json) -> str | None` (None = ok). +pub fn ffi_validate_preconditions(b: &dyn Backend, args_json: &str) -> Option<String> { + let args: serde_json::Value = serde_json::from_str(args_json).unwrap_or(serde_json::Value::Null); + b.validate_preconditions(&args).err() +} + +/// `units(input_json) -> str` (JSON `[Unit]`). +pub fn ffi_units(b: &dyn Backend, input_json: &str) -> String { + match parse::<AuthorInput>(input_json, "AuthorInput") { + Ok(input) => serde_json::to_string(&b.units(&input)).unwrap_or_else(|_| "[]".into()), + Err(_) => "[]".into(), + } +} + +/// `author_prompt(input_json, failure_json | None) -> str` (JSON `Prompt`). +pub fn ffi_author_prompt(b: &dyn Backend, input_json: &str, failure_json: Option<&str>) -> String { + let input: AuthorInput = match parse(input_json, "AuthorInput") { + Ok(v) => v, + Err(e) => { + return serde_json::to_string(&Prompt { system: None, instruction: format!("ERROR: {e}") }) + .unwrap_or_default() + } + }; + let failure: Option<Failure> = failure_json.and_then(|s| serde_json::from_str(s).ok()); + let prompt = b.author_prompt(&input, failure.as_ref()); + serde_json::to_string(&prompt).unwrap_or_default() +} + +/// `judge_prompt(input_json, spec) -> str | None` (None = skip judging). +pub fn ffi_judge_prompt(b: &dyn Backend, input_json: &str, spec: &str) -> Option<String> { + let input: AuthorInput = parse(input_json, "AuthorInput").ok()?; + b.judge_prompt(&input, spec) + .map(|p| serde_json::to_string(&p).unwrap_or_default()) +} + +/// `compile(input_json, spec, workdir, sandbox_json) -> str` (JSON `CompileResult`). BLOCKING. +pub fn ffi_compile( + b: &dyn Backend, + input_json: &str, + spec: &str, + workdir: &str, + sandbox_json: &str, +) -> String { + let input: AuthorInput = match parse(input_json, "AuthorInput") { + Ok(v) => v, + Err(e) => return serde_json::to_string(&CompileResult::Failed { errors: e }).unwrap_or_default(), + }; + let sandbox: Sandbox = parse(sandbox_json, "Sandbox").unwrap_or_default(); + let r = b.compile(&input, spec, std::path::Path::new(workdir), &sandbox); + serde_json::to_string(&r).unwrap_or_else(|e| { + serde_json::to_string(&CompileResult::Failed { errors: e.to_string() }).unwrap_or_default() + }) +} + +/// `validate(input_json, spec, unit, workdir, sandbox_json) -> str` (JSON `ValidateOutcome`). BLOCKING. +pub fn ffi_validate( + b: &dyn Backend, + input_json: &str, + spec: &str, + unit: &str, + workdir: &str, + sandbox_json: &str, +) -> String { + let sandbox: Sandbox = parse(sandbox_json, "Sandbox").unwrap_or_default(); + let outcome = match parse::<AuthorInput>(input_json, "AuthorInput") { + Ok(input) => b.validate(&input, spec, unit, std::path::Path::new(workdir), &sandbox), + Err(e) => { + let mut v = Verdict::with_outcome("ERROR"); + v.detail = Some(e); + ValidateOutcome::Verdicts { verdicts: vec![(unit.to_string(), v)] } + } + }; + serde_json::to_string(&outcome).unwrap_or_default() +} + +/// `sandbox_grants(args_json) -> str` (JSON `SandboxGrants`). +pub fn ffi_sandbox_grants(b: &dyn Backend, args_json: &str) -> String { + let args: serde_json::Value = serde_json::from_str(args_json).unwrap_or(serde_json::Value::Null); + serde_json::to_string(&b.sandbox_grants(&args)).unwrap_or_else(|_| "{}".into()) +} + +/// `workspace_prep(input_json) -> str` (JSON `WorkspacePrep`). Pure. +pub fn ffi_workspace_prep(b: &dyn Backend, input_json: &str) -> String { + match parse::<AuthorInput>(input_json, "AuthorInput") { + Ok(input) => serde_json::to_string(&b.workspace_prep(&input)).unwrap_or_else(|_| "{}".into()), + Err(_) => "{}".into(), + } +} + +/// `finalize(outcomes_json) -> str | None` (JSON `{relpath: contents}`, or None). +pub fn ffi_finalize(b: &dyn Backend, outcomes_json: &str) -> Option<String> { + let outcomes: serde_json::Value = serde_json::from_str(outcomes_json).ok()?; + let files = b.finalize(&outcomes); + if files.is_empty() { + None + } else { + serde_json::to_string(&files).ok() + } +} + +// =========================================================================== +// The export macro. +// =========================================================================== + +/// Emit the PyO3 module the Python host loads. Invoke it once in an application crate +/// (a `cdylib` depending on `autoprover-sdk` and `pyo3`): +/// +/// ```ignore +/// autoprover_sdk::export_app!(my_app, MyApp::new()); +/// ``` +/// +/// `module_ident` MUST match the wheel's module name. The expansion defines the pure callouts +/// (`descriptor`/`validate_preconditions`/`units`/`author_prompt`/`judge_prompt`/`finalize`) and +/// the two BLOCKING ones (`compile`/`validate`, which release the GIL while `run-confined` runs), +/// all delegating to the `ffi_*` helpers. +#[macro_export] +macro_rules! export_app { + ($module:ident, $ctor:expr) => { + fn __autoprover_app() -> &'static dyn $crate::Backend { + static APP: ::std::sync::OnceLock<::std::boxed::Box<dyn $crate::Backend>> = + ::std::sync::OnceLock::new(); + &**APP.get_or_init(|| ::std::boxed::Box::new($ctor)) + } + + #[$crate::pyo3::pyfunction] + fn descriptor() -> ::std::string::String { + $crate::ffi_descriptor(__autoprover_app()) + } + + #[$crate::pyo3::pyfunction] + fn validate_preconditions( + args_json: ::std::string::String, + ) -> ::std::option::Option<::std::string::String> { + $crate::ffi_validate_preconditions(__autoprover_app(), &args_json) + } + + #[$crate::pyo3::pyfunction] + fn units(input_json: ::std::string::String) -> ::std::string::String { + $crate::ffi_units(__autoprover_app(), &input_json) + } + + #[$crate::pyo3::pyfunction] + #[pyo3(signature = (input_json, failure_json=None))] + fn author_prompt( + input_json: ::std::string::String, + failure_json: ::std::option::Option<::std::string::String>, + ) -> ::std::string::String { + $crate::ffi_author_prompt(__autoprover_app(), &input_json, failure_json.as_deref()) + } + + #[$crate::pyo3::pyfunction] + fn judge_prompt( + input_json: ::std::string::String, + spec: ::std::string::String, + ) -> ::std::option::Option<::std::string::String> { + $crate::ffi_judge_prompt(__autoprover_app(), &input_json, &spec) + } + + #[$crate::pyo3::pyfunction] + fn compile( + py: $crate::pyo3::Python<'_>, + input_json: ::std::string::String, + spec: ::std::string::String, + workdir: ::std::string::String, + sandbox_json: ::std::string::String, + ) -> ::std::string::String { + // Release the GIL for the (minutes-long) build — no async runtime needed. + py.allow_threads(move || { + $crate::ffi_compile(__autoprover_app(), &input_json, &spec, &workdir, &sandbox_json) + }) + } + + #[$crate::pyo3::pyfunction] + fn validate( + py: $crate::pyo3::Python<'_>, + input_json: ::std::string::String, + spec: ::std::string::String, + unit: ::std::string::String, + workdir: ::std::string::String, + sandbox_json: ::std::string::String, + ) -> ::std::string::String { + py.allow_threads(move || { + $crate::ffi_validate( + __autoprover_app(), + &input_json, + &spec, + &unit, + &workdir, + &sandbox_json, + ) + }) + } + + #[$crate::pyo3::pyfunction] + fn sandbox_grants(args_json: ::std::string::String) -> ::std::string::String { + $crate::ffi_sandbox_grants(__autoprover_app(), &args_json) + } + + #[$crate::pyo3::pyfunction] + fn workspace_prep(input_json: ::std::string::String) -> ::std::string::String { + $crate::ffi_workspace_prep(__autoprover_app(), &input_json) + } + + #[$crate::pyo3::pyfunction] + fn finalize( + outcomes_json: ::std::string::String, + ) -> ::std::option::Option<::std::string::String> { + $crate::ffi_finalize(__autoprover_app(), &outcomes_json) + } + + #[$crate::pyo3::pymodule] + fn $module( + m: &$crate::pyo3::Bound<'_, $crate::pyo3::types::PyModule>, + ) -> $crate::pyo3::PyResult<()> { + use $crate::pyo3::types::PyModuleMethods as _; + m.add_function($crate::pyo3::wrap_pyfunction!(descriptor, m)?)?; + m.add_function($crate::pyo3::wrap_pyfunction!(validate_preconditions, m)?)?; + m.add_function($crate::pyo3::wrap_pyfunction!(units, m)?)?; + m.add_function($crate::pyo3::wrap_pyfunction!(author_prompt, m)?)?; + m.add_function($crate::pyo3::wrap_pyfunction!(judge_prompt, m)?)?; + m.add_function($crate::pyo3::wrap_pyfunction!(compile, m)?)?; + m.add_function($crate::pyo3::wrap_pyfunction!(validate, m)?)?; + m.add_function($crate::pyo3::wrap_pyfunction!(sandbox_grants, m)?)?; + m.add_function($crate::pyo3::wrap_pyfunction!(workspace_prep, m)?)?; + m.add_function($crate::pyo3::wrap_pyfunction!(finalize, m)?)?; + ::std::result::Result::Ok(()) + } + }; +} diff --git a/rust/example-app/Cargo.toml b/rust/example-app/Cargo.toml new file mode 100644 index 00000000..f217c196 --- /dev/null +++ b/rust/example-app/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "example-app" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "Demonstration AutoProver application in Rust (the 'echo prover'), built into a Python wheel." + +[lib] +# MUST match the `export_app!` module ident and the maturin module name. +name = "echoprover" +crate-type = ["cdylib"] + +[dependencies] +autoprover-sdk = { path = "../autoprover-sdk" } +serde = { workspace = true } +serde_json = { workspace = true } +# `extension-module` (don't link libpython) + `abi3-py312` (one wheel for cp312+). +pyo3 = { workspace = true, features = ["extension-module", "abi3-py312"] } diff --git a/rust/example-app/pyproject.toml b/rust/example-app/pyproject.toml new file mode 100644 index 00000000..301f03c4 --- /dev/null +++ b/rust/example-app/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.5,<2.0"] +build-backend = "maturin" + +[project] +name = "echoprover" +version = "0.1.0" +description = "Demonstration AutoProver application implemented in Rust." +requires-python = ">=3.12" +classifiers = ["Programming Language :: Rust"] + +[tool.maturin] +# The compiled module is imported as `echoprover` (matches `export_app!(echoprover, …)`). +module-name = "echoprover" +# abi3 wheel — one artifact for cp312+. +features = ["pyo3/extension-module"] diff --git a/rust/example-app/src/lib.rs b/rust/example-app/src/lib.rs new file mode 100644 index 00000000..5483f567 --- /dev/null +++ b/rust/example-app/src/lib.rs @@ -0,0 +1,113 @@ +//! The "echo prover" — a minimal, self-contained demonstration of a Rust-based +//! AutoProver [`Backend`] on `autoprover-sdk`. It is intentionally not a real +//! verifier: it authors a "spec" from an LLM turn, treats compilation as a no-op, +//! and validates every unit as GOOD — enough to exercise the Python host + FFI +//! round-trip (descriptor, units, author_prompt, compile, validate) without any real +//! toolchain. A production backend keeps this exact shape and swaps the callouts for +//! real ones (see `docs/rust-backend-api.md`). + +use std::path::Path; + +use autoprover_sdk::{ + AppDescriptor, ArtifactLayout, AuthorInput, Backend, CompileResult, CoreSlot, EventKind, + Failure, PhaseSpec, Prompt, Sandbox, Unit, ValidateOutcome, Verdict, +}; + +struct EchoApp; + +impl Backend for EchoApp { + fn descriptor(&self) -> AppDescriptor { + AppDescriptor { + name: "echoprover".to_string(), + header_text: "Echo Prover (Rust demo) | AutoProver".to_string(), + ecosystem: "evm".to_string(), + backend_tag: "echoprover".to_string(), + backend_guidance: "These properties are checked by the echo backend, a demo that \ + accepts any well-formed spec. Feel free to state universal properties." + .to_string(), + analysis_key: "echoprover-analysis".to_string(), + phases: vec![ + PhaseSpec { key: "analysis".into(), label: "System Analysis".into(), order: 0, core_slot: Some(CoreSlot::Analysis) }, + PhaseSpec { key: "extraction".into(), label: "Property Extraction".into(), order: 1, core_slot: Some(CoreSlot::Extraction) }, + // A UI-only phase with no core slot (cf. autoprove's harness/autosetup). + PhaseSpec { key: "solving".into(), label: "Solving".into(), order: 2, core_slot: None }, + PhaseSpec { key: "formalization".into(), label: "Formalization".into(), order: 3, core_slot: Some(CoreSlot::Formalization) }, + PhaseSpec { key: "report".into(), label: "Report".into(), order: 4, core_slot: Some(CoreSlot::Report) }, + ], + args: vec![autoprover_sdk::ArgSpec { + flag: "--echo-tag".to_string(), + help: "An arbitrary tag stamped into the echo spec.".to_string(), + default: autoprover_sdk::ArgDefault::Str { value: Some("demo".to_string()) }, + required: false, + }], + rag_db_default: None, + event_kinds: vec![EventKind::log("solver_line", "Solver")], + artifact_layout: ArtifactLayout { + deliverable_dir: "certora/echo".into(), + internal_dir: ".certora_internal/echo".into(), + report_dir: "certora/echo/reports".into(), + artifact_dir: "certora/echo/specs".into(), + artifact_prefix: "echospec".into(), + artifact_extension: "espec".into(), + property_suffix: "property_rules".into(), + deliverable_primary: None, + }, + // A simple per-component wheel: no shared setup, one file per component, no toolchain + // confinement/serialization (its compile/validate are pure). All defaults. + setup: None, + deliverable_mode: autoprover_sdk::DeliverableMode::PerComponent, + serialize_toolchain: false, + confine_by_default: false, + component_noun: None, + } + } + + fn units(&self, input: &AuthorInput) -> Vec<Unit> { + input + .props + .iter() + .enumerate() + .map(|(i, p)| { + let slug = if p.slug.is_empty() { format!("p{i}") } else { p.slug.clone() }; + Unit { property: p.title.clone(), unit: format!("rule_{slug}"), target: None } + }) + .collect() + } + + fn author_prompt(&self, input: &AuthorInput, failure: Option<&Failure>) -> Prompt { + let titles: Vec<&str> = input.props.iter().map(|p| p.title.as_str()).collect(); + let mut instruction = format!( + "Author a spec with a rule per property: {}. Return the spec source only.", + titles.join(", ") + ); + if let Some(f) = failure { + instruction.push_str(&format!("\n\nThe previous attempt was rejected: {}", f.errors)); + } + Prompt { system: None, instruction } + } + + fn compile( + &self, + _input: &AuthorInput, + _spec: &str, + _workdir: &Path, + _sandbox: &Sandbox, + ) -> CompileResult { + // The demo accepts any well-formed spec — no build gate. + CompileResult::Ok + } + + fn validate( + &self, + _input: &AuthorInput, + _spec: &str, + unit: &str, + _workdir: &Path, + _sandbox: &Sandbox, + ) -> ValidateOutcome { + // Self-contained: any well-formed spec builds; the (own-target) unit "passes". + ValidateOutcome::Verdicts { verdicts: vec![(unit.to_string(), Verdict::with_outcome("GOOD"))] } + } +} + +autoprover_sdk::export_app!(echoprover, EchoApp); diff --git a/tests/conftest.py b/tests/conftest.py index 47ae1819..6a554e34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ from typing import Any, AsyncIterator, Iterator, Callable, Iterable, TYPE_CHECKING, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass, field +from urllib.parse import urlparse import numpy as np import psycopg @@ -45,12 +46,45 @@ except ImportError: _HAS_TESTCONTAINERS = False +def _external_pg_url() -> str | None: + """Admin DSN of an already-running postgres to use INSTEAD of testcontainers. + + Set by the containerized test flow (docs/crucible-demo.md) so DB-backed tests + run inside the crucible container against the compose `postgres` service — no + docker-in-docker. Must be a superuser DSN (tests CREATE ROLE/DATABASE).""" + return os.environ.get("COMPOSER_TEST_PG_URL") or None + + needs_postgres = pytest.mark.skipif( - not _HAS_TESTCONTAINERS, - reason="testcontainers[postgres] not installed", + not (_HAS_TESTCONTAINERS or _external_pg_url()), + reason="no postgres: install testcontainers[postgres] or set COMPOSER_TEST_PG_URL", ) +class _ExternalPostgres: + """Minimal ``PostgresContainer`` stand-in for an already-running postgres, so + DB-backed tests run unchanged against the compose `postgres` service. Only the + surface the tests use is implemented.""" + + def __init__(self, url: str) -> None: + p = urlparse(url) + self.username = p.username or "postgres" + self.password = p.password or "" + self._host = p.hostname or "localhost" + self._port = p.port or 5432 + self._db = (p.path or "/postgres").lstrip("/") or "postgres" + + def get_connection_url(self, host: str | None = None, driver: str | None = "psycopg2") -> str: + scheme = "postgresql" if not driver else f"postgresql+{driver}" + return f"{scheme}://{self.username}:{self.password}@{self._host}:{self._port}/{self._db}" + + def get_container_host_ip(self) -> str: + return self._host + + def get_exposed_port(self, port: int) -> int: + return self._port + + @pytest.fixture(autouse=True) def _isolate_run_summary(): """Keep the run-summary context var from leaking between tests.""" @@ -242,8 +276,13 @@ async def langgraph_db() -> AsyncIterator[LanggraphDBSetup | None]: @pytest.fixture(scope="session") def pg_container() -> Iterator["PostgresContainer | None"]: + ext = _external_pg_url() + if ext: + yield _ExternalPostgres(ext) # type: ignore[misc] + return if not _HAS_TESTCONTAINERS: - return None + yield None + return with PostgresContainer("pgvector/pgvector:pg16") as pg: yield pg diff --git a/tests/test_rust_frontend.py b/tests/test_rust_frontend.py new file mode 100644 index 00000000..5b4e25e7 --- /dev/null +++ b/tests/test_rust_frontend.py @@ -0,0 +1,70 @@ +"""Generic Rust frontend event routing (no wheel / no running TUI). + +A declared ``notice`` event kind (e.g. Crucible's per-invariant ``verdict``) must be +surfaced as a persistent callout via ``post_notice`` — not buried in the collapsible +events log — while ordinary kinds still stream to the log and undeclared kinds are +ignored. The notice headline carries an outcome glyph (✓/✗) when the payload has one. +""" + +import asyncio +from typing import Any, cast + +from composer.rustapp.frontend import GenericRustTaskHandler, _notice_headline +from composer.ui.tool_display import ToolDisplayConfig + + +def test_notice_headline_prefixes_outcome_glyph(): + assert _notice_headline({"outcome": "GOOD", "line": "held"}) == "✓ held" + assert _notice_headline({"outcome": "BAD", "line": "refuted"}) == "✗ refuted" + + +def test_notice_headline_without_outcome_is_plain_line(): + assert _notice_headline({"line": "building…"}) == "building…" + + +class _RecordingHandler(GenericRustTaskHandler): + """Records where each event is routed, bypassing real textual mounting.""" + + def __init__(self, event_kinds: set[str], notice_kinds: set[str]): + super().__init__( + "t", "Label", cast(Any, None), cast(Any, None), ToolDisplayConfig(), + event_kinds, notice_kinds, + ) + self.notices: list[str] = [] + self.logged: list[str] = [] + + async def post_notice(self, headline, detail=None, *, toast=True): # type: ignore[override] + self.notices.append(headline if isinstance(headline, str) else headline.plain) + + async def _ensure_event_log(self): # type: ignore[override] + handler = self + + class _Log: + def write(self, line: str) -> None: + handler.logged.append(line) + + return _Log() + + +def _handle(handler: _RecordingHandler, payload: dict) -> None: + asyncio.run(handler.handle_event(payload, ["t"], "cp")) + + +def test_notice_kind_routes_to_post_notice_not_log(): + h = _RecordingHandler(event_kinds={"fuzz_pulse", "verdict"}, notice_kinds={"verdict"}) + _handle(h, {"type": "verdict", "outcome": "BAD", "line": "counterexample found"}) + assert h.notices == ["✗ counterexample found"] + assert h.logged == [] + + +def test_streaming_kind_routes_to_log_not_notice(): + h = _RecordingHandler(event_kinds={"fuzz_pulse", "verdict"}, notice_kinds={"verdict"}) + _handle(h, {"type": "fuzz_pulse", "line": "fuzzing…"}) + assert h.logged == ["[fuzz_pulse] fuzzing…"] + assert h.notices == [] + + +def test_undeclared_kind_is_ignored(): + h = _RecordingHandler(event_kinds={"verdict"}, notice_kinds={"verdict"}) + _handle(h, {"type": "mystery", "line": "nope"}) + assert h.notices == [] and h.logged == [] diff --git a/tests/test_rust_llm_agent.py b/tests/test_rust_llm_agent.py new file mode 100644 index 00000000..d719d51e --- /dev/null +++ b/tests/test_rust_llm_agent.py @@ -0,0 +1,31 @@ +"""The tool-enabled authoring turn's prompt handling (no wheel / LLM needed). + +The backend owns the prompt: its author/judge prompt payload carries the ``instruction`` +and may define its own ``system`` prompt; otherwise a neutral, backend-agnostic default +applies (no language/domain leak — the trigger for this was the old prompt hardcoding +"Rust-based"). Lives in ``composer.rustapp.adapter`` (merged from the former ``_llm_agent``). +""" + +from composer.rustapp.adapter import _DEFAULT_SYS_PROMPT, _split_prompt + + +def test_bare_string_is_the_instruction_with_default_system(): + assert _split_prompt("do the thing") == (None, "do the thing") + + +def test_dict_instruction_extracted_cleanly(): + # Not JSON-wrapped (the old behavior dumped the whole dict as the prompt). + assert _split_prompt({"instruction": "author X"}) == (None, "author X") + + +def test_backend_may_define_its_own_system_prompt(): + assert _split_prompt({"system": "you are a fuzz author", "instruction": "author X"}) == ( + "you are a fuzz author", + "author X", + ) + + +def test_default_system_prompt_is_backend_agnostic(): + # No language/domain leak; still conveys the result-tool contract. + assert "Rust" not in _DEFAULT_SYS_PROMPT + assert "result" in _DEFAULT_SYS_PROMPT diff --git a/tests/test_rustapp.py b/tests/test_rustapp.py new file mode 100644 index 00000000..52beae39 --- /dev/null +++ b/tests/test_rustapp.py @@ -0,0 +1,228 @@ +"""End-to-end tests for the Rust application/backend framework (composer.rustapp). + +These drive the ``echoprover`` demo wheel (built from ``rust/example-app``) as a +:class:`~autoprover_sdk.Backend`: the pure callouts (``descriptor`` / ``units`` / +``author_prompt`` / ``compile`` / ``validate``) plus the descriptor synthesis and the +host wiring. They need the ``echoprover`` wheel importable (``maturin develop`` in +``rust/example-app``); tests skip cleanly otherwise. + +No Postgres / LLM is required — the callouts are pure (echoprover's ``compile`` is a +no-op and ``validate`` returns GOOD), which is the point of the passive-service design: +the loop lives in Python and the wheel just answers questions. +""" + +import json + +import pytest + +echoprover = pytest.importorskip( + "echoprover", + reason="build the demo wheel first: (cd rust/example-app && maturin develop)", +) + +from composer.rustapp.descriptor import AppDescriptor, CoreSlot +from composer.rustapp.result import RustFormalResult + + +def _component_input(*titles: str) -> str: + return json.dumps( + { + "kind": "component", + "program": "Counter", + "component": {"name": "Counter"}, + "props": [ + {"title": t, "sort": "invariant", "description": "x", "slug": t.replace(" ", "_")} + for t in titles + ], + "context": {}, + } + ) + + +def test_descriptor_parses_and_maps_core_phases(): + desc = AppDescriptor.model_validate_json(echoprover.descriptor()) + assert desc.name == "echoprover" + assert desc.backend_tag == "echoprover" + # All four core slots are mapped, plus a UI-only "solving" phase. + slots = desc.core_slot_map() + assert set(slots) == set(CoreSlot) + keys = [p.key for p in desc.ordered_phases()] + assert keys == ["analysis", "extraction", "solving", "formalization", "report"] + + +def test_units_are_one_per_property(): + units = json.loads(echoprover.units(_component_input("increment_increases", "never_overflows"))) + assert units == [ + {"property": "increment_increases", "unit": "rule_increment_increases"}, + {"property": "never_overflows", "unit": "rule_never_overflows"}, + ] + + +def test_author_prompt_lists_the_properties(): + prompt = json.loads(echoprover.author_prompt(_component_input("increment_increases"), None)) + assert "increment_increases" in prompt["instruction"] + assert prompt.get("system") is None + + +def test_compile_is_a_noop_ok(): + # The demo accepts any well-formed spec — compile is a no-op gate. + r = json.loads(echoprover.compile(_component_input("p"), "spec", "/tmp", json.dumps({"argv_prefix": []}))) + assert r == {"status": "ok"} + + +def test_validate_returns_a_good_verdict(): + res = json.loads( + echoprover.validate(_component_input("p"), "spec", "rule_p", "/tmp", json.dumps({"argv_prefix": []})) + ) + # ValidateOutcome: the demo always builds, so per-unit verdicts (not build_failed). + assert res == {"kind": "verdicts", "verdicts": [["rule_p", {"outcome": "GOOD"}]]} + + +def test_result_round_trips_through_cache_serialization(): + # The driver caches by model_dump/validate; ensure that survives. + res = RustFormalResult.from_formalized( + { + "commentary": "c", + "artifact_text": "spec", + "property_units": [("p", ["rule_p"])], + "skipped": [{"property_title": "q", "reason": "n/a"}], + "output_link": "local://x", + } + ) + reloaded = RustFormalResult.model_validate_json(res.model_dump_json()) + assert reloaded.property_units() == [("p", ["rule_p"])] + assert reloaded.artifact_text == "spec" + assert reloaded.skipped[0].property_title == "q" + + +# --------------------------------------------------------------------------- +# Generic host: entry point (argparse), shared-enum identity, frontend. +# These import the heavier host (needs the full composer stack). If it can't +# import (e.g. running against a slim env), skip rather than error. +# --------------------------------------------------------------------------- + +host = pytest.importorskip( + "composer.rustapp.host", reason="needs the full composer stack installed" +) + + +def test_entry_argparser_has_positionals_and_declared_flags(): + from composer.rustapp.entry import build_arg_parser + + app = host.build_application("echoprover") + parser = build_arg_parser(app) + + # Declared flag default (from the descriptor's ArgSpec) is applied. + args = parser.parse_args(["/proj", "src/C.sol:C", "doc.md"]) + assert args.project_root == "/proj" + assert args.main_contract == "src/C.sol:C" + assert args.system_doc == "doc.md" + assert args.max_concurrent == 4 + assert args.echo_tag == "demo" + + # …and is overridable. + args2 = parser.parse_args(["/proj", "src/C.sol:C", "doc.md", "--echo-tag", "hi"]) + assert args2.echo_tag == "hi" + + +def test_system_doc_is_optional_with_discovery_phase_fallback(): + from composer.rustapp.entry import _discovery_phase, build_arg_parser + + app = host.build_application("echoprover") + parser = build_arg_parser(app) + + # system_doc may be omitted (→ discovery); still parses. + ns = parser.parse_args(["/proj", "src/C.sol:C"]) + assert ns.system_doc is None + assert parser.parse_args(["/proj", "src/C.sol:C", "doc.md"]).system_doc == "doc.md" + + # A wheel that declares no discover_design_doc phase falls back to its first phase. + first_key = app.descriptor.ordered_phases()[0].key + assert _discovery_phase(app) is app.phase[first_key] + + +def test_frontend_labels_and_backend_phases_share_one_enum(): + # The correctness invariant: the phases the driver stamps on TaskInfo (from + # the backend's core_phases) must be the SAME enum members the frontend's + # phase_labels are keyed by, or label lookup silently misses. + from composer.input.files import InMemoryTextFile + from composer.spec.context import SourceCode + from composer.spec.system_model import SolidityIdentifier + + app = host.build_application("echoprover") + source = SourceCode( + content=InMemoryTextFile(basename="doc.md", string_contents="doc", provider="test"), + project_root="/tmp/echo-proj", + contract_name=SolidityIdentifier("C"), + relative_path="src/C.sol", + forbidden_read="", + ) + backend = app.make_backend(source) + for slot, member in backend.core_phases.items(): + assert member in app.phase_labels, (slot, member) + # Section order lists every declared phase's label. + assert set(app.section_order) == set(app.phase_labels.values()) + + +def test_generic_console_handler_renders_declared_events(capsys): + import asyncio + + from composer.rustapp.frontend import GenericRustConsoleHandler, _render_event + + assert _render_event({"type": "solver_line", "line": "hello"}) == "hello" + assert _render_event({"type": "x", "a": 1}) == '{"a": 1}' + + handler = GenericRustConsoleHandler({"solver_line"}) + asyncio.run(handler.handle_event({"type": "solver_line", "line": "L1"}, ["t"], "cp")) + # An undeclared kind is ignored. + asyncio.run(handler.handle_event({"type": "other", "line": "nope"}, ["t"], "cp")) + out = capsys.readouterr().out + assert "solver_line: L1" in out + assert "nope" not in out + + +def test_generic_tui_app_constructs(): + from composer.rustapp.frontend import GenericRustApp + + app = host.build_application("echoprover") + tui = GenericRustApp( + phase_labels=app.phase_labels, + section_order=app.section_order, + header_text=app.header_text, + event_kinds={e.kind for e in app.descriptor.event_kinds}, + ) + assert tui is not None + + +def test_descriptor_carries_ecosystem_and_resolves(): + from composer.pipeline.ecosystem import EVM + from composer.rustapp.host import resolve_ecosystem + + desc = AppDescriptor.model_validate_json(echoprover.descriptor()) + assert desc.ecosystem == "evm" + assert resolve_ecosystem(desc) is EVM + + +def test_build_application_carries_resolved_ecosystem(): + from composer.pipeline.ecosystem import EVM + + app = host.build_application("echoprover") + assert app.ecosystem is EVM + + +def test_resolve_ecosystem_rejects_unregistered_chain(): + from composer.rustapp.host import resolve_ecosystem + + desc = AppDescriptor.model_validate_json(echoprover.descriptor()) + # soroban is a valid ChainTag but not registered until a later phase. + unregistered = desc.model_copy(update={"ecosystem": "soroban"}) + with pytest.raises(ValueError, match="not registered"): + resolve_ecosystem(unregistered) + + +def test_descriptor_ecosystem_defaults_to_evm_when_absent(): + # Wheels built before the field existed omit it; the mirror defaults to evm. + raw = json.loads(echoprover.descriptor()) + del raw["ecosystem"] + desc = AppDescriptor.model_validate(raw) + assert desc.ecosystem == "evm" diff --git a/tests/test_solana_build.py b/tests/test_solana_build.py new file mode 100644 index 00000000..9b935d48 --- /dev/null +++ b/tests/test_solana_build.py @@ -0,0 +1,80 @@ +"""Tests for the Solana build step's offline warming (Phase 6 step 4). + +Pin the §5 split: the registry is warmed with network *outside* the sandbox +(`warm_cargo_cache` → `cargo fetch`, no provider) only when a sandbox is enabled, +and the actual build is then handed the provider (so it runs confined + offline). +Uses fakes — no real cargo/toolchain. +""" + +from pathlib import Path + +import pytest + +import composer.spec.solana.build as buildmod +from composer.sandbox.command import CommandResult +from composer.sandbox.config import SandboxConfig + +pytestmark = pytest.mark.asyncio + + +async def test_warm_cargo_cache_runs_unsandboxed_fetch(tmp_path, monkeypatch): + seen = {} + + async def fake_run(program, args, files, *, workdir, timeout_s=600, sem=None, + provider=None, policy=None, env_overlay=None): + seen.update(program=program, args=args, workdir=Path(workdir), + provider=provider, env_overlay=env_overlay) + return CommandResult(0, "", "") + + monkeypatch.setattr(buildmod, "run_local_command", fake_run) + res = await buildmod.warm_cargo_cache(tmp_path, cargo_home=tmp_path / ".sandbox_cargo") + assert res.exit_code == 0 + assert seen["program"] == "cargo" and seen["args"] == ["fetch"] + assert seen["workdir"] == tmp_path + assert seen["provider"] is None # warming must NOT be sandboxed (it needs network) + # fetch targets the private per-run CARGO_HOME (same one the offline build reads) + assert seen["env_overlay"] == {"CARGO_HOME": str(tmp_path / ".sandbox_cargo")} + + +async def _fake_build_run_factory(calls): + async def fake_run(program, args, files, *, workdir, timeout_s=600, sem=None, + provider=None, policy=None, env_overlay=None): + calls.append((program, provider is not None)) + so = Path(workdir) / "target" / "deploy" / "vault.so" + so.parent.mkdir(parents=True, exist_ok=True) + so.write_text("") # simulate a produced .so so build_program succeeds + return CommandResult(0, "", "") + + return fake_run + + +async def test_build_program_skips_warm_when_unsandboxed(tmp_path, monkeypatch): + calls: list = [] + warm_count = {"n": 0} + + async def fake_warm(*a, **k): + warm_count["n"] += 1 + return CommandResult(0, "", "") + + monkeypatch.setattr(buildmod, "warm_cargo_cache", fake_warm) + monkeypatch.setattr(buildmod, "run_local_command", await _fake_build_run_factory(calls)) + + await buildmod.build_program(tmp_path, "vault") # no sandbox → no warm, no provider + assert warm_count["n"] == 0 + assert calls and calls[0][1] is False + + +async def test_build_program_warms_and_sandboxes_when_enabled(tmp_path, monkeypatch): + calls: list = [] + warm_count = {"n": 0} + + async def fake_warm(*a, **k): + warm_count["n"] += 1 + return CommandResult(0, "", "") + + monkeypatch.setattr(buildmod, "warm_cargo_cache", fake_warm) + monkeypatch.setattr(buildmod, "run_local_command", await _fake_build_run_factory(calls)) + + await buildmod.build_program(tmp_path, "vault", sandbox=SandboxConfig(provider="launcher")) + assert warm_count["n"] == 1 # warmed once, outside the sandbox + assert calls[0][1] is True # the build command received the provider diff --git a/tests/test_solana_gate.py b/tests/test_solana_gate.py new file mode 100644 index 00000000..430f1bf9 --- /dev/null +++ b/tests/test_solana_gate.py @@ -0,0 +1,123 @@ +"""Phase-4 gate: the Solana front half (analysis + property extraction) end-to-end. + +Runs the shared driver over the SOLANA ecosystem + a null backend on a sample Anchor vault, +with REAL models. Everything else (Postgres, source tools) runs for real; there is no prover or +AutoSetup (the null backend just records properties). Pass = the pipeline runs to completion and +extracts a non-empty set of properties; the printed properties let a human judge "sane". + +Marked ``expensive`` (real LLM + containers). Run with: + env -u CERTORA .venv/bin/python -m pytest tests/test_solana_gate.py -m expensive -q -s +""" +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast, TYPE_CHECKING + +import psycopg +import pytest +from psycopg.sql import SQL, Identifier, Literal + +import composer.workflow.services as services +from composer.pipeline.core import PipelineRun, run_pipeline +from composer.pipeline.ecosystem import SOLANA, RUST_FORBIDDEN_READ +from composer.spec.context import SourceCode, WorkflowContext +from composer.spec.service_host import ModelProvider, PureServiceHost +from composer.llm.registry import get_provider_for +from composer.spec.solana.null_backend import NullSolanaArtifactStore, NullSolanaBackend +from composer.spec.source.source_env import build_basic_source_tools, build_source_tools +from composer.spec.system_model import SolidityIdentifier +from composer.ui.tool_display import async_tool_context +from composer.rustapp.frontend import GenericRustConsoleHandler +from composer.workflow.services import standard_connections, llm_factory +from composer.kb.knowledge_base import DefaultEmbedder + +from tests.conftest import needs_postgres, MockSentenceTransformer +from tests.conftest import _RAG_DB, _VECTOR_DBS, _MEMORIES_DDL, _db_url + +if TYPE_CHECKING: + from testcontainers.postgres import PostgresContainer + +pytestmark = [pytest.mark.expensive, needs_postgres, pytest.mark.asyncio] + +_SCENARIO = Path(__file__).parent.parent / "test_scenarios" / "solana_vault" + + +def _model_args() -> object: + return SimpleNamespace( + heavy_model="claude-opus-4-6", + lite_model="claude-sonnet-4-6", + tokens=128_000, + thinking_tokens=2048, + memory_tool=False, + interleaved_thinking=False, + ) + + +async def test_solana_vault_front_half(pg_container: "PostgresContainer", monkeypatch): + assert _SCENARIO.is_dir(), _SCENARIO + + # 1. Roles + databases the pipeline expects (matches services._DATABASE_CONFIGS). + admin_url = pg_container.get_connection_url(driver=None) + with psycopg.connect(admin_url, autocommit=True) as admin: + for cfg in services._DATABASE_CONFIGS.values(): + admin.execute(SQL("CREATE ROLE {} LOGIN PASSWORD {}").format( + Identifier(cfg["user"]), Literal(cfg["password"]))) + admin.execute(SQL("CREATE DATABASE {} OWNER {}").format( + Identifier(cfg["database"]), Identifier(cfg["user"]))) + admin.execute(SQL("CREATE DATABASE {}").format(Identifier(_RAG_DB))) + for db in _VECTOR_DBS: + with psycopg.connect(_db_url(pg_container, db), autocommit=True) as conn: + conn.execute("CREATE EXTENSION IF NOT EXISTS vector") + mem = services._DATABASE_CONFIGS["memory"] + mem_url = ( + f"postgresql://{mem['user']}:{mem['password']}" + f"@{pg_container.get_container_host_ip()}:{pg_container.get_exposed_port(5432)}/{mem['database']}" + ) + with psycopg.connect(mem_url, autocommit=True) as conn: + conn.execute(_MEMORIES_DDL) + + monkeypatch.setenv("CERTORA_AI_COMPOSER_PGHOST", pg_container.get_container_host_ip()) + monkeypatch.setenv("CERTORA_AI_COMPOSER_PGPORT", str(pg_container.get_exposed_port(5432))) + + model = MockSentenceTransformer() # deterministic embedder; nothing here needs real embeddings + args = _model_args() + + async with ( + standard_connections(provider="anthropic", embedder=DefaultEmbedder(model)) as conns, + async_tool_context(), + ): + content = await conns.uploader.get_document(_SCENARIO / "system.md") + assert content is not None + source = SourceCode( + content=content, + project_root=str(_SCENARIO), + contract_name=SolidityIdentifier("vault"), # the target program's identifier + relative_path="programs/vault/src/lib.rs", + forbidden_read=RUST_FORBIDDEN_READ, + ) + _tiered = get_provider_for(tiered=cast(Any, args)) + model_provider = ModelProvider( + heavy_model=_tiered.heavy, lite_model=_tiered.lite, checkpointer=conns.checkpointer, + ) + basic = build_basic_source_tools(root=str(_SCENARIO), forbidden_read=RUST_FORBIDDEN_READ) + full = build_source_tools(basic, model_provider, conns.indexed_store, ("solana_gate", "src"), recursion_limit=100) + env = PureServiceHost(models=model_provider, rag_tools=(), sort="existing").bind_source_tools(full) + + ctx = WorkflowContext.create( + services=conns.memory, + thread_id="solana_gate", store=conns.store, recursion_limit=100, + cache_namespace=None, memory_namespace=None, + ) + + import asyncio + backend = NullSolanaBackend(NullSolanaArtifactStore(str(_SCENARIO))) + run = PipelineRun(ctx=ctx, source=source, _handler_factory=GenericRustConsoleHandler(set()).make_handler, _semaphore=asyncio.Semaphore(4), env=env) + result = await run_pipeline(backend, run, ecosystem=SOLANA, interactive=False, threat_model=None, max_bug_rounds=1) + + # Pass: front half ran and extracted properties. + print(f"\nSolana gate: {result.n_components} invariant(s), {result.n_properties} properties") + for o in result.outcomes: + print(f"\n== {o.feat.display_name} ==") + for p in o.props: + print(f" [{p.sort}] {p.title}: {p.description}") + assert result.n_components > 0, "no invariants extracted" + assert result.n_properties > 0, "no properties extracted" diff --git a/uv.lock b/uv.lock index 7f31e03f..d9a05b59 100644 --- a/uv.lock +++ b/uv.lock @@ -125,9 +125,16 @@ s3 = [ ] [package.dev-dependencies] +apps = [ + { name = "echoprover" }, + { name = "maturin-import-hook" }, +] ci = [ { name = "pyright" }, ] +dev = [ + { name = "maturin" }, +] ragbuild = [ { name = "beautifulsoup4" }, { name = "einops" }, @@ -189,7 +196,12 @@ requires-dist = [ provides-extras = ["ml", "s3", "certora-cli", "certora-cli-beta", "certora-cli-beta-mirror", "prover", "cpu", "cuda"] [package.metadata.requires-dev] +apps = [ + { name = "echoprover", editable = "rust/example-app" }, + { name = "maturin-import-hook", specifier = ">=0.3.0" }, +] ci = [{ name = "pyright" }] +dev = [{ name = "maturin", specifier = ">=1.14.1" }] ragbuild = [ { name = "beautifulsoup4", specifier = "==4.13.5" }, { name = "einops" }, @@ -1263,6 +1275,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] +[[package]] +name = "echoprover" +version = "0.1.0" +source = { editable = "rust/example-app" } + [[package]] name = "einops" version = "0.8.2" @@ -2339,6 +2356,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "maturin" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/b3/addd877f871fb1860d46d3a4f206ecb10b946c85846805e6367631926fd3/maturin-1.14.1.tar.gz", hash = "sha256:9d6577a62cd08e0ceba7a0db06fb098e0c9b1b3429bad747a4f3a18215a1b3df", size = 369637, upload-time = "2026-06-19T05:19:49.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/f0/97c5a5bd9c71653a066c0976a484eaaae50b9369557838a4176b7b0bdaa5/maturin-1.14.1-py3-none-linux_armv6l.whl", hash = "sha256:522292398945442cdafa9daeb2271b2340fbde57027b818f923f88eab04174f8", size = 10207496, upload-time = "2026-06-19T05:19:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/fe/83/294bca639b0e052f1e2f65199b3db258780c7d4e31408b934c9c974a1379/maturin-1.14.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ffe5ad71f21d1e6603c4dd75f7fee34adf5ed5ebcebb692886549888ebb329ed", size = 19680113, upload-time = "2026-06-19T05:19:13.43Z" }, + { url = "https://files.pythonhosted.org/packages/43/b6/79c881410a3b1c187f7eb3d407aecae646c6a4433d630d72200359015e83/maturin-1.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3306078070c1508fd715b9116070cbcaff5959024272a9f1e6f5cb29768b86c", size = 10169205, upload-time = "2026-06-19T05:19:16.615Z" }, + { url = "https://files.pythonhosted.org/packages/93/9d/44b6f26dcb7f7a04c5501ac2dbb6ca1490150682baa525ca5860504f9eab/maturin-1.14.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:cd457cd88961156e26379e1155bd287cc0ec1c8b2f1582b0660fb31b87c8842d", size = 10188098, upload-time = "2026-06-19T05:19:19.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bd/9c0d5d6983905ce2c9edaa073a7e89355a9cf7f396988e05d32f1c37785d/maturin-1.14.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:dfc54ae32e6fcb18302193ab9a30b0b25eefffba994ae13238974805533ef75e", size = 10627576, upload-time = "2026-06-19T05:19:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/e5/33/b096412bd6a7cb399652b260666f901adf88a687181a6dbd6a3f89f0a94e/maturin-1.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a131d912b5267e640bc96d70f4914e10590aed64082ec9abacba7cea52004224", size = 10085181, upload-time = "2026-06-19T05:19:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/56/8d/08c3bf469c38a23c9e6c877e338193001eb604d010fedc08341974e38528/maturin-1.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:be18fc568fb76884c0205456336892a75105ec398e6b667cd777c6268bd06d69", size = 10026363, upload-time = "2026-06-19T05:19:28.904Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/c4d1a92839f8745ab4aab988a7db884a79d6d710bd3b286fcf9316dece1a/maturin-1.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:994a0c8ba3ad8a92b3a9ee1b02645d200d610216b15cff5102b0fe65e8e08666", size = 13321347, upload-time = "2026-06-19T05:19:32.411Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fa/170f04624d03fd07d2a8b1b67de83a127af93aef9eaa425839553347297b/maturin-1.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be80866363e605d137991b491a741a84cde9ae350183c4c85f49690ca9aaaa65", size = 10877609, upload-time = "2026-06-19T05:19:35.448Z" }, + { url = "https://files.pythonhosted.org/packages/61/ad/1ae2e1d0ded282bf2c55ac13f0811d87deb425e200ae64a15785675dede9/maturin-1.14.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:5282dffd4b539d2be245f4e5b1a5ab6bc1033b58f4a4872f5833f9d43c954aa4", size = 10417316, upload-time = "2026-06-19T05:19:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/fb/27/bf677183920718da49cd7982d6a3ffc440aad8919329f571d189f81b7bdf/maturin-1.14.1-py3-none-win32.whl", hash = "sha256:1a04de0a20188f95c721b5702eed18140bdcccb28c386797093eca3f62f4d4e0", size = 8931293, upload-time = "2026-06-19T05:19:41.183Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/585adeb9167b08d3cdff0032a938b0e72655c92003df4f52c3f696a1bcc2/maturin-1.14.1-py3-none-win_amd64.whl", hash = "sha256:3c9f94640ecc4895e94abaf834a0684430032c865b2748a36c12461fd9252fdd", size = 10314067, upload-time = "2026-06-19T05:19:44.389Z" }, + { url = "https://files.pythonhosted.org/packages/51/d4/dac8c0720ae246be1700afb6fbdbbea20fe35b13f6570b2f70faa005df77/maturin-1.14.1-py3-none-win_arm64.whl", hash = "sha256:15cea8fcb3ba47dd636f50092bb34baea8b04ac777392f23e6bf8a9a61efb894", size = 9718943, upload-time = "2026-06-19T05:19:47.49Z" }, +] + +[[package]] +name = "maturin-import-hook" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/a1/3b007a4132265d5da19096d6835cd4c7e9908afb9156afb96d19ff190f39/maturin_import_hook-0.3.0.tar.gz", hash = "sha256:cdd51ce267663c437c4aea1bfd5f7ee5884b54a9d866ea924227d68d758bbba2", size = 30785, upload-time = "2025-06-08T15:43:57.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/55/0c17a1fccec90dffbb1d904d7ddb7971e4e95f994c6264637e8fe66a5b86/maturin_import_hook-0.3.0-py3-none-any.whl", hash = "sha256:f5d20b4bc3056d84170dc08fe655160421b28d2b5128b48f55312315cdbb2cfe", size = 30848, upload-time = "2025-06-08T15:43:56.298Z" }, +] + [[package]] name = "mcp" version = "1.28.0"