Source code for phenotypic.tune._evaluation._builder

"""Turn a sampled parameter combo into a runnable ``ImagePipeline``.

The combo is a flat ``{root-relative-key: value}`` mapping (the same keys a
``SearchSpace`` knob carries; see master §5). ``build_pipeline`` clones the base
pipeline, overlays each key onto the op it addresses by **fresh reconstruction**
(full validation — byte-compatible with the legacy sweep's
``operation_class(**merged)``), and drops ops toggled off via ``__enabled__``.
"""
from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any, Union, cast

from pydantic import ValidationError
from pydantic_core import InitErrorDetails

from phenotypic import ImagePipeline

#: Matches a nested list-index segment ``name[i]`` (e.g. ``detectors[0]``) and
#: captures the field name and the integer index. The depth cap is 1: a key may
#: carry **exactly one** such segment (a second ``[i]`` is a depth error).
_NESTED_SEGMENT_RE = re.compile(r"^(?P<field>[^\[\]]+)\[(?P<index>\d+)\]$")
#: Matches any segment that *opens* a bracket — used to detect a malformed or
#: second ``[i]`` segment after the first nested segment has been consumed.
_BRACKET_SEGMENT_RE = re.compile(r"\[")


@dataclass(frozen=True)
class FlatKey:
    """A scalar field on the op at ``position``: ``"<pos>.<field>"``."""

    position: int
    field: str


@dataclass(frozen=True)
class PresenceKey:
    """A presence toggle: ``"<pos>.__enabled__"`` or ``"<pos>.<Class>.__enabled__"``.

    ``cls_name`` is the class segment from the three-part form (validated against
    the op actually at ``position``), or ``None`` for the bare two-part form.
    """

    position: int
    cls_name: str | None


@dataclass(frozen=True)
class NestedKey:
    """A one-level nested-op field: ``"<pos>.<field>[<index>].<leaf>"``.

    Addresses ``ordered_ops[position].<field>[index].<leaf>`` — a scalar field on
    the operation occupying slot ``index`` of the parent's operation-valued list
    field. The depth cap is 1: ``leaf`` may not itself carry a ``[i]`` segment.
    """

    position: int
    field: str
    index: int
    leaf: str


#: The union of typed parse results ``_parse_key`` may return.
ParsedKey = Union[FlatKey, PresenceKey, NestedKey]

_FILAMENTOUS_SCENE_PARENTS = {"max_colony_radius_px", "min_branch_width_px"}
_FILAMENTOUS_DERIVED_FIELDS = {
    "gauss_sigma",
    "tile_size",
    "tile_overlap",
    "pct_min_wavelength",
    "mad_window",
    "path_dilation_radius",
    "snr_margin",
    "coherence_window_radius",
}


def _parse_key(key: str, ordered_ops: list) -> ParsedKey:
    """Resolve a combo key to a typed ``FlatKey`` / ``PresenceKey`` / ``NestedKey``.

    The grammar is additive over Phase 1: flat ``"<pos>.<field>"`` and presence
    ``"<pos>[.<Class>].__enabled__"`` keys parse exactly as before (projecting
    onto the same ``(position, field)`` / ``(position, "__enabled__")`` shape);
    a segment matching ``name[i]`` switches the key into the nested grammar.

    Disambiguation and limits:
        * A segment matching ``name[i]`` (e.g. ``detectors[0]``) marks a nested
          key; the **depth cap is 1** — a second ``[i]`` segment raises a
          "depth" ``ValueError``.
        * A malformed bracket segment (``name[x]``, ``name[]``) or a nested key
          missing its leaf raises a clear ``ValueError``.

    Args:
        key: A root-relative combo key.
        ordered_ops: The base pipeline's ops in order (for bounds + class checks).

    Returns:
        A ``FlatKey``, ``PresenceKey``, or ``NestedKey``.

    Raises:
        IndexError: If the position is out of range.
        ValueError: If a presence key's class name does not match the op there,
            or for a malformed / over-deep nested key.
    """
    parts = key.split(".")
    position = int(parts[0])
    if not 0 <= position < len(ordered_ops):
        raise IndexError(
            f"combo key {key!r} targets position {position}, but the base "
            f"pipeline has {len(ordered_ops)} op(s)"
        )

    # Locate the first ``name[i]`` segment (if any) — its presence selects the
    # nested grammar. The remaining segments after it must contain no further
    # bracket (depth cap = 1).
    for seg_index, segment in enumerate(parts[1:], start=1):
        nested = _NESTED_SEGMENT_RE.match(segment)
        if nested is not None:
            return _parse_nested_key(key, position, parts, seg_index, nested)

    if parts[-1] == "__enabled__":
        if len(parts) == 3:
            expected_cls = parts[1]
            actual_cls = type(ordered_ops[position]).__name__
            if actual_cls != expected_cls:
                raise ValueError(
                    f"presence key {key!r} targets class {expected_cls!r}, but "
                    f"position {position} holds a {actual_cls!r}"
                )
            return PresenceKey(position=position, cls_name=expected_cls)
        return PresenceKey(position=position, cls_name=None)

    if len(parts) == 2:
        # Guard against a malformed bracket that escaped the nested regex
        # (e.g. ``"0.detectors[x]"``): an unmatched ``[`` is never a flat field.
        if _BRACKET_SEGMENT_RE.search(parts[1]):
            raise ValueError(
                f"combo key {key!r} has a malformed nested segment {parts[1]!r} "
                "(expected ``field[<int>]``)"
            )
        return FlatKey(position=position, field=parts[1])

    # A 3+ part key with no ``name[i]`` and not a classed presence is malformed
    # (e.g. a stray bracket that did not match, or an unexpected dotted path).
    raise ValueError(
        f"combo key {key!r} is not a recognised flat, presence, or nested key"
    )


def _parse_nested_key(
    key: str,
    position: int,
    parts: list[str],
    seg_index: int,
    nested: re.Match[str],
) -> NestedKey:
    """Build a ``NestedKey`` from a matched ``name[i]`` segment (depth cap = 1).

    Args:
        key: The full combo key (for error messages).
        position: The already-parsed op position.
        parts: The dot-split key segments.
        seg_index: Index into ``parts`` of the matched ``name[i]`` segment.
        nested: The ``_NESTED_SEGMENT_RE`` match for that segment.

    Returns:
        The parsed ``NestedKey``.

    Raises:
        ValueError: For a second ``[i]`` (depth error) or a missing leaf.
    """
    leaf_parts = parts[seg_index + 1:]
    if not leaf_parts:
        raise ValueError(
            f"nested key {key!r} is missing its leaf field "
            "(expected ``<pos>.<field>[<int>].<leaf>``)"
        )
    # Depth cap = 1: no segment after the first ``[i]`` may open another bracket.
    for leaf_segment in leaf_parts:
        if _BRACKET_SEGMENT_RE.search(leaf_segment):
            raise ValueError(
                f"nested key {key!r} exceeds the nesting depth cap of 1 "
                f"(a second ``[i]`` segment {leaf_segment!r} is not supported)"
            )
    leaf = ".".join(leaf_parts)
    return NestedKey(
        position=position,
        field=nested.group("field"),
        index=int(nested.group("index")),
        leaf=leaf,
    )


def _rebuild_op(op: Any, overrides: dict[str, Any]) -> Any:
    """Return a fresh op of the same type with ``overrides`` applied.

    Reconstructs through the constructor (re-running validators) rather than
    mutating in place, so the result serializes byte-identically to a freshly
    constructed op — operations are immutable/keyword-only.

    Args:
        op: The base operation instance.
        overrides: Field name → new value.

    Returns:
        A new operation instance.
    """
    fields = {name: getattr(op, name) for name in type(op).model_fields}
    fields.update(overrides)
    return type(op)(**fields)


def _filamentous_auto_values(op: Any) -> dict[str, Any]:
    """Return old auto-derived values for a ``FilamentousFungiDetector``."""
    r = op.max_colony_radius_px
    w = op.min_branch_width_px
    mad_window = int(round(op._MAD_WINDOW_PER_W * w)) + 1
    if mad_window % 2 == 0:
        mad_window += 1
    return {
        "gauss_sigma": op._GAUSS_SIGMA_PER_R * r,
        "tile_size": int(round(op._TILE_SIZE_PER_R * r)),
        "tile_overlap": int(round(op._TILE_OVERLAP_PER_R * r)),
        "pct_min_wavelength": op._WAVELENGTH_PER_W * w,
        "mad_window": mad_window,
        "path_dilation_radius": max(1, int(round(op._PATH_DILATION_PER_W * w))),
        "snr_margin": max(2, int(round(op._SNR_MARGIN_PER_W * w))),
        "coherence_window_radius": int(round(op._COHERENCE_RADIUS_PER_W * w)),
    }


def _reset_filamentous_auto_derived_fields(
    op: Any, overrides: dict[str, Any]
) -> dict[str, Any]:
    """Reset auto-derived detector fields when their scene parent is tuned."""
    if type(op).__name__ != "FilamentousFungiDetector":
        return overrides
    if not (_FILAMENTOUS_SCENE_PARENTS & set(overrides)):
        return overrides

    auto_values = _filamentous_auto_values(op)
    updated = dict(overrides)
    for field in _FILAMENTOUS_DERIVED_FIELDS:
        if field in updated:
            continue
        if getattr(op, field) == auto_values[field]:
            updated[field] = None
    return updated


def _rebuild_op_or_raise_with_keys(
    op: Any, overrides: dict[str, Any], keys: list[str]
) -> Any:
    """Reconstruct ``op`` with ``overrides``; on failure, name the knob keys.

    The leaf op's own ``field_validator`` / ``Field`` bounds fire here during
    fresh reconstruction. This is the **apply-time ⊆ backstop**: the ``⊆``
    inference check is blind to *validator*-enforced bounds (they live in
    imperative code, not ``model_fields[name].metadata``), so an out-of-bound
    sampled value is only caught at this reconstruction site.

    A failing reconstruction re-raises the op's ``pydantic.ValidationError``
    **wrapped** so the message names the offending knob key(s) and the op class
    — no new exception type, the result is still a ``ValidationError`` (a
    ``ValueError`` subclass) carrying the original per-field errors.

    Args:
        op: The base operation instance being overlaid.
        overrides: Field name → sampled value for this position.
        keys: The root-relative knob keys (``"<pos>.<field>"``) for ``overrides``.

    Returns:
        A freshly reconstructed operation instance.

    Raises:
        ValidationError: Wrapped to prepend the knob key + op class.
    """
    try:
        return _rebuild_op(op, overrides)
    except ValidationError as exc:
        cls_name = type(op).__name__
        prefix = f"{', '.join(keys)} [{cls_name}]"
        augmented: list[dict[str, Any]] = []
        for err in exc.errors(include_url=False):
            ctx = dict(err.get("ctx") or {})
            ctx["error"] = f"{prefix}: {err['msg']}"
            augmented.append(
                {
                    "type": "value_error",
                    "loc": err["loc"],
                    "input": err.get("input"),
                    "ctx": ctx,
                }
            )
        raise ValidationError.from_exception_data(
            f"{prefix} (tuning overlay)",
            cast("list[InitErrorDetails]", augmented),
        ) from exc


def _rebuild_nested_field(
    parent: Any,
    position: int,
    field: str,
    slot_overrides: dict[int, dict[str, Any]],
) -> list[Any]:
    """Return a new value for ``parent.<field>`` with nested leaf overrides applied.

    For each ``(index, {leaf: value})`` entry, reads the live leaf op at
    ``parent.<field>[index]``, asserts it is present (loud error on a ``None``
    slot or out-of-range index), and rebuilds it through the **same** key-tagged
    backstop (:func:`_rebuild_op_or_raise_with_keys`) so a leaf-validator failure
    still surfaces with the offending knob key + op class. Untouched slots
    (including ``None`` slots) pass through unchanged.

    Depth cap = 1: leaf overrides are scalar fields on the nested op; the nested
    op's own operation-valued fields are never recursed into here.

    Args:
        parent: The parent operation instance owning the list field.
        position: The parent's position in the pipeline (for key tagging).
        field: The parent's operation-list field name (e.g. ``"detectors"``).
        slot_overrides: ``{index: {leaf_field: value}}`` for this field.

    Returns:
        A fresh list to splice into the parent's ``field`` (the parent itself is
        reconstructed by the caller via :func:`_rebuild_op_or_raise_with_keys`).

    Raises:
        IndexError: If a nested index is out of range.
        ValueError: If the addressed slot is ``None`` (an empty/unfilled slot).
        ValidationError: When a nested leaf value violates the leaf op's own
            bounds — wrapped to name the knob key + leaf op class.
    """
    current = getattr(parent, field, None)
    if not isinstance(current, list):
        raise ValueError(
            f"nested key targets {type(parent).__name__}.{field}, which is not a "
            f"list field (got {type(current).__name__})"
        )
    new_list = list(current)
    for index, leaf_overrides in slot_overrides.items():
        if not 0 <= index < len(new_list):
            raise IndexError(
                f"nested key targets {position}.{field}[{index}], but that field "
                f"holds {len(new_list)} slot(s)"
            )
        leaf_op = new_list[index]
        if leaf_op is None:
            raise ValueError(
                f"nested key targets {position}.{field}[{index}], but that slot "
                "is empty (None) — there is no operation to tune there"
            )
        keys = [
            f"{position}.{field}[{index}].{leaf}" for leaf in leaf_overrides
        ]
        new_list[index] = _rebuild_op_or_raise_with_keys(
            leaf_op, leaf_overrides, keys
        )
    return new_list


[docs] def build_pipeline(base: ImagePipeline, params: dict[str, Any]) -> ImagePipeline: """Clone ``base``, overlay ``params``, and drop ``__enabled__=False`` ops. Args: base: The base pipeline embedded in the ``TuningSpec``. params: A flat combo (``{root-relative-key: value}``) from a strategy. Returns: A new ``ImagePipeline`` carrying the base's measurements/post/qc with the tuned operations. Raises: IndexError / ValueError: Propagated from key parsing (out-of-range position/index, class mismatch, empty nested slot, malformed key). ValidationError: When a sampled value violates the leaf op's own bounds (the apply-time ``⊆`` backstop), wrapped to name the knob key + op class — for both flat and nested leaves. """ candidate = base.model_copy(deep=True) # preserves meas/post/qc; isolates ops from base ordered_ops = list(candidate.get_ops().values()) overrides: dict[int, dict[str, Any]] = {} enabled: dict[int, bool] = {} # Nested overrides keyed by (position, field) → {index: {leaf: value}}. nested: dict[tuple[int, str], dict[int, dict[str, Any]]] = {} for key, value in params.items(): parsed = _parse_key(key, ordered_ops) if isinstance(parsed, PresenceKey): enabled[parsed.position] = bool(value) elif isinstance(parsed, FlatKey): overrides.setdefault(parsed.position, {})[parsed.field] = value else: # NestedKey ( nested.setdefault((parsed.position, parsed.field), {}) .setdefault(parsed.index, {})[parsed.leaf] ) = value new_ops: list[Any] = [] for position, op in enumerate(ordered_ops): if not enabled.get(position, True): continue # presence toggled off → drop the op op_overrides = dict(overrides.get(position, {})) # Fold any nested-field overrides for this op into a single rebuild. nested_for_op = { field: slot_map for (pos, field), slot_map in nested.items() if pos == position } for field, slot_map in nested_for_op.items(): op_overrides[field] = _rebuild_nested_field( op, position, field, slot_map ) op_overrides = _reset_filamentous_auto_derived_fields(op, op_overrides) if not op_overrides: # un-overridden ops come from `candidate` (the deep copy), never `base` new_ops.append(op) continue keys = [f"{position}.{field}" for field in op_overrides] new_ops.append(_rebuild_op_or_raise_with_keys(op, op_overrides, keys)) candidate.set_ops(new_ops) return candidate