Source code for src.tools.problems

"""Problem type definitions and registry for EngiBench integration.

This is the SINGLE FILE to update when adding a new problem type:
1. Add to ProblemId Literal type
2. Add the engibench import path to _PROBLEM_IMPORTS

Everything else (SUPPORTED_PROBLEMS, PROBLEM_CLASSES, the ``Beams2D`` /
``Photonics2D`` re-exports) is derived automatically.

The engibench problem classes are imported *lazily*, on first lookup. Importing
``engibench.problems.beams2d.v1`` eagerly costs ~1.7 s (it drags in seaborn ->
scipy.stats -> IPython/ipywidgets, and datasets -> pandas/pyarrow), and this
module sits on the import path of every agent via ``src.utils.prompts`` ->
``src.tools`` -- including agents that never touch engibench (arxiv, rag,
search, cli). Deferring the class import keeps ``SUPPORTED_PROBLEMS`` (derived
from the Literal, not from the classes) free.
"""

import logging
import os
from collections.abc import Iterator, Mapping
from importlib import import_module
from importlib.util import find_spec
from typing import TYPE_CHECKING, Literal, get_args

if TYPE_CHECKING:
    from engibench.problems.beams2d.v1 import Beams2D  # type: ignore[import-untyped]
    from engibench.problems.photonics2d.v1 import (
        Photonics2D,  # type: ignore[import-untyped]
    )

logger = logging.getLogger(__name__)

# engibench itself is checked here even though its problem classes are resolved
# lazily below. Every tool that resolves one wraps its body in a broad
# ``except`` that returns ``{"success": False}`` -- create_problem even carries
# an ``except ImportError`` branch that was unreachable while this module
# imported engibench eagerly. The benchmark scorer reads that same flag as the
# agent's score, so a degraded install would otherwise be recorded as agent
# failure rather than a broken environment, and create_problem gates every
# downstream step of the engineering workflow. find_spec does not execute the
# package, so this keeps the fail-loud property at no import cost.
if find_spec("engibench") is None:  # pragma: no cover - install-time guard
    _msg = (
        "engibench is required by src.tools.problems but is not installed. It "
        "is declared in pyproject.toml; the problem classes are imported "
        "lazily, so without this check the failure would surface as a silent "
        "tool error rather than a broken install."
    )
    raise ImportError(_msg)

# Type alias for problem IDs - this is the single source of truth for supported problems
ProblemId = Literal["beams2d", "photonics2d"]

# Where each problem class lives: {problem_id: (module, class name)}.
# The class name doubles as the lazily re-exported module attribute
# (``from src.tools.problems import Beams2D`` keeps working).
_PROBLEM_IMPORTS: dict[str, tuple[str, str]] = {
    "beams2d": ("engibench.problems.beams2d.v1", "Beams2D"),
    "photonics2d": ("engibench.problems.photonics2d.v1", "Photonics2D"),
}


class _LazyProblemClasses(Mapping[str, type]):
    """``{problem_id: problem class}`` that imports engibench on first lookup.

    A drop-in replacement for the eager dict: membership tests, ``keys()``,
    ``list()`` and iteration only need the ids (no engibench import), while
    ``PROBLEM_CLASSES[problem_id]`` imports and returns the real class.
    """

    def __init__(self) -> None:
        self._resolved: dict[str, type] = {}

    def __getitem__(self, key: str) -> type:
        cached = self._resolved.get(key)
        if cached is not None:
            return cached
        # KeyError for an unknown id is raised here, before import_module, so an
        # invalid lookup never imports engibench.
        module_path, class_name = _PROBLEM_IMPORTS[key]
        resolved: type = getattr(import_module(module_path), class_name)
        self._resolved[key] = resolved
        return resolved

    def __contains__(self, key: object) -> bool:
        # Override Mapping's default (which calls __getitem__ and would import
        # engibench just to answer a membership test).
        return key in _PROBLEM_IMPORTS

    def __iter__(self) -> Iterator[str]:
        return iter(_PROBLEM_IMPORTS)

    def __len__(self) -> int:
        return len(_PROBLEM_IMPORTS)

    def __repr__(self) -> str:
        return f"_LazyProblemClasses({sorted(_PROBLEM_IMPORTS)})"


# Map problem IDs to their class implementations (lazy - see above)
PROBLEM_CLASSES: Mapping[str, type] = _LazyProblemClasses()

_CLASS_EXPORTS = {
    class_name: module_path for module_path, class_name in _PROBLEM_IMPORTS.values()
}


def __getattr__(name: str) -> type:
    """Lazily resolve the re-exported problem classes (PEP 562)."""
    module_path = _CLASS_EXPORTS.get(name)
    if module_path is None:
        msg = f"module {__name__!r} has no attribute {name!r}"
        raise AttributeError(msg)
    return getattr(import_module(module_path), name)  # type: ignore[no-any-return]


# Supported problem types - automatically derived from ProblemId
SUPPORTED_PROBLEMS = list(get_args(ProblemId))

# Env var the eval harness sets per training-code run to bind the whole pipeline
# to the registered benchmark's problem (see ``harness_problem_id``).
PROBLEM_ID_ENV_VAR = "TRAINING_CODE_PROBLEM_ID"


[docs] def harness_problem_id(problem_id: str) -> str: """Return the harness-injected ``problem_id`` if set, else ``problem_id``. The evaluation harness sets ``TRAINING_CODE_PROBLEM_ID`` per training-code run (from the registered benchmark, e.g. ``training_code_photonics2d`` -> ``photonics2d``) so the whole pipeline -- SLURM training, the local dry-run, contract validation, and model evaluation -- targets that problem without the prompt having to name it. Unset -> the tool/agent-supplied value (default ``beams2d``), so behaviour is unchanged when the harness does not set it. The override is stripped and validated against :data:`SUPPORTED_PROBLEMS`; an empty/whitespace or unsupported value (e.g. a typo in a manual export) is ignored with a warning and the supplied ``problem_id`` is kept, rather than silently binding the pipeline to a bogus problem. """ override = os.getenv(PROBLEM_ID_ENV_VAR) override = override.strip() if override else "" if not override: return problem_id if override not in SUPPORTED_PROBLEMS: logger.warning( "Ignoring %s=%r (not a supported problem: %s); using %r.", PROBLEM_ID_ENV_VAR, override, ", ".join(SUPPORTED_PROBLEMS), problem_id, ) return problem_id if override != problem_id: logger.debug( "%s overriding problem_id %r -> %r", PROBLEM_ID_ENV_VAR, problem_id, override, ) return override
# Export problem classes for type hints __all__ = [ "PROBLEM_CLASSES", "PROBLEM_ID_ENV_VAR", "SUPPORTED_PROBLEMS", "Beams2D", "Photonics2D", "ProblemId", "harness_problem_id", ]