from __future__ import annotations
import inspect
import re
import uuid as _uuid_mod
from collections.abc import Callable, Mapping
from functools import wraps
from pathlib import Path
from typing import Any
from pipefunc import PipeFunc, Pipeline
from CADETProcess.parameter_space.space import ParameterSpace
from .errors import EvaluationFailure
__all__ = ["EvaluationPipeline"]
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_CONTEXT_ARG = "__eval_context__"
# Stable UUID slot for the zero-evaluation-object mode: cache entries are
# keyed on (x_key, uuid), and without an object the x_key alone identifies
# the evaluation. A fixed constant keeps keys stable across processes.
_NO_OBJECT_UUID = "__no_evaluation_object__"
class _EvaluationContext:
"""Stable, serializable cache key combining a parameter assignment and an evaluation object.
Pipefunc caches by argument value. Evaluation objects are mutable, so passing
the raw object would produce stale cache hits whenever ``set_values`` mutates it.
Wrapping ``(x_key, obj_uuid)`` gives a key that is:
- correct: distinct for different x or different objects
- stable: UUID survives pickling, so disk/shared-memory caches work across processes
"""
__slots__ = ("x_key", "obj", "_uuid")
def __init__(self, x_key: tuple, obj: Any, obj_uuid: str) -> None:
self.x_key = x_key
self.obj = obj
self._uuid = obj_uuid
def __hash__(self) -> int:
return hash((self.x_key, self._uuid))
def __eq__(self, other: object) -> bool:
return (
isinstance(other, _EvaluationContext)
and self.x_key == other.x_key
and self._uuid == other._uuid
)
def __reduce__(self) -> tuple:
# Only x_key and _uuid define identity (see __hash__/__eq__).
# obj is runtime-only and may not be picklable (e.g. local lambdas in
# event transforms). Cross-process cache lookups only need the key.
return (_EvaluationContext, (self.x_key, None, self._uuid))
def _validate_identifier(name: str) -> None:
if not _IDENTIFIER_RE.match(name):
raise ValueError(f"{name!r} is not a valid Python identifier")
def _wrap_with_failure_propagation(func: Callable, stage: str) -> Callable:
"""Return a wrapper that propagates EvaluationFailure and catches exceptions.
If any positional or keyword argument is an `EvaluationFailure`, that failure
is returned immediately without calling `func`. Otherwise `func` is called
normally; any exception is caught and returned as a new `EvaluationFailure`.
Unclassified exceptions default to `recoverable=True` (transient: do not
cache), since a misclassified transient failure permanently poisons a
legitimately good x, while the opposite mistake only costs an occasional
re-crash at a duplicate x. A node author who knows a failure is
deterministic (CADET solver failure, validation rejection) classifies it
explicitly by setting `recoverable = False` on the raised exception.
"""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
for v in (*args, *kwargs.values()):
if isinstance(v, EvaluationFailure):
return v
try:
return func(*args, **kwargs)
except Exception as e:
recoverable = getattr(e, "recoverable", True)
return EvaluationFailure(stage=stage, reason=str(e), exc=e, recoverable=recoverable)
return wrapper
def _make_injection_wrapper(func: Callable, requires: list[str]) -> Callable:
"""Wrap `func` so its parameter names match `requires`.
Pipefunc wires nodes by matching argument names to upstream `output_name` values.
When a user function's argument names do not match the upstream output names,
this wrapper creates an intermediate function whose signature does match,
forwarding the arguments positionally to `func`.
The `requires` entries must be valid Python identifiers.
"""
for name in requires:
_validate_identifier(name)
@wraps(func)
def wrapper(**kwargs: Any) -> Any:
return func(*(kwargs[name] for name in requires))
sig_params = [
inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY)
for name in requires
]
wrapper.__signature__ = inspect.Signature(sig_params)
return wrapper
def _make_context_wrapper(func: Callable) -> Callable:
"""Wrap a root node so it receives ``_EvaluationContext`` and extracts the obj.
The wrapped function's signature is ``(__eval_context__,)`` so pipefunc wires
it to the single pipeline root arg. The user's function is called with the
raw evaluation object extracted from the context.
"""
def wrapper(**kwargs: Any) -> Any:
ctx: _EvaluationContext = kwargs[_CONTEXT_ARG]
return func(ctx.obj)
wrapper.__signature__ = inspect.Signature(
[inspect.Parameter(_CONTEXT_ARG, inspect.Parameter.KEYWORD_ONLY)]
)
return wrapper
def _guard_recoverable_writes(cache: Any) -> None:
"""Patch *cache* so recoverable ``EvaluationFailure`` values are never written.
pipefunc's per-node cache has no native hook to skip a write conditionally
on the result value, so the cache's own ``put`` is wrapped in place.
Recoverable (transient) failures must not be cached: caching one would
permanently poison a legitimately good x across restarts. Deterministic
failures (``recoverable=False``) still cache normally, since re-running a
deterministic crash just crashes again.
"""
original_put = cache.put
def put(key: Any, value: Any, *args: Any, **kwargs: Any) -> None:
if isinstance(value, EvaluationFailure) and value.recoverable:
return
original_put(key, value, *args, **kwargs)
cache.put = put
def _make_node(
func: Callable,
output_name: str,
requires: list[str] | None,
cache: bool = True,
) -> PipeFunc:
"""Build a `PipeFunc` node with failure propagation and optional arg injection."""
safe = _wrap_with_failure_propagation(func, stage=output_name)
if requires is not None:
node_func = _make_injection_wrapper(safe, requires)
else:
node_func = _make_context_wrapper(safe)
return PipeFunc(node_func, output_name=output_name, cache=cache)
[docs]
class EvaluationPipeline:
"""DAG-based evaluation engine backed by `pipefunc.Pipeline`.
Parameterized by a `ParameterSpace`; cannot be constructed without one.
`evaluate(x)` writes `x` into the evaluation objects via `space.set_values`,
then runs the registered node graph and returns all (or selected) named outputs.
`pipefunc` is an internal implementation detail. No `PipeFunc` or `Pipeline`
objects appear in the public API.
Parameters
----------
space : ParameterSpace
Owns the evaluation objects and knows how to write parameter values
into them. `evaluate` decodes the vector and delegates to
`space.set_values`.
Examples
--------
>>> pipeline = EvaluationPipeline(space)
>>> pipeline.add_evaluator(simulate, output_name="simulation_results")
>>> pipeline.add_evaluator(
... fractionate, output_name="fractionation_results",
... requires=["simulation_results"],
... )
>>> results = pipeline.evaluate({"length": 0.5})
>>> results["fractionation_results"]
"""
def __init__(
self,
space: ParameterSpace,
cache_dir: str | Path | None = None,
) -> None:
if not isinstance(space, ParameterSpace):
raise TypeError(f"Expected ParameterSpace, got {type(space).__name__}")
self._space = space
self._cache_dir: Path | None = Path(cache_dir) if cache_dir is not None else None
self._nodes: list[PipeFunc] = []
self._output_names: list[str] = []
self._pipeline: Pipeline | None = None
self._obj_uuids: dict[int, str] = {} # id(obj) → stable UUID
# ------------------------------------------------------------------
# Registration
[docs]
def add_evaluator(
self,
func: Callable,
output_name: str,
requires: list[str] | None = None,
cache: bool = True,
) -> None:
"""Register a callable as a named node in the evaluation DAG.
Parameters
----------
func : callable
The function to register. Its return value becomes the named output.
When `requires` is None, `func`'s own argument names must match
upstream `output_name` values so pipefunc can wire them.
When `requires` is given, a wrapper is generated whose argument names
match the `requires` list, so `func`'s argument names are irrelevant.
output_name : str
Name of this node's output in the DAG. Must be a valid Python
identifier and unique within this pipeline.
requires : list[str], optional
Ordered list of upstream output names to inject as positional arguments
to `func`. When given, `func` must accept ``len(requires)`` positional
arguments. When None, pipefunc wires `func` by its own argument names.
cache : bool
Whether to cache the output of this node. Defaults to True.
Pass False for nodes with side effects (e.g. callbacks) where
repeated execution is intentional and results need not be stored.
Raises
------
TypeError
If `func` is not callable.
ValueError
If `output_name` is already registered or is not a valid identifier,
or if any entry in `requires` is not a valid identifier.
"""
if not callable(func):
raise TypeError(f"Expected callable, got {type(func).__name__}")
_validate_identifier(output_name)
if output_name in self._output_names:
raise ValueError(f"output_name {output_name!r} is already registered")
node = _make_node(func, output_name, requires, cache=cache)
self._nodes.append(node)
self._output_names.append(output_name)
self._pipeline = None # invalidate cached pipeline
# ------------------------------------------------------------------
# Evaluation
@property
def output_names(self) -> list[str]:
"""list[str]: All registered output names, in registration order."""
return list(self._output_names)
def _get_pipeline(self) -> Pipeline:
"""Return the cached pipeline, building it if necessary.
The pipeline is rebuilt whenever ``add_evaluator`` is called after a
previous build. Within a stable registration phase the same instance
is reused across ``evaluate`` calls.
"""
if self._pipeline is None:
if not self._nodes:
raise RuntimeError(
"No evaluators registered. Call add_evaluator before evaluate."
)
if self._cache_dir is not None:
self._cache_dir.mkdir(parents=True, exist_ok=True)
self._pipeline = Pipeline(
list(self._nodes),
cache_type="disk",
# lru_shared=False keeps the in-memory LRU a plain dict; the
# shared variant backs it with a multiprocessing.Manager
# process that is never torn down and deadlocks interpreter
# exit on Python 3.12. A process-shared cache buys nothing
# here: parallelism is per-individual, so each worker holds
# its own pipeline copy.
cache_kwargs={
"cache_dir": str(self._cache_dir),
"lru_shared": False,
},
validate_type_annotations=False,
)
else:
self._pipeline = Pipeline(
list(self._nodes),
cache_type="hybrid",
# shared=False: plain in-process dict instead of a
# Manager-backed one. See the disk branch above.
cache_kwargs={"shared": False},
validate_type_annotations=False,
)
_guard_recoverable_writes(self._pipeline.cache)
return self._pipeline
def _obj_uuid(self, obj: Any) -> str:
"""Return the stable UUID for *obj*, assigning one on first encounter."""
key = id(obj)
if key not in self._obj_uuids:
self._obj_uuids[key] = str(_uuid_mod.uuid4())
return self._obj_uuids[key]
[docs]
def evaluate(
self,
assignment: Mapping[str, Any],
targets: list[str] | None = None,
bypass_cache: bool = False,
evaluation_objects: list[Any] | None = None,
) -> dict[str, Any]:
"""Set parameter values and run the evaluation graph.
The `Pipeline` instance is reused across calls. Cache invalidation is
handled by ``_EvaluationContext``: each call constructs a context keyed on
``(x_key, obj_uuid)``, where ``x_key`` is the registration-ordered
``(name, value)`` tuple of the independent assignment. Results for
different assignments are naturally distinct cache entries without manual
cache clearing; assignments that only differ in a categorical value are
distinct entries too. Intermediate nodes shared by multiple targets
within a single call are computed only once.
When the parameter space has no evaluation objects, the assignment
itself becomes the root: root nodes (those without ``requires``)
receive the assignment mapping instead of an evaluation object, and
the run follows the single-result return convention.
Parameters
----------
assignment : Mapping
Values for the independent parameters by name, in physical units.
Order-insensitive. Numeric vectors are an encoding owned by
``TransformedSpace``; decode first:
``pipeline.evaluate(space.transformed_space.decode(x))``.
targets : list[str], optional
Output names to compute. `None` computes all registered outputs.
Requesting a subset exploits pipefunc's lazy evaluation: only the
subgraph needed for the requested outputs is executed.
bypass_cache : bool
When True, clear the pipeline cache before evaluating so all nodes
are recomputed from scratch. Useful for debugging to confirm that
results are not stale.
evaluation_objects : list, optional
Restrict the run to these registered evaluation objects. `None`
runs all registered objects. The return convention follows the
selected subset: one object gives plain values, several give lists.
Returns
-------
dict[str, Any]
Mapping from output name to result. For a single evaluation object
(or none registered) the values are plain results (or
`EvaluationFailure`). For multiple evaluation objects the values
are lists indexed by evaluation object. Results may be
`EvaluationFailure` instances when a node failed.
"""
if not isinstance(assignment, Mapping):
raise TypeError(
"evaluate takes a named assignment (Mapping of parameter name "
"to value); numeric vectors are an encoding owned by "
"TransformedSpace — decode first: "
"pipeline.evaluate(space.transformed_space.decode(x))."
)
self._space.set_values(assignment)
x_key: tuple = tuple(
(p.name, assignment[p.name])
for p in self._space.independent_parameters
)
if bypass_cache:
# Append a nonce so every node sees a guaranteed cache miss.
# Existing entries for other assignments are unaffected.
x_key = x_key + (_uuid_mod.uuid4().hex,)
if targets is None:
targets = self._output_names
else:
unknown = [t for t in targets if t not in self._output_names]
if unknown:
raise ValueError(f"Unknown target(s): {unknown}")
eval_objs = self._space.evaluation_objects
if evaluation_objects is not None:
if not evaluation_objects:
raise ValueError("evaluation_objects must not be empty; pass None for all.")
unknown_objs = [o for o in evaluation_objects if o not in eval_objs]
if unknown_objs:
raise ValueError(f"Unknown evaluation object(s): {unknown_objs}")
eval_objs = list(evaluation_objects)
pipeline = self._get_pipeline()
def _run_for_ctx(ctx: _EvaluationContext) -> dict[str, Any]:
"""Run all targets for one root context in a single pipeline call."""
if len(targets) == 1:
value = pipeline(targets[0], **{_CONTEXT_ARG: ctx})
return {targets[0]: value}
values = pipeline.run(targets, kwargs={_CONTEXT_ARG: ctx})
assert len(values) == len(targets), (
f"Expected {len(targets)} results from pipeline.run, got {len(values)}"
)
return dict(zip(targets, values))
if not eval_objs:
# Zero-evaluation-object mode: the assignment itself is the root.
ctx = _EvaluationContext(x_key, dict(assignment), _NO_OBJECT_UUID)
return _run_for_ctx(ctx)
def _run_for(obj: Any) -> dict[str, Any]:
return _run_for_ctx(_EvaluationContext(x_key, obj, self._obj_uuid(obj)))
if len(eval_objs) == 1:
return _run_for(eval_objs[0])
results: dict[str, list] = {t: [] for t in targets}
for obj in eval_objs:
for t, v in _run_for(obj).items():
results[t].append(v)
return results