Source code for pycsamt.forward.maxwell.batch

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Robust, resumable batch solving with retries and failure manifests.

:func:`solve_batch` is the single entry point. It solves many
:class:`~pycsamt.forward.maxwell.contracts.MaxwellProblem` instances
against one :class:`~pycsamt.forward.maxwell.backends.MaxwellBackend`,
optionally through a
:class:`~pycsamt.forward.maxwell.cache.MaxwellResultCache` for
resumability, and never lets one bad problem silently corrupt or halt
the whole run: every terminal failure is recorded in a
:class:`FailureManifest` instead of entering a training dataset
unnoticed.

Three concerns are deliberately kept separate:

Resumability
    Pass a :class:`~pycsamt.forward.maxwell.cache.MaxwellResultCache`;
    a repeated run over the same problems and cache directory skips
    everything already solved, including across process restarts.

Retries
    :class:`BatchPolicy` retries only exceptions considered transient
    (by default
    :class:`~pycsamt.forward.maxwell.adapters.BackendExecutionError`
    and
    :class:`~pycsamt.forward.maxwell.adapters.SolverConvergenceError`).
    A deterministic failure such as
    :class:`~pycsamt.forward.maxwell.adapters.IncompatibleProblemError`
    is recorded immediately without wasting attempts.

Failure manifests
    Every problem that exhausts its attempts becomes a
    :class:`ProblemFailure` inside the returned :class:`BatchReport`,
    which can be persisted with
    :meth:`FailureManifest.to_json_file` and inspected or filtered
    back out of a future run's input problems.
"""

from __future__ import annotations

import json
import re
import time
from collections.abc import Callable, Iterable, Mapping
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from .adapters import BackendExecutionError, SolverConvergenceError
from .backends import MaxwellBackend
from .cache import MaxwellResultCache
from .contracts import ForwardResult, MaxwellProblem

__all__ = [
    "BatchAbortedError",
    "BatchPolicy",
    "ProblemFailure",
    "FailureManifest",
    "BatchReport",
    "solve_batch",
]

_HEX_64 = re.compile(r"^[0-9a-f]{64}$")


def _problem_hash(value: str, name: str) -> str:
    hashed = str(value).strip().lower()
    if not _HEX_64.match(hashed):
        raise ValueError(f"{name} must be a 64-character hex digest.")
    return hashed


[docs] @dataclass(frozen=True) class BatchPolicy: """Configure retries and concurrency for :func:`solve_batch`. Parameters ---------- max_attempts : int, default=1 Attempts per problem, including the first. Values above one retry a solve that raised an exception in ``retry_on``. retry_backoff_s : float, default=1.0 Base delay before each retry; attempt *n* (n > 1) waits ``retry_backoff_s * n`` seconds before the next attempt. retry_on : tuple of exception types, optional Exception types treated as transient and worth retrying. Defaults to :class:`~pycsamt.forward.maxwell.adapters.BackendExecutionError` and :class:`~pycsamt.forward.maxwell.adapters.SolverConvergenceError` (which covers :class:`~pycsamt.forward.maxwell.external.ExternalProcessError` as a subclass). Anything else, including :class:`~pycsamt.forward.maxwell.adapters.IncompatibleProblemError` and :class:`~pycsamt.forward.maxwell.adapters.InvalidBackendResultError`, is recorded as a terminal failure after its first occurrence. Note that :class:`~pycsamt.forward.maxwell.adapters.BaseMaxwellAdapter` wraps ordinary exceptions raised inside a solve into ``BackendExecutionError`` by default (its own ``AdapterPolicy.wrap_backend_exceptions``), so a deterministic bug in a backend's own code is retried like any other ``BackendExecutionError`` unless that adapter was built with ``wrap_backend_exceptions=False``. stop_on_first_failure : bool, default=False Raise :class:`BatchAbortedError` as soon as any problem exhausts its attempts, instead of recording it and continuing. With ``max_workers > 1`` this only stops further submissions; futures already in flight are still allowed to finish. max_workers : int, default=1 Number of solves run concurrently in a thread pool. Values above one only help when the backend releases the GIL during its work (true of scipy sparse solves and of any :class:`~pycsamt.forward.maxwell.external.BaseExternalMaxwellAdapter`, which spends most of its time waiting on a subprocess). Examples -------- >>> policy = BatchPolicy(max_attempts=3, retry_backoff_s=0.5) >>> policy.max_attempts 3 """ max_attempts: int = 1 retry_backoff_s: float = 1.0 retry_on: tuple[type[Exception], ...] = ( BackendExecutionError, SolverConvergenceError, ) stop_on_first_failure: bool = False max_workers: int = 1 def __post_init__(self) -> None: if ( not isinstance(self.max_attempts, int) or isinstance(self.max_attempts, bool) or self.max_attempts < 1 ): raise ValueError("max_attempts must be a positive integer.") backoff = float(self.retry_backoff_s) if not backoff == backoff or backoff < 0: # NaN-safe check raise ValueError("retry_backoff_s must be finite and >= 0.") retry_on = tuple(self.retry_on) if not retry_on or not all( isinstance(value, type) and issubclass(value, Exception) for value in retry_on ): raise ValueError( "retry_on must be a non-empty tuple of exception types." ) if ( not isinstance(self.max_workers, int) or isinstance(self.max_workers, bool) or self.max_workers < 1 ): raise ValueError("max_workers must be a positive integer.") object.__setattr__(self, "retry_backoff_s", backoff) object.__setattr__(self, "retry_on", retry_on)
[docs] @dataclass(frozen=True) class ProblemFailure: """Record one problem's terminal solve failure. Parameters ---------- problem_hash : str Identity of the :class:`~pycsamt.forward.maxwell.contracts.MaxwellProblem` that failed. attempts : int Number of attempts made before this failure was recorded. error_type : str Exception class name, for quick triage without deserializing. message : str Human-readable exception message. Examples -------- >>> failure = ProblemFailure("a" * 64, 2, "BackendExecutionError", "x") >>> failure.attempts 2 """ problem_hash: str attempts: int error_type: str message: str def __post_init__(self) -> None: problem_hash = _problem_hash(self.problem_hash, "problem_hash") attempts = int(self.attempts) if attempts < 1: raise ValueError("attempts must be a positive integer.") error_type = str(self.error_type).strip() message = str(self.message).strip() if not error_type or not message: raise ValueError("error_type and message cannot be empty.") object.__setattr__(self, "problem_hash", problem_hash) object.__setattr__(self, "attempts", attempts) object.__setattr__(self, "error_type", error_type) object.__setattr__(self, "message", message)
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable representation. Returns ------- dict Problem identity, attempt count, and exception details. Examples -------- >>> ProblemFailure("a" * 64, 1, "X", "y").to_dict()["attempts"] 1 """ return { "problem_hash": self.problem_hash, "attempts": self.attempts, "error_type": self.error_type, "message": self.message, }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> ProblemFailure: """Restore a validated failure record. Parameters ---------- data : mapping State previously returned by :meth:`to_dict`. Returns ------- ProblemFailure Restored, validated record. Examples -------- >>> state = ProblemFailure("a" * 64, 1, "X", "y").to_dict() >>> ProblemFailure.from_dict(state).error_type 'X' """ return cls( data["problem_hash"], data["attempts"], data["error_type"], data["message"], )
[docs] @dataclass(frozen=True) class FailureManifest: """Ordered, JSON-persistable record of every terminal batch failure. Parameters ---------- failures : sequence of ProblemFailure, optional Failures in the order they were recorded. Problem hashes must be unique within one manifest. Examples -------- >>> failure = ProblemFailure("a" * 64, 1, "X", "boom") >>> manifest = FailureManifest((failure,)) >>> len(manifest), bool(manifest) (1, True) """ failures: tuple[ProblemFailure, ...] = () def __post_init__(self) -> None: failures = tuple(self.failures) if any(not isinstance(value, ProblemFailure) for value in failures): raise TypeError("failures must contain ProblemFailure values.") hashes = [value.problem_hash for value in failures] if len(set(hashes)) != len(hashes): raise ValueError("problem hashes must be unique in one manifest.") object.__setattr__(self, "failures", failures)
[docs] @property def hashes(self) -> frozenset[str]: """Return every failed problem's hash. Returns ------- frozenset of str Set suitable for filtering a future run's input problems. Examples -------- >>> failure = ProblemFailure("a" * 64, 1, "X", "boom") >>> "a" * 64 in FailureManifest((failure,)).hashes True """ return frozenset(value.problem_hash for value in self.failures)
def __len__(self) -> int: return len(self.failures) def __bool__(self) -> bool: return bool(self.failures) def __contains__(self, problem: MaxwellProblem) -> bool: return problem.problem_hash in self.hashes
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable, schema-versioned representation. Returns ------- dict Every failure, in recorded order. Examples -------- >>> FailureManifest().to_dict()["schema_version"] 1 """ return { "schema_version": 1, "failures": [value.to_dict() for value in self.failures], }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> FailureManifest: """Restore a validated manifest. Parameters ---------- data : mapping State previously returned by :meth:`to_dict`. Returns ------- FailureManifest Restored manifest. Raises ------ ValueError If the schema version is unsupported. Examples -------- >>> state = FailureManifest().to_dict() >>> FailureManifest.from_dict(state).failures () """ if data.get("schema_version") != 1: raise ValueError("unsupported FailureManifest schema version.") return cls( tuple( ProblemFailure.from_dict(value) for value in data.get("failures", ()) ) )
[docs] def to_json_file(self, path: str | Path) -> Path: """Write this manifest as indented, deterministic JSON. Parameters ---------- path : str or pathlib.Path Destination file. Returns ------- pathlib.Path The destination path. Examples -------- >>> from tempfile import TemporaryDirectory >>> failure = ProblemFailure("a" * 64, 1, "X", "boom") >>> manifest = FailureManifest((failure,)) >>> with TemporaryDirectory() as directory: ... target = Path(directory) / "failures.json" ... _ = manifest.to_json_file(target) ... restored = FailureManifest.from_json_file(target) >>> restored == manifest True """ target = Path(path) target.write_text( json.dumps(self.to_dict(), sort_keys=True, indent=2), encoding="utf-8", ) return target
[docs] @classmethod def from_json_file(cls, path: str | Path) -> FailureManifest: """Load a manifest written by :meth:`to_json_file`. Parameters ---------- path : str or pathlib.Path Source file. Returns ------- FailureManifest Restored manifest. Examples -------- See :meth:`to_json_file` for a complete round trip. """ data = json.loads(Path(path).read_text(encoding="utf-8")) return cls.from_dict(data)
[docs] @dataclass(frozen=True) class BatchReport: """Summarize one :func:`solve_batch` run. Parameters ---------- total : int Number of problems submitted to the batch. Can exceed ``len(solved) + len(failed)`` when ``BatchPolicy.stop_on_first_failure`` ended the run before every problem was attempted. solved : tuple of str Problem hashes with a valid result, whether freshly computed or already cached. cache_hits : tuple of str Subset of ``solved`` that were already present in the cache (skipped re-solving). Empty when no cache was used. failed : FailureManifest Every problem that exhausted its attempts. Examples -------- >>> report = BatchReport(1, ("a" * 64,), (), FailureManifest()) >>> report.success_fraction 1.0 """ total: int solved: tuple[str, ...] cache_hits: tuple[str, ...] failed: FailureManifest def __post_init__(self) -> None: total = int(self.total) if total < 0: raise ValueError("total must be non-negative.") solved = tuple(self.solved) cache_hits = tuple(self.cache_hits) if not isinstance(self.failed, FailureManifest): raise TypeError("failed must be a FailureManifest.") if len(solved) + len(self.failed) > total: raise ValueError("len(solved) + len(failed) cannot exceed total.") if not set(cache_hits) <= set(solved): raise ValueError("cache_hits must be a subset of solved.") object.__setattr__(self, "total", total) object.__setattr__(self, "solved", solved) object.__setattr__(self, "cache_hits", cache_hits)
[docs] @property def success_fraction(self) -> float: """Return the fraction of submitted problems that were solved. Returns ------- float ``len(solved) / total``; ``1.0`` when ``total`` is zero. Examples -------- >>> BatchReport(0, (), (), FailureManifest()).success_fraction 1.0 """ if self.total == 0: return 1.0 return len(self.solved) / self.total
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable representation. Returns ------- dict Totals, solved/cache-hit hashes, and the failure manifest. Examples -------- >>> BatchReport(0, (), (), FailureManifest()).to_dict()["total"] 0 """ return { "total": self.total, "solved": list(self.solved), "cache_hits": list(self.cache_hits), "success_fraction": self.success_fraction, "failed": self.failed.to_dict(), }
[docs] class BatchAbortedError(RuntimeError): """Raised when ``BatchPolicy.stop_on_first_failure`` aborts early. Parameters ---------- report : BatchReport Partial report covering every problem resolved before the abort; every problem submitted after the triggering failure (in the sequential case) was never attempted. Examples -------- >>> report = BatchReport(1, (), (), FailureManifest()) >>> error = BatchAbortedError(report) >>> error.report is report True """ def __init__(self, report: BatchReport) -> None: super().__init__( f"batch aborted after {len(report.failed)} failure(s); " f"{len(report.solved)}/{report.total} problems solved " "before stopping." ) self.report = report
def _solve_one( problem: MaxwellProblem, backend: MaxwellBackend, cache: MaxwellResultCache | None, policy: BatchPolicy, ) -> tuple[ForwardResult, bool] | ProblemFailure: attempts = 0 last_error: Exception | None = None for attempt in range(1, policy.max_attempts + 1): attempts = attempt if attempt > 1: time.sleep(policy.retry_backoff_s * attempt) cached = cache is not None and cache.contains(problem) try: if cache is not None: result = cache.get_or_solve(problem, backend) else: result = backend.solve(problem) return result, cached except policy.retry_on as exc: last_error = exc continue except Exception as exc: # recorded as a failure, not swallowed return ProblemFailure( problem.problem_hash, attempt, type(exc).__name__, str(exc) ) assert last_error is not None return ProblemFailure( problem.problem_hash, attempts, type(last_error).__name__, str(last_error), )
[docs] def solve_batch( problems: Iterable[MaxwellProblem], backend: MaxwellBackend, *, cache: MaxwellResultCache | None = None, policy: BatchPolicy | None = None, on_result: Callable[[MaxwellProblem, ForwardResult], None] | None = None, on_failure: Callable[[MaxwellProblem, ProblemFailure], None] | None = None, ) -> BatchReport: """Solve many problems robustly, with retries and a failure manifest. Parameters ---------- problems : iterable of MaxwellProblem Problems to solve. Consumed fully before returning; order is preserved in the returned report only through per-problem hashes, not positionally. backend : MaxwellBackend Conforming backend used for every problem. cache : MaxwellResultCache or None, optional When given, problems already cached are skipped (resumability) and freshly computed results are written back to it. policy : BatchPolicy or None, optional Retry, concurrency, and abort configuration. Defaults to :class:`BatchPolicy`. on_result : callable, optional Invoked with ``(problem, result)`` for every problem that succeeds, in completion order. Useful for streaming results into a dataset without holding them all in memory. on_failure : callable, optional Invoked with ``(problem, failure)`` for every problem that exhausts its attempts, in completion order. Returns ------- BatchReport Totals, solved/cache-hit problem hashes, and a :class:`FailureManifest` of every terminal failure. Raises ------ TypeError If ``backend`` or ``policy`` has the wrong type. BatchAbortedError If ``policy.stop_on_first_failure`` is set and any problem exhausts its attempts. Examples -------- >>> import numpy as np >>> from pycsamt.forward.maxwell import ( ... CallableMaxwellAdapter, ... BackendCapabilities, ... ForwardResult, ... MaxwellMesh, ... MaxwellProblem, ... ReceiverSet, ... SolverDiagnostics, ... ) >>> mesh = MaxwellMesh([0, 1, 2], [0, 1, 2]) >>> problem = MaxwellProblem( ... mesh, np.ones((2, 2)), [1], ReceiverSet([[0.5, 0]], ["S"]) ... ) >>> def solver(value): ... diagnostics = SolverDiagnostics([[True]], [[0]], [[0]], 0) ... return ForwardResult( ... value.problem_hash, ... value.frequencies_hz, ... value.receivers.names, ... value.components, ... [[[1j, 1j]]], ... None, ... "demo", ... "1", ... diagnostics, ... ) >>> cap = BackendCapabilities("demo", "1", (2,), ("zxy", "zyx")) >>> backend = CallableMaxwellAdapter(cap, solver) >>> report = solve_batch([problem], backend) >>> report.success_fraction 1.0 """ if not isinstance(backend, MaxwellBackend): raise TypeError("backend must implement MaxwellBackend.") policy = BatchPolicy() if policy is None else policy if not isinstance(policy, BatchPolicy): raise TypeError("policy must be a BatchPolicy or None.") problem_list = list(problems) solved: list[str] = [] cache_hits: list[str] = [] failures: list[ProblemFailure] = [] def _handle(problem: MaxwellProblem, outcome) -> None: if isinstance(outcome, ProblemFailure): failures.append(outcome) if on_failure is not None: on_failure(problem, outcome) return result, cached = outcome solved.append(problem.problem_hash) if cached: cache_hits.append(problem.problem_hash) if on_result is not None: on_result(problem, result) if policy.max_workers <= 1: for problem in problem_list: outcome = _solve_one(problem, backend, cache, policy) _handle(problem, outcome) if policy.stop_on_first_failure and failures: break else: with ThreadPoolExecutor(max_workers=policy.max_workers) as pool: pending = {} aborting = False for problem in problem_list: if aborting: break future = pool.submit( _solve_one, problem, backend, cache, policy ) pending[future] = problem for future in as_completed(pending): _handle(pending[future], future.result()) if policy.stop_on_first_failure and failures: aborting = True report = BatchReport( len(problem_list), tuple(solved), tuple(cache_hits), FailureManifest(tuple(failures)), ) if policy.stop_on_first_failure and failures: raise BatchAbortedError(report) return report