diff --git a/apn/checker.py b/apn/checker.py index e713dc1..68131e5 100644 --- a/apn/checker.py +++ b/apn/checker.py @@ -7,10 +7,24 @@ from pathlib import PurePosixPath from typing import Literal, Protocol, runtime_checkable -from inspect_ai.util import OutputLimitExceededError, sandbox +from inspect_ai.util import OutputLimitExceededError, sandbox, tool_state_instance from apn.layout import ENTRY_MODULE, ENTRY_PATH, ENTRY_REL, PROJECT, SUBMISSION_DIR +COMPARATOR_SANDBOX = "comparator" + + +def current_member() -> str | None: + """The swarm member whose tool state scope the caller runs in, if any.""" + instance = tool_state_instance(None) + return None if instance is None else instance.removeprefix("@") + + +def comparator_sandbox_name(member: str | None) -> str: + """The comparator sandbox that checks ``member``'s submissions: one per + swarm member, or the sample's single comparator outside a swarm.""" + return COMPARATOR_SANDBOX if member is None else f"{COMPARATOR_SANDBOX}-{member}" + # Paths inside the trusted `comparator` sandbox (the comparator stage of # apn/lean/Dockerfile). The challenge is staged into the lake project's `run/` # scratch lib; the agent's submission tree is staged at `Submission/` -- the @@ -231,7 +245,8 @@ def comparator_config(decl: str, claim: Claim) -> str: class SandboxComparator: - """Runs Lean FRO's Comparator against the trusted ``comparator`` sandbox. + """Runs Lean FRO's Comparator against the trusted comparator sandbox + (the caller's, per :func:`comparator_sandbox_name`). Per check (comparator-migration-plan.md §3.2): reset the sandbox's ``.lake`` to the image's pristine tree, recreate the ``run/`` and @@ -256,18 +271,13 @@ class SandboxComparator: Comparator transcript at scoring time. """ - def __init__( - self, - sandbox_name: str | None = "comparator", - timeout: int = 60 * 60, - ) -> None: - self._sandbox_name = sandbox_name + def __init__(self, timeout: int = 60 * 60) -> None: self._timeout = timeout async def check( self, spec: str, submission_tar: bytes, decl: str, claim: Claim ) -> CheckOutcome: - sb = sandbox(self._sandbox_name) + sb = sandbox(comparator_sandbox_name(current_member())) # Host-side: re-serialize the agent's tar as a trusted archive of its # Lean modules (a verdict if that is impossible; the sandbox is never diff --git a/apn/scorer.py b/apn/scorer.py index 63225e2..b346a61 100644 --- a/apn/scorer.py +++ b/apn/scorer.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from typing import Any from inspect_ai.scorer import ( CORRECT, @@ -13,9 +14,15 @@ stderr, ) from inspect_ai.solver import TaskState -from inspect_ai.util import OutputLimitExceededError, sandbox, store +from inspect_ai.util import ( + OutputLimitExceededError, + SandboxEnvironment, + sandbox, + store, + tool_state_scope, +) -from apn.checker import Claim, ProofChecker +from apn.checker import Claim, ProofChecker, current_member from apn.filetree import build_tree_from_tar, read_submission_tar logger = logging.getLogger(__name__) @@ -23,59 +30,153 @@ # Where the submit tool records the agent's declared claim (see apn.solver). CLAIM_STORE_KEY = "submission_claim" +SWARM_RESULT_KEY = "inspect_swarm:result" +SWARM_LEDGER_KEY = "inspect_swarm:ledger" + + +def _member_key(key: str) -> str: + member = current_member() + return key if member is None else f"{key}:{member}" + + +def claim_store_key() -> str: + return _member_key(CLAIM_STORE_KEY) + @scorer(metrics=[accuracy(), stderr()]) -def proof_scorer(checker: ProofChecker) -> Scorer: +def proof_scorer(checker: ProofChecker, swarm: bool = False) -> Scorer: """Score a sample by checking the agent's ``Submission/`` module tree (entry module ``Spec.lean``) with the configured proof checker (Comparator in - production).""" + production). + + With ``swarm``, a check inside a member scores that member's own sandbox, + and the final score after the swarm checks every member's sandbox: CORRECT + if any member's submission passes.""" async def score(state: TaskState, target: Target) -> Score: - # Per-attempt attempt index, kept in the sample store (the react/deepagent - # attempt_count is not reachable from here). Increments even when - # max_attempts=1, so a single-attempt sample still tags attempt-1. - attempt = store().get("_score_call_idx", 0) + 1 - store().set("_score_call_idx", attempt) - - try: - tar = await read_submission_tar(sandbox()) - except OutputLimitExceededError as exc: - return Score( - value=INCORRECT, - explanation=str(exc), - metadata={"stage": "submission_oversize", "verifier_output": None}, - ) + if swarm and current_member() is None: + return await _score_swarm(state, checker) + # Known limitation: in a swarm, every member writes this same key, so the last one wins. + return await _score_sandbox(state, checker, sandbox(), "submission_contents") + + return score - _write_submission_sidecar(state, attempt, tar) - _record_submission_tree(state, tar) - # The claim the agent declared on its submit call. A sample scored - # without one (e.g. it hit its limits before ever submitting) defaults - # to "proof" -- deterministic, and such submissions reject anyway. - claim: Claim = store().get(CLAIM_STORE_KEY, "proof") - # The target theorem's fully qualified name (== the sample id except - # where the manifest overrides it; see apn.dataset.build_dataset). - decl = state.metadata["decl_name"] +async def _score_swarm(state: TaskState, checker: ProofChecker) -> Score: + record = state.store.get(SWARM_RESULT_KEY) + scores: dict[str, Score] = {} + rows: list[list[str]] = [] + for m in record["members"]: + name = m["name"] + with tool_state_scope(name): + recorded = store().get(claim_store_key()) + scores[name] = await _score_sandbox( + state, + checker, + sandbox(m["sandbox"]), + f"submission_contents_{name}", + ) + score = scores[name] + assert score.metadata is not None and score.explanation is not None + claim = ( + 'did not call `submit_proof()`, defaulted to `claim="proof"`' + if recorded is None + else f'called `submit_proof(claim="{recorded}")`' + ) + check = next( + (line for line in reversed(score.explanation.splitlines()) if line.strip()), + "", + ) + result = "CORRECT" if score.value == CORRECT else "INCORRECT" + rows.append([name, m["status"], result, claim, score.metadata["stage"], check]) + passed = [name for name, s in scores.items() if s.value == CORRECT] + return Score( + value=CORRECT if passed else INCORRECT, + explanation=_swarm_explanation( + len(scores), passed, state.store.get(SWARM_LEDGER_KEY), rows + ), + metadata={ + "passed": passed, + "members": {name: s.metadata for name, s in scores.items()}, + }, + ) + + +def _swarm_explanation( + size: int, passed: list[str], ledger: dict[str, Any], rows: list[list[str]] +) -> str: + if passed: + summary = ( + f"Swarm of {size}: {len(passed)} members' work passes " + f"({', '.join(passed)})." + ) + else: + summary = f"Swarm of {size}: no member's work passes." + summary += f" Stop reason: {ledger['stop_reason']}." + if ledger["ended_by"] is not None: + summary += f" Ended by: {ledger['ended_by']}." + table = [ + "| Member | Status | Result | Claim | Stage | Checker |", + "| --- | --- | --- | --- | --- | --- |", + *( + "| " + " | ".join(cell.replace("|", "\\|") for cell in row) + " |" + for row in rows + ), + ] + return "\n".join([summary, "", *table]) + + +async def _score_sandbox( + state: TaskState, + checker: ProofChecker, + sb: SandboxEnvironment, + tree_key: str, +) -> Score: + # Per-attempt attempt index, kept in the sample store (the react/deepagent + # attempt_count is not reachable from here). Increments even when + # max_attempts=1, so a single-attempt sample still tags attempt-1. + attempt_key = _member_key("_score_call_idx") + attempt = store().get(attempt_key, 0) + 1 + store().set(attempt_key, attempt) - spec = state.metadata["sketch"] - outcome = await checker.check(spec, tar, decl=decl, claim=claim) + try: + tar = await read_submission_tar(sb) + except OutputLimitExceededError as exc: return Score( - value=CORRECT if outcome.ok else INCORRECT, - explanation=outcome.detail, - metadata={ - "stage": outcome.stage, - "claim": claim, - "verifier_output": outcome.detail, - }, + value=INCORRECT, + explanation=str(exc), + metadata={"stage": "submission_oversize", "verifier_output": None}, ) - return score - - -def _record_submission_tree(state: TaskState, tar: bytes) -> None: + _write_submission_sidecar(state, attempt, tar) + _record_submission_tree(state, tar, tree_key) + + # The claim the agent declared on its submit call. A sample scored + # without one (e.g. it hit its limits before ever submitting) defaults + # to "proof" -- deterministic, and such submissions reject anyway. + recorded: Claim | None = store().get(claim_store_key()) + claim: Claim = "proof" if recorded is None else recorded + # The target theorem's fully qualified name (== the sample id except + # where the manifest overrides it; see apn.dataset.build_dataset). + decl = state.metadata["decl_name"] + + spec = state.metadata["sketch"] + outcome = await checker.check(spec, tar, decl=decl, claim=claim) + return Score( + value=CORRECT if outcome.ok else INCORRECT, + explanation=outcome.detail, + metadata={ + "stage": outcome.stage, + "claim": claim, + "verifier_output": outcome.detail, + }, + ) + + +def _record_submission_tree(state: TaskState, tar: bytes, key: str) -> None: """Set the agent's ``Submission/`` directory as a display tree on sample metadata.""" try: - state.metadata["submission_contents"] = build_tree_from_tar(tar) + state.metadata[key] = build_tree_from_tar(tar) except Exception: logger.warning( "Failed to build the Submission/ display tree from the scored tar; " @@ -85,7 +186,8 @@ def _record_submission_tree(state: TaskState, tar: bytes) -> None: def _write_submission_sidecar(state: TaskState, attempt: int, tar: bytes) -> None: - """Write the scored ``Submission/`` tar to ``artifacts//attempt-N.tar``.""" + """Write the scored ``Submission/`` tar to ``artifacts//attempt-N.tar``, + or ``artifacts///attempt-N.tar`` for a swarm member.""" try: # private Inspect API -- no public way to get the log path from a scorer. from inspect_ai.log._samples import sample_active @@ -96,6 +198,9 @@ def _write_submission_sidecar(state: TaskState, attempt: int, tar: bytes) -> Non logger.warning("Could not get active sample; skipping submission sidecar") return sidecar_dir = UPath(active.log_location).parent / "artifacts" / state.uuid + member = current_member() + if member is not None: + sidecar_dir = sidecar_dir / member sidecar_dir.mkdir(parents=True, exist_ok=True) # Zero-pad the attempt index (PortBench's sidecar convention) so the # files sort lexicographically in attempt order. diff --git a/apn/solver.py b/apn/solver.py index d7783a2..6a5ef71 100644 --- a/apn/solver.py +++ b/apn/solver.py @@ -9,6 +9,7 @@ AgentContinue, AgentState, AgentSubmit, + agent, as_solver, deepagent, general, @@ -24,12 +25,20 @@ from inspect_ai.tool import Tool, ToolDef, ToolResult, ToolSource, text_editor, tool from inspect_ai.util import sandbox, store from inspect_boltons.tools import resources +from inspect_swarm import ( + Budget, + member, + shared_memory, + swarm, + swarm_resources, + swarm_tools, +) -from apn.checker import Claim +from apn.checker import Claim, comparator_sandbox_name from apn.layout import ENTRY_PATH from apn.limits import continue_unless_looping from apn.prompts import user_prompt -from apn.scorer import CLAIM_STORE_KEY +from apn.scorer import claim_store_key from apn.tools import bash logger = logging.getLogger(__name__) @@ -106,7 +115,7 @@ async def execute(claim: Claim) -> ToolResult: """ if claim not in ("proof", "disproof"): raise ValueError(f'claim must be "proof" or "disproof", got {claim!r}') - store().set(CLAIM_STORE_KEY, claim) + store().set(claim_store_key(), claim) return "Submitted." return execute @@ -115,6 +124,41 @@ async def execute(claim: Claim) -> ToolResult: AgentType = Literal["deep", "react"] +def _attempts(gated: bool) -> AgentAttempts: + # An effectively infinite max_attempts on the built-in Inspect react() and deepagent() + # solvers is used to implement "gated submission" (Inspect allows retries if the scorer + # returns INCORRECT). This is a bit of a hack: it would be clearer to implement this + # inside the solver. However, doing it like this lets us benefit automatically from + # upstream improvements in the built-in solvers; in particular, Inspect maintainers will + # keep these up to date with internal Inspect changes. + max_attempts = 99_999_999 if gated else 1 + return AgentAttempts( + attempts=max_attempts, incorrect_message=gated_incorrect_message + ) + + +def _prover_submit() -> AgentSubmit: + # Name it distinctly from any subagents' "submit" tool and keep the + # call in the message history. keep_in_messages=True stops the loop + # from folding the tool's return into the assistant message; the + # distinct name means the main loop's submission scan can never match + # a subagent's "submit" (no early-termination collision). + return AgentSubmit(tool=submit(), name="submit_proof", keep_in_messages=True) + + +# The default continue message is very generic ("proceed to the next step"), this one +# might be better at avoiding doom loops. +CONTINUE_MESSAGE = "Continue working on the problem." + + +def swarm_member_names(swarm_size: int | None) -> list[str] | None: + """The swarm's member names, or ``None`` for no swarm (``swarm_size`` unset, + zero or negative).""" + if swarm_size is None or swarm_size <= 0: + return None + return [f"prover-{i}" for i in range(1, swarm_size + 1)] + + def build_agent( agent_type: AgentType, *, @@ -157,64 +201,84 @@ def build_agent( ) +@agent +def _prover(agent_type: AgentType, gated: bool, swarm: bool = False) -> Agent: + """The prover loop, for a single agent or a swarm member. + + ``swarm_tools()`` does nothing outside a swarm. A member's budget tool is + ``swarm_resources`` (its own share of the budget), since ``resources`` + shows the sample's limits.""" + return build_agent( + agent_type, + tools=[ + text_editor(), + bash(timeout=300), + # TODO: make inspect_boltons' resources() report a swarm member's own + # budget via swarm_budget() (started on tadamcz/inspect_boltons branch + # swarm-aware-resources), then use it for everyone and drop this helper function. + swarm_resources() if swarm else resources(), + swarm_tools(), + ], + attempts=_attempts(gated), + submit=_prover_submit(), + on_continue=continue_unless_looping(CONTINUE_MESSAGE), + compaction=CompactionSummary(threshold=300_000), + ) + + @solver def lean_prover( agent_type: AgentType, gated: bool, literature: bool, util_module: str, + swarm_size: int | None = None, ) -> Solver: """ Args: gated: Gated submission (retry until correct or token/time limit). literature: Run with the offline arXiv corpus. - agent_type: Which agent loop to run. + agent_type: Which agent loop to run (each member's, in a swarm). util_module: The dataset pin's FC util module (``apn.dataset.fc_profile(...).util_module``), named in the prompt's import-integrity rule. + swarm_size: Run a swarm of this many members; ``None``, zero or + negative runs a single agent. Each member works in its own sandbox + (see ``swarm_member_names``) with its own comparator; the swarm's + budget is split equally. """ async def solve(state: TaskState, generate: Generate) -> TaskState: _warn_if_ignored_formalizations(state) sketch = state.metadata["sketch"] - await sandbox().write_file(ENTRY_PATH, sketch) - tools = [ - text_editor(), - bash(timeout=300), - resources(), - ] - - # An effectively infinite max_attempts on the built-in Inspect react() and deepagent() - # solvers is used to implement "gated submission" (Inspect allows retries if the scorer - # returns INCORRECT). This is a bit of a hack: it would be clearer to implement this - # inside the solver. However, doing it like this lets us benefit automatically from - # upstream improvements in the built-in solvers; in particular, Inspect maintainers will - # keep these up to date with internal Inspect changes. - if gated: - max_attempts = 99_999_999 + names = swarm_member_names(swarm_size) + if names is not None: + for name in names: + await sandbox(name).write_file(ENTRY_PATH, sketch) + agent = swarm( + members=[ + member( + _prover, + name=name, + args={"agent_type": agent_type, "gated": gated, "swarm": True}, + sandbox=name, + visible_sandboxes=[comparator_sandbox_name(name)], + ) + for name in names + ], + channels=[shared_memory( + # TODO: remove these if inspect_swarm changes their defaults to be more sensible + max_files=10_000, + max_file_bytes=1_000_000, + max_file_lines=100_000, + max_board_bytes=400_000_000, + )], + budget=Budget(split="equal", reserve=0.01), + ) else: - max_attempts = 1 - - agent = build_agent( - agent_type, - tools=tools, - attempts=AgentAttempts( - attempts=max_attempts, incorrect_message=gated_incorrect_message - ), - # Name it distinctly from any subagents' "submit" tool and keep the - # call in the message history. keep_in_messages=True stops the loop - # from folding the tool's return into the assistant message; the - # distinct name means the main loop's submission scan can never match - # a subagent's "submit" (no early-termination collision). - submit=AgentSubmit( - tool=submit(), name="submit_proof", keep_in_messages=True - ), - # The default continue message is very generic ("proceed to the next step"), this one - # might be better at avoiding doom loops. - on_continue=continue_unless_looping("Continue working on the problem."), - compaction=CompactionSummary(threshold=300_000), - ) + await sandbox().write_file(ENTRY_PATH, sketch) + agent = _prover(agent_type, gated) state.messages = [ ChatMessageUser( content=user_prompt(ENTRY_PATH, state.token_limit, literature, util_module), diff --git a/apn/task.py b/apn/task.py index 59d9087..2b21168 100644 --- a/apn/task.py +++ b/apn/task.py @@ -4,14 +4,14 @@ import re import tempfile from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, Sequence import yaml from inspect_ai import Task, task from apn import __version__ -from apn.solver import AgentType, lean_prover -from apn.checker import SandboxComparator +from apn.solver import AgentType, lean_prover, swarm_member_names +from apn.checker import SandboxComparator, comparator_sandbox_name from apn.dataset import ( ERDOS_DIR, FC100_DIR, @@ -109,10 +109,23 @@ def _build_section(target: str, fc_commit: str) -> dict[str, Any]: } -def get_compose_file_content(fc_commit: str, literature: bool = False) -> str: +def _sandbox_pairs(members: Sequence[str] | None) -> list[tuple[str, str]]: + """(agent service, comparator service) names: ``default`` and the single + comparator, or one pair per swarm member.""" + if members is None: + return [("default", comparator_sandbox_name(None))] + return [(member, comparator_sandbox_name(member)) for member in members] + + +def get_compose_file_content( + fc_commit: str, + literature: bool = False, + members: Sequence[str] | None = None, +) -> str: """The docker-backend sandbox config (local runs, CI tests). - Two services: the agent's workspace, and the trusted `comparator` verifier. + Two services per agent (see :func:`_sandbox_pairs`): the agent's workspace, + and the trusted `comparator` verifier. The comparator container needs no filesystem hardening of its own (comparator-migration-plan.md §3.1): comparator's landrun (Landlock) sandbox confines the untrusted solution build to writes in `.lake`, every @@ -121,34 +134,36 @@ def get_compose_file_content(fc_commit: str, literature: bool = False) -> str: reset-dotlake.sh restores a pristine `.lake`. """ agent_kind = _agent_image_kind(literature) - compose: dict[str, Any] = { - "services": { - "default": { - "image": f"{IMAGE_REPOSITORY}:{get_identifier_for_image(agent_kind, fc_commit)}", - "build": _build_section(agent_kind, fc_commit), - "init": True, - "entrypoint": list(SANDBOX_COMMAND), - "mem_limit": f"{AGENT_MEMORY_GIB}g", - "network_mode": "none", - }, - "comparator": { - "image": f"{IMAGE_REPOSITORY}:{get_identifier_for_image('comparator', fc_commit)}", - "build": _build_section("comparator", fc_commit), - "init": True, - "entrypoint": list(SANDBOX_COMMAND), - "mem_limit": f"{COMPARATOR_MEMORY_GIB}g", - "network_mode": "none", - }, + services: dict[str, Any] = {} + for agent, comparator in _sandbox_pairs(members): + services[agent] = { + "image": f"{IMAGE_REPOSITORY}:{get_identifier_for_image(agent_kind, fc_commit)}", + "build": _build_section(agent_kind, fc_commit), + "init": True, + "entrypoint": list(SANDBOX_COMMAND), + "mem_limit": f"{AGENT_MEMORY_GIB}g", + "network_mode": "none", } - } - return yaml.safe_dump(compose, sort_keys=False) + services[comparator] = { + "image": f"{IMAGE_REPOSITORY}:{get_identifier_for_image('comparator', fc_commit)}", + "build": _build_section("comparator", fc_commit), + "init": True, + "entrypoint": list(SANDBOX_COMMAND), + "mem_limit": f"{COMPARATOR_MEMORY_GIB}g", + "network_mode": "none", + } + return yaml.safe_dump({"services": services}, sort_keys=False) AGENT_EPHEMERAL_STORAGE_GIB = 20 COMPARATOR_EPHEMERAL_STORAGE_GIB = 8 -def get_values_file_content(fc_commit: str, literature: bool = False) -> str: +def get_values_file_content( + fc_commit: str, + literature: bool = False, + members: Sequence[str] | None = None, +) -> str: """The k8s/Hawk-backend sandbox config: chart-native agent-env values. Written directly in the Helm chart's vocabulary. k8s_sandbox could @@ -181,34 +196,35 @@ def resources(memory_gib: int, ephemeral_storage_gib: int) -> dict[str, Any]: "requests": {"ephemeral-storage": f"{ephemeral_storage_gib}Gi"}, } - values: dict[str, Any] = { - "services": { - "default": { - "image": f"{repository}:{get_identifier_for_image(agent_kind, fc_commit)}", - # Without this the chart defaults to a bare `tail -f /dev/null` - # as PID 1, which never reaps orphans (see SANDBOX_COMMAND). - "command": list(SANDBOX_COMMAND), - "networkIsolated": True, - "dnsRecord": True, - "resources": resources(AGENT_MEMORY_GIB, AGENT_EPHEMERAL_STORAGE_GIB), - }, - "comparator": { - "image": f"{repository}:{get_identifier_for_image('comparator', fc_commit)}", - "command": list(SANDBOX_COMMAND), - "runtimeClassName": "CLUSTER_DEFAULT", - "networkIsolated": True, - "dnsRecord": True, - "resources": resources( - COMPARATOR_MEMORY_GIB, COMPARATOR_EPHEMERAL_STORAGE_GIB - ), - }, + services: dict[str, Any] = {} + for agent, comparator in _sandbox_pairs(members): + services[agent] = { + "image": f"{repository}:{get_identifier_for_image(agent_kind, fc_commit)}", + # Without this the chart defaults to a bare `tail -f /dev/null` + # as PID 1, which never reaps orphans (see SANDBOX_COMMAND). + "command": list(SANDBOX_COMMAND), + "networkIsolated": True, + "dnsRecord": True, + "resources": resources(AGENT_MEMORY_GIB, AGENT_EPHEMERAL_STORAGE_GIB), } - } - return yaml.safe_dump(values, sort_keys=False) + services[comparator] = { + "image": f"{repository}:{get_identifier_for_image('comparator', fc_commit)}", + "command": list(SANDBOX_COMMAND), + "runtimeClassName": "CLUSTER_DEFAULT", + "networkIsolated": True, + "dnsRecord": True, + "resources": resources( + COMPARATOR_MEMORY_GIB, COMPARATOR_EPHEMERAL_STORAGE_GIB + ), + } + return yaml.safe_dump({"services": services}, sort_keys=False) def get_sandbox_config( - fc_commit: str, literature: bool, backend: SandboxBackend | None + fc_commit: str, + literature: bool, + backend: SandboxBackend | None, + members: Sequence[str] | None = None, ) -> tuple[SandboxBackend, str]: """The Inspect ``sandbox`` spec ``(type, config-file path)`` for a backend (``None``: see :func:`resolve_sandbox_backend`). @@ -216,7 +232,9 @@ def get_sandbox_config( Each backend gets its own generated artifact (``compose.yaml`` for docker, ``values.yaml`` for k8s -- k8s_sandbox treats any config file NOT named ``*compose.yaml``/``*compose.yml`` as chart values). Files are isolated in - per-(version, FC pin, variant) subdirs so they don't clobber each other. + per-(version, FC pin, variant, swarm size) subdirs so they don't + clobber each other. ``members``, a swarm's member names, gives each member + its own agent and comparator sandboxes. """ backend = resolve_sandbox_backend(backend) variant = "corpus" if literature else "closed-book" @@ -225,14 +243,15 @@ def get_sandbox_config( / _docker_tag_component(__version__) / f"fc_{fc_commit[:12]}" / variant + / ("single-agent" if members is None else f"swarm-{len(members)}") ) directory.mkdir(parents=True, exist_ok=True) if backend == "docker": path = directory / "compose.yaml" - content = get_compose_file_content(fc_commit, literature) + content = get_compose_file_content(fc_commit, literature, members) elif backend == "k8s": path = directory / "values.yaml" - content = get_values_file_content(fc_commit, literature) + content = get_values_file_content(fc_commit, literature, members) else: raise ValueError( f"Unknown sandbox_backend {backend!r}; expected 'docker' or 'k8s'." @@ -254,6 +273,7 @@ def apn_oeis( gated: bool = True, literature: bool = False, agent_type: AgentType = "react", + swarm_size: int | None = None, sandbox_backend: SandboxBackend | None = None, ) -> Task: """The Formal Conjectures autoformalized OEIS conjectures (492 samples). @@ -271,10 +291,15 @@ def apn_oeis( gated=gated, literature=literature, agent_type=agent_type, + swarm_size=swarm_size, util_module=fc_profile(pin).util_module, ), - scorer=proof_scorer(SandboxComparator()), - sandbox=get_sandbox_config(pin, literature, sandbox_backend), + scorer=proof_scorer( + SandboxComparator(), swarm=swarm_member_names(swarm_size) is not None + ), + sandbox=get_sandbox_config( + pin, literature, sandbox_backend, swarm_member_names(swarm_size) + ), ) @@ -284,6 +309,7 @@ def apn_fc100open( gated: bool = True, literature: bool = False, agent_type: AgentType = "react", + swarm_size: int | None = None, sandbox_backend: SandboxBackend | None = None, ) -> Task: name_list = load_subset(FC100_DIR, subset) if subset is not None else None @@ -294,10 +320,15 @@ def apn_fc100open( gated=gated, literature=literature, agent_type=agent_type, + swarm_size=swarm_size, util_module=fc_profile(pin).util_module, ), - scorer=proof_scorer(SandboxComparator()), - sandbox=get_sandbox_config(pin, literature, sandbox_backend), + scorer=proof_scorer( + SandboxComparator(), swarm=swarm_member_names(swarm_size) is not None + ), + sandbox=get_sandbox_config( + pin, literature, sandbox_backend, swarm_member_names(swarm_size) + ), ) @@ -307,6 +338,7 @@ def apn_erdos( gated: bool = True, literature: bool = False, agent_type: AgentType = "react", + swarm_size: int | None = None, sandbox_backend: SandboxBackend | None = None, ) -> Task: """The Bloom statement selection of Erdős problems.""" @@ -318,10 +350,15 @@ def apn_erdos( gated=gated, literature=literature, agent_type=agent_type, + swarm_size=swarm_size, util_module=fc_profile(pin).util_module, ), - scorer=proof_scorer(SandboxComparator()), - sandbox=get_sandbox_config(pin, literature, sandbox_backend), + scorer=proof_scorer( + SandboxComparator(), swarm=swarm_member_names(swarm_size) is not None + ), + sandbox=get_sandbox_config( + pin, literature, sandbox_backend, swarm_member_names(swarm_size) + ), ) @@ -331,6 +368,7 @@ def apn_personal_corresp( gated: bool = True, literature: bool = False, agent_type: AgentType = "react", + swarm_size: int | None = None, sandbox_backend: SandboxBackend | None = None, ) -> Task: """Open conjectures sent to us in personal correspondence, formalized by @@ -345,8 +383,13 @@ def apn_personal_corresp( gated=gated, literature=literature, agent_type=agent_type, + swarm_size=swarm_size, util_module=fc_profile(pin).util_module, ), - scorer=proof_scorer(SandboxComparator()), - sandbox=get_sandbox_config(pin, literature, sandbox_backend), + scorer=proof_scorer( + SandboxComparator(), swarm=swarm_member_names(swarm_size) is not None + ), + sandbox=get_sandbox_config( + pin, literature, sandbox_backend, swarm_member_names(swarm_size) + ), ) diff --git a/configs/erdos74-swarm4.yaml b/configs/erdos74-swarm4.yaml new file mode 100644 index 0000000..c79a52b --- /dev/null +++ b/configs/erdos74-swarm4.yaml @@ -0,0 +1,59 @@ +# Schema: https://github.com/METR/hawk/blob/main/hawk/api/EvalSetConfig.schema.json +name: erdos74-swarm4 + +tasks: + - package: git+ssh://git@github.com/epoch-research/LeanOpenProblems.git@swarm + name: apn + items: + - name: apn_erdos + args: + subset: bloom_selection + agent_type: deep + swarm_size: 4 + literature: true + sample_ids: + - Erdos74.erdos_74 +models: + - package: openai + name: openai + items: + - name: gpt-6-astra + args: + config: + reasoning_effort: "max" + max_retries: 7 + max_connections: 10 + +cost_limit: 400.0 +# Swarms use time_limit, not working_limit. Working time is wall-clock time +# minus the periods when any task waits (for a connection slot, a retry or a +# rate limit), with overlapping waits counted once, so with many concurrent +# members it can stall far behind the wall clock: working_limit is poorly +# suited to a swarm. A time limit does charge members for that waiting, so +# avoid contention when running a swarm: give max_connections room for every +# member running at once across samples, and mind rate limits. +time_limit: 86_400 # 24 * 60 * 60 + +# $ per 1M tokens; keys must match Inspect's resolved / names. +model_cost_config: + openai/gpt-6-astra: + input: 10.0 + output: 50.0 + input_cache_read: 1.0 + input_cache_write: 12.5 + +secrets: + - name: LEAN_OPEN_PROBLEMS_IMAGE_NAME + description: "The ECR repo containing the LeanOpenProblems sandbox images" + +runner: + memory: 200Gi + environment: + INSPECT_LOG_CONDENSE: "true" + # TODO: remove UV_EXCLUDE_NEWER once no longer needed (it circumvents the + # security cooldown). It admits inspect-swarm 0.2.0, released 2026-10-27. + UV_EXCLUDE_NEWER: "2026-10-28T00:00:00Z" + +packages: + - inspect-ai==0.3.283 + - semver>=3.0.0,<3.1.0 diff --git a/pyproject.toml b/pyproject.toml index c11c1ad..e6d141d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ license-files = ["LICENSE"] requires-python = ">=3.13,<3.14" dependencies = [ "inspect-boltons @ git+https://github.com/tadamcz/inspect_boltons@35b794e845f1748947a4f18efdfcb31dd0cf746a", + "inspect-swarm>=0.2.0,<0.3.0", ] [build-system] @@ -31,7 +32,7 @@ apn = "apn._registry" dev = [ "anthropic>=0.105.2", "hawk[cli]", - "inspect-ai>=0.3.263", + "inspect-ai>=0.3.283", "mypy>=2.1.0", "openai>=2.38.0", "pytest>=8.0", diff --git a/tests/test_checker.py b/tests/test_checker.py index 381ac1c..c8ce0ce 100644 --- a/tests/test_checker.py +++ b/tests/test_checker.py @@ -32,7 +32,8 @@ from inspect_ai.model import ModelName from inspect_ai.scorer import CORRECT, INCORRECT, Score, Target from inspect_ai.solver import TaskState -from inspect_ai.util import ExecResult, OutputLimitExceededError +from inspect_ai.util import ExecResult, OutputLimitExceededError, tool_state_scope +from inspect_ai.util._store import Store import apn.checker as checker_mod import apn.scorer as scorer_mod @@ -51,7 +52,13 @@ module_path, sanitize_submission, ) -from apn.scorer import CLAIM_STORE_KEY, proof_scorer +from apn.scorer import ( + CLAIM_STORE_KEY, + SWARM_LEDGER_KEY, + SWARM_RESULT_KEY, + claim_store_key, + proof_scorer, +) SPEC = "import Mathlib\ntheorem tgt : True := by sorry\ntheorem tgt.disproof : ¬ (type_of% @tgt) := sorry\n" @@ -709,3 +716,180 @@ async def test_scorer_writes_attempt_sidecar( sidecars = list((tmp_path / "artifacts").rglob("attempt-*.tar")) assert len(sidecars) == 1 assert sidecars[0].read_bytes() == tar + + +async def test_check_uses_the_member_s_own_comparator( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requested: list[str | None] = [] + + def fake_sandbox(name: str | None = None) -> ScriptedSandbox: + requested.append(name) + return ScriptedSandbox(comparator=_ok(_ACCEPT_OUT)) + + monkeypatch.setattr(checker_mod, "sandbox", fake_sandbox) + checker = SandboxComparator() + await checker.check(SPEC, SUBMISSION_TAR, decl="tgt", claim="proof") + with tool_state_scope("prover-1"): + await checker.check(SPEC, SUBMISSION_TAR, decl="tgt", claim="proof") + assert requested == ["comparator", "comparator-prover-1"] + + +def test_claim_store_key_is_per_tool_state_scope() -> None: + assert claim_store_key() == CLAIM_STORE_KEY + with tool_state_scope("prover-1"): + key_1 = claim_store_key() + with tool_state_scope("prover-2"): + key_2 = claim_store_key() + assert len({CLAIM_STORE_KEY, key_1, key_2}) == 3 + + +class TarChecker: + def __init__(self, passing: bytes) -> None: + self._passing = passing + self.calls: list[tuple[bytes, str]] = [] + + async def check( + self, spec: str, submission_tar: bytes, decl: str, claim: str + ) -> CheckOutcome: + self.calls.append((submission_tar, claim)) + ok = submission_tar == self._passing + last = "Your solution is okay!" if ok else "uncaught exception: a | b" + return CheckOutcome(ok=ok, stage="comparator", detail=f"Building\n{last}\n") + + +def _swarm_fixture( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[TaskState, dict[str, bytes], FakeStore]: + tars = { + name: _tar_of({"./Spec.lean": f"{name} work"}) + for name in ("prover-1", "prover-2", "prover-3") + } + store = FakeStore() + for name, claim in (("prover-1", "proof"), ("prover-2", "disproof")): + with tool_state_scope(name): + store.set(claim_store_key(), claim) + state = _state(store) + state.store.set( + SWARM_RESULT_KEY, + { + "members": [ + { + "name": name, + "sandbox": name, + "status": "submitted" if name == "prover-2" else "stopped", + } + for name in tars + ] + }, + ) + state.store.set(SWARM_LEDGER_KEY, {"stop_reason": "submitted", "ended_by": None}) + sandboxes = {name: FakeSandbox(tar) for name, tar in tars.items()} + + def fake_sandbox(name: str | None = None) -> FakeSandbox: + return sandboxes["prover-2" if name is None else name] + + monkeypatch.setattr(scorer_mod, "sandbox", fake_sandbox) + monkeypatch.setattr(scorer_mod, "store", lambda: store) + return state, tars, store + + +async def test_swarm_final_score_checks_every_member_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state, tars, _ = _swarm_fixture(monkeypatch) + checker = TarChecker(passing=tars["prover-2"]) + score = await proof_scorer(checker, swarm=True)(state, Target("")) + assert score is not None + assert score.value == CORRECT + assert score.metadata is not None + assert score.metadata["passed"] == ["prover-2"] + assert score.explanation == ( + "Swarm of 3: 1 members' work passes (prover-2). Stop reason: submitted.\n" + "\n" + "| Member | Status | Result | Claim | Stage | Checker |\n" + "| --- | --- | --- | --- | --- | --- |\n" + '| prover-1 | stopped | INCORRECT | called `submit_proof(claim="proof")` | comparator | uncaught exception: a \\| b |\n' + '| prover-2 | submitted | CORRECT | called `submit_proof(claim="disproof")` | comparator | Your solution is okay! |\n' + "| prover-3 | stopped | INCORRECT | did not call `submit_proof()`, defaulted to " + '`claim="proof"` | comparator | uncaught exception: a \\| b |' + ) + assert checker.calls == [ + (tars["prover-1"], "proof"), + (tars["prover-2"], "disproof"), + (tars["prover-3"], "proof"), + ] + assert {"submission_contents_prover-1", "submission_contents_prover-3"} <= set( + state.metadata + ) + + +async def test_swarm_final_score_incorrect_when_no_member_passes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state, _, _ = _swarm_fixture(monkeypatch) + state.store.set( + SWARM_LEDGER_KEY, {"stop_reason": "swarm_cap", "ended_by": "sample_limit:cost"} + ) + score = await proof_scorer(TarChecker(passing=b""), swarm=True)(state, Target("")) + assert score is not None + assert score.value == INCORRECT + assert score.explanation is not None + assert score.explanation.splitlines()[0] == ( + "Swarm of 3: no member's work passes. Stop reason: swarm_cap. " + "Ended by: sample_limit:cost." + ) + + +async def test_swarm_score_inside_a_member_checks_only_its_own_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state, tars, _ = _swarm_fixture(monkeypatch) + checker = TarChecker(passing=tars["prover-2"]) + with tool_state_scope("prover-2"): + score = await proof_scorer(checker, swarm=True)(state, Target("")) + assert score is not None + assert score.value == CORRECT + assert checker.calls == [(tars["prover-2"], "disproof")] + + +async def test_scorer_does_not_record_a_default_claim( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = Store() + monkeypatch.setattr( + scorer_mod, "sandbox", lambda *a, **k: FakeSandbox(_tar_of({"./Spec.lean": "x"})) + ) + monkeypatch.setattr(scorer_mod, "store", lambda: store) + checker = StubChecker(False) + await proof_scorer(checker)(_state(FakeStore()), Target("")) + assert checker.calls[0][3] == "proof" + assert CLAIM_STORE_KEY not in store.keys() + + +async def test_swarm_sidecars_are_per_member( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + import inspect_ai.log._samples as samples_mod + + monkeypatch.setattr( + samples_mod, + "sample_active", + lambda: SimpleNamespace(log_location=str(tmp_path / "run.eval")), + ) + state, tars, _ = _swarm_fixture(monkeypatch) + checker = TarChecker(passing=tars["prover-2"]) + with tool_state_scope("prover-2"): + await proof_scorer(checker, swarm=True)(state, Target("")) + await proof_scorer(checker, swarm=True)(state, Target("")) + root = tmp_path / "artifacts" / state.uuid + sidecars = { + path.relative_to(root).as_posix(): path.read_bytes() + for path in root.rglob("attempt-*.tar") + } + assert sidecars == { + "prover-1/attempt-00001.tar": tars["prover-1"], + "prover-2/attempt-00001.tar": tars["prover-2"], + "prover-2/attempt-00002.tar": tars["prover-2"], + "prover-3/attempt-00001.tar": tars["prover-3"], + } diff --git a/uv.lock b/uv.lock index 3babc27..eac932e 100644 --- a/uv.lock +++ b/uv.lock @@ -22,19 +22,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/61/0a2186c8ca53cf464767d4c4fb7fd9e23f48ce4547c2fa0d1c83a4d52410/agent_client_protocol-0.12.1-py3-none-any.whl", hash = "sha256:aaf3cc301ed87d9e2c4ccb91adfe1cd58784355f6ce7e513f50e7ca2391862ef", size = 85929, upload-time = "2026-08-16T14:08:21.307Z" }, ] -[[package]] -name = "aioboto3" -version = "15.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiobotocore", extra = ["boto3"] }, - { name = "aiofiles" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" }, -] - [[package]] name = "aiobotocore" version = "2.25.1" @@ -53,20 +40,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" }, ] -[package.optional-dependencies] -boto3 = [ - { name = "boto3" }, -] - -[[package]] -name = "aiofiles" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, -] - [[package]] name = "aiohappyeyeballs" version = "2.6.2" @@ -187,6 +160,7 @@ version = "0.1.11rc11" source = { editable = "." } dependencies = [ { name = "inspect-boltons" }, + { name = "inspect-swarm" }, ] [package.dev-dependencies] @@ -203,14 +177,17 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "inspect-boltons", git = "https://github.com/tadamcz/inspect_boltons?rev=35b794e845f1748947a4f18efdfcb31dd0cf746a" }] +requires-dist = [ + { name = "inspect-boltons", git = "https://github.com/tadamcz/inspect_boltons?rev=35b794e845f1748947a4f18efdfcb31dd0cf746a" }, + { name = "inspect-swarm", specifier = ">=0.2.0,<0.3.0" }, +] [package.metadata.requires-dev] dev = [ { name = "anthropic", specifier = ">=0.105.2" }, { name = "environs" }, { name = "hawk", extras = ["cli"], git = "https://github.com/METR/hawk?subdirectory=hawk&tag=v3.3.1" }, - { name = "inspect-ai", specifier = ">=0.3.263" }, + { name = "inspect-ai", specifier = ">=0.3.283" }, { name = "mypy", specifier = ">=2.1.0" }, { name = "openai", specifier = ">=2.38.0" }, { name = "pytest", specifier = ">=8.0" }, @@ -632,11 +609,11 @@ wheels = [ [[package]] name = "inspect-ai" -version = "0.3.263" +version = "0.3.285" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "agent-client-protocol" }, - { name = "aioboto3" }, + { name = "aiobotocore" }, { name = "anyio" }, { name = "beautifulsoup4" }, { name = "boto3" }, @@ -676,9 +653,9 @@ dependencies = [ { name = "zipp" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/03/3bd61990e197e6e0a68ebd7f1496d0b80af6c1966eea0e48fcfc1ed3b793/inspect_ai-0.3.263.tar.gz", hash = "sha256:54553ca8bfe711853414b49d962e492a60fd4cac8df935be47287a4b719f92b1", size = 35796833, upload-time = "2026-09-04T01:38:04.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/e0/595bf39b253826aaa8fd886e44ca41fc5fc9730e20aad044b8d5f927d32d/inspect_ai-0.3.285.tar.gz", hash = "sha256:78b927ddb6bf959f00cb9baffc5081eb4872067961000463696a92134c8d2025", size = 36578076, upload-time = "2026-10-25T14:29:12.556Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/c0/c12e505462462d5cc2b3fa98b6b2142c9589ebc0308144498f6e9de0c70c/inspect_ai-0.3.263-py3-none-any.whl", hash = "sha256:503e7ff509d77fcdf65989027a19fea966fb668deb88a234468c3290964fd86a", size = 34294681, upload-time = "2026-09-04T01:37:58.013Z" }, + { url = "https://files.pythonhosted.org/packages/b7/1f/ee409d639d66453f864c719c3d0d544cd234b3b2a0deef730c4cd4d55c32/inspect_ai-0.3.285-py3-none-any.whl", hash = "sha256:4658e36773e9d78116a69a3eb066f47698aa7ea9fc1c0146927a86f02b455aa6", size = 35251997, upload-time = "2026-10-25T14:29:08.272Z" }, ] [[package]] @@ -690,6 +667,20 @@ dependencies = [ { name = "tabulate" }, ] +[[package]] +name = "inspect-swarm" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "inspect-ai" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/21/46b5b1fcd993f0bc2dcf3a66a3b47bdec02932e15eb22a65dbfefa70d7f5/inspect_swarm-0.2.0.tar.gz", hash = "sha256:ef86c4ba67d3e1547650c3ec154323ed1c5f82130758ba9c95a95c886c04817b", size = 84228, upload-time = "2026-10-27T17:05:43.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3b/d5eb921541f4e52fae990a60716b2d128ce9efc2facae6de17df0df43db0/inspect_swarm-0.2.0-py3-none-any.whl", hash = "sha256:e11f61870418fe78050e3ea6d45fb7093dc3d3cd55c42d49dbd93c59a158864b", size = 96924, upload-time = "2026-10-27T17:05:42.238Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1085,11 +1076,11 @@ wheels = [ [[package]] name = "nest-asyncio2" -version = "1.7.2" +version = "1.7.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/2c/70e3b6bb2189cfd260280b7e1042bd622949a258663f95e887fada4ac1e6/nest_asyncio2-1.7.4.tar.gz", hash = "sha256:995b1409d926ec55e0876ea39a69bffdd8852cf1db3652c177e8c8ffdbcaf3cc", size = 16891, upload-time = "2026-10-07T22:31:50.507Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, + { url = "https://files.pythonhosted.org/packages/09/ed/559d2915dce85794bc0c36064043da170da50cd98746dcbddd85d5932017/nest_asyncio2-1.7.4-py3-none-any.whl", hash = "sha256:86e77b52c3d1496b294af3c944b44213b9b82a12a5ee590c0acc4ca4692ef6c7", size = 8543, upload-time = "2026-10-07T22:31:49.245Z" }, ] [[package]]