Source code for phenotypic.tune._search_space._infer

"""``infer_search_space`` — mine a pipeline's pydantic fields into a proposal.

This module implements the **flat, single-op** Tier-2 dispatch (the §4 heuristic
table of ``search-space-inference.md``) plus the Tier-1 ``TuneSpec`` override
(§3), and **one-level nested-op recursion** (§6): when ``recurse_nested=True``
(the default), a list-valued ``OperationField`` is recursed exactly one level,
emitting ``"<pos>.<field>[<i>].<leaf>"`` knobs. Single operation-valued fields
are recorded as excluded because their bare dotted keys are not parseable by the
runtime key grammar. The recursion is strictly additive; ``recurse_nested=False``
yields the flat-only proposal.

The inference core, :func:`_infer_field`, maps one operation field to either a
:class:`~phenotypic.tune.Knob` (a tunable domain) or an
:class:`~phenotypic.tune.Excluded` record (a field that could not / should not be
tuned). :func:`infer_search_space` walks a live ``ImagePipeline``'s ops in
position order, builds the flat ``"<pos>.<field>"`` keys, and assembles the
:class:`~phenotypic.tune.InferredSearchSpace` proposal.

Example:
    ``GaussianBlur.sigma`` carries a ``TuneSpec`` (the annotations workstream), so
    it resolves via Tier-1; ``ChanVeseDetector.mu`` is still un-annotated and
    exercises the Tier-2 unbounded heuristic ``[d/4, d·4]``.

    >>> from phenotypic import ImagePipeline
    >>> from phenotypic.enhance import GaussianBlur
    >>> from phenotypic.detect import ChanVeseDetector
    >>> from phenotypic.tune import infer_search_space
    >>> pipe = ImagePipeline(ops=[GaussianBlur(sigma=2.0), ChanVeseDetector()])
    >>> sigma = next(k for k in infer_search_space(pipe).knobs if k.key == "0.sigma")
    >>> (sigma.source, sigma.domain.low, sigma.domain.high)
    ('tune_spec', 0.5, 5.0)
    >>> mu = next(k for k in infer_search_space(pipe).knobs if k.key == "1.mu")
    >>> mu.source
    'unbounded_heuristic'
    >>> infer_search_space(pipe).needs_review
    True
"""
from __future__ import annotations

import enum
import math
import types
import typing
from pathlib import Path
from typing import Any, Final, Literal, Union, get_args, get_origin

import annotated_types as at
import numpy as np

from ._domains import Categorical, FloatRange, IntRange
from ._inferred import Excluded, ExcludeReason, InferredSearchSpace
from ._space import Knob
from ._targets import KnobTarget, parse_key, with_op_class
from ._tune_spec import TuneSpec

#: Multiplicative half-window for the unbounded heuristic: ``[d/f, d·f]``.
_DEFAULT_UNBOUNDED_FACTOR: Final[float] = 4.0
#: Span ratio (high/low) above which a range auto-trips ``log=True`` — "spans
#: more than one order of magnitude" for the unbounded heuristic.
_LOG_SPAN_THRESHOLD: Final[float] = 10.0
#: Span ratio above which a *bounded* range auto-trips ``log=True`` (~100×).
_BOUNDED_LOG_SPAN_THRESHOLD: Final[float] = 100.0


def _is_union_origin(origin: Any) -> bool:
    """Return ``True`` for both ``typing.Union`` and PEP 604 unions."""
    return origin is Union or origin is types.UnionType


def _walk_metadata(annotation: Any) -> list[Any]:
    """Collect ``Annotated`` extras from anywhere in the annotation tree.

    pydantic surfaces only the *outermost* ``Annotated`` extras in
    ``model_fields[name].metadata``; a marker nested under ``Optional`` (the
    natural ``Optional[Annotated[float, TuneSpec(...)]]``) would be missed. This
    walks the tree — every ``__metadata__`` chain and ``get_args`` branch.

    Args:
        annotation: A (possibly wrapped / nested) type annotation.

    Returns:
        All ``Annotated`` extra objects found at any depth.
    """
    found: list[Any] = []
    found.extend(getattr(annotation, "__metadata__", ()))
    for arg in get_args(annotation):
        found.extend(_walk_metadata(arg))
    return found


def _has_marker(metadata: list[Any], name: str) -> bool:
    """Return ``True`` if any metadata object has class ``name``.

    Markers (``TuneSpec`` / ``_ColumnRefMarker`` / ``_OperationFieldMarker``)
    are matched by class *name* rather than ``isinstance`` so this module never
    has to import the GUI / tools markers — it reads only their presence.
    """
    return any(type(m).__name__ == name for m in metadata)


def _strip_optional(annotation: Any) -> tuple[Any, bool]:
    """Return ``(inner, is_multi_union)`` for a ``T | None`` annotation.

    For ``T | None`` (a union whose only non-``None`` member is ``T``) returns
    ``(T, False)``. For a multi-type union with no ``None`` collapse target
    returns ``(annotation, True)`` flagging it unsupported. Non-unions pass
    through as ``(annotation, False)``.
    """
    origin = get_origin(annotation)
    if not _is_union_origin(origin):
        return annotation, False
    args = get_args(annotation)
    non_none = [a for a in args if a is not type(None)]
    if len(non_none) == 1:
        return non_none[0], False
    # A multi-type union (A | B, neither resolving to a single non-None T).
    return annotation, True


def _core_type(annotation: Any) -> Any:
    """Strip an outer ``Annotated`` wrapper, returning the bare type."""
    if get_origin(annotation) is typing.Annotated:
        return get_args(annotation)[0]
    return annotation


def _to_float(value: Any) -> float:
    """Coerce an ``annotated_types`` bound (a ``SupportsX`` value) to ``float``."""
    return float(value)


def _numeric_bounds(metadata: list[Any]) -> tuple[float | None, float | None]:
    """Extract ``(low, high)`` from ``annotated_types`` constraints.

    Reads ``Ge``/``Gt`` for the lower bound and ``Le``/``Lt`` for the upper,
    and unwraps an ``Interval``. Strictness is recorded only implicitly — the
    bound value itself is used as the (inclusive) search edge.

    Args:
        metadata: The field's ``annotated_types`` constraint objects.

    Returns:
        ``(low, high)`` where each is ``None`` when that side is unbounded.
    """
    low: float | None = None
    high: float | None = None
    for m in metadata:
        if isinstance(m, at.Interval):
            if m.ge is not None:
                low = _to_float(m.ge)
            if m.gt is not None:
                low = _to_float(m.gt)
            if m.le is not None:
                high = _to_float(m.le)
            if m.lt is not None:
                high = _to_float(m.lt)
        elif isinstance(m, at.Ge):
            low = _to_float(m.ge)
        elif isinstance(m, at.Gt):
            low = _to_float(m.gt)
        elif isinstance(m, at.Le):
            high = _to_float(m.le)
        elif isinstance(m, at.Lt):
            high = _to_float(m.lt)
    return low, high


def _schema_description(op: Any, field_name: str) -> str:
    """Return the field's docstring-derived description (``""`` if absent)."""
    try:
        props = type(op).model_json_schema().get("properties", {})
    except Exception:  # pragma: no cover - schema generation is robust here
        return ""
    return props.get(field_name, {}).get("description", "") or ""


def _bounded_knob(
    key: str,
    core: Any,
    low: float,
    high: float,
    *,
    description: str,
) -> Knob:
    """Build a ``bounded`` knob from explicit Field bounds."""
    is_int = core is int
    log = low > 0 and (high / low) >= _BOUNDED_LOG_SPAN_THRESHOLD
    if is_int:
        domain: Any = IntRange(low=int(low), high=int(high), log=log)
    else:
        domain = FloatRange(low=low, high=high, log=log)
    return Knob(
        target=parse_key(key),
        domain=domain,
        source="bounded",
        needs_review=False,
        description=description,
    )


def _unbounded_knob_or_excluded(
    key: str,
    core: Any,
    value: Any,
    *,
    factor: float,
    description: str,
    field_type: str,
) -> Knob | Excluded:
    """Apply the ``[d/f, d·f]`` heuristic, or exclude a non-positive anchor."""
    if not isinstance(value, (int, float)) or isinstance(value, bool):
        return Excluded(key=key, reason="non_numeric", field_type=field_type)
    d = float(value)
    if d <= 0:
        # A non-positive anchor collapses/flips the multiplicative ``[d/f, d·f]``
        # window — distinct from a genuinely non-numeric anchor above.
        return Excluded(
            key=key, reason="non_positive_default", field_type=field_type
        )

    low = d / factor
    high = d * factor
    is_int = core is int
    if is_int:
        ilow = math.floor(low)
        ihigh = math.ceil(high)
        if ilow == ihigh:  # widen a collapsed range so low < high
            ilow -= 1
            ihigh += 1
        log = ilow > 0 and (ihigh / ilow) > _LOG_SPAN_THRESHOLD
        domain: Any = IntRange(low=ilow, high=ihigh, log=log)
    else:
        log = low > 0 and (high / low) > _LOG_SPAN_THRESHOLD
        domain = FloatRange(low=low, high=high, log=log)
    return Knob(
        target=parse_key(key),
        domain=domain,
        source="unbounded_heuristic",
        needs_review=True,
        description=description,
    )


def _find_tune_spec(metadata: list[Any]) -> TuneSpec | None:
    """Return the first ``TuneSpec`` in ``metadata`` (or ``None``)."""
    for m in metadata:
        if isinstance(m, TuneSpec):
            return m
    return None


def _assert_subset(
    key: str, spec: TuneSpec, metadata: list[Any]
) -> None:
    """Enforce ``TuneSpec[low, high] ⊆ [ge/gt, le/lt]`` at inference time.

    Reads the four ``annotated_types`` bounds pydantic emits into the field
    metadata (``Ge``/``Gt``/``Le``/``Lt``) and respects strictness: ``low > gt``
    / ``low >= ge`` on the lower edge, ``high < lt`` / ``high <= le`` on the
    upper. A ``TuneSpec`` that escapes a co-located ``Field`` constraint raises
    a clear ``ValueError`` here (you cannot search where the value is invalid).

    Caveat — **validator-enforced bounds are invisible.** This project usually
    enforces numeric bounds in a ``field_validator``, not ``Field(ge=, le=)``;
    those bounds live in imperative code, never in ``model_fields[name]
    .metadata``, so this check cannot see them. A ``TuneSpec`` exceeding a
    *validator*-enforced bound passes inference and only fails later at apply /
    trial time — that apply-time backstop is the real guard.

    Args:
        key: The field's root-relative key (for the error message).
        spec: The ``TuneSpec`` carrying ``low``/``high``.
        metadata: The combined field + annotation-tree metadata.

    Raises:
        ValueError: If the ``TuneSpec`` range escapes the Field bounds.
    """
    if spec.low is None and spec.high is None:
        return
    # Expand any Interval into its component bounds so a single loop handles all.
    bounds: list[Any] = []
    for m in metadata:
        if isinstance(m, at.Interval):
            bounds.extend(_interval_to_bounds(m))
        else:
            bounds.append(m)

    for m in bounds:
        if isinstance(m, at.Ge) and spec.low is not None and spec.low < _to_float(m.ge):
            raise ValueError(
                f"TuneSpec on {key!r} is not ⊆ its Field bound: low "
                f"{spec.low} < ge {_to_float(m.ge)}"
            )
        if isinstance(m, at.Gt) and spec.low is not None and spec.low <= _to_float(m.gt):
            raise ValueError(
                f"TuneSpec on {key!r} is not ⊆ its Field bound: low "
                f"{spec.low} <= gt {_to_float(m.gt)} (strict)"
            )
        if isinstance(m, at.Le) and spec.high is not None and spec.high > _to_float(m.le):
            raise ValueError(
                f"TuneSpec on {key!r} is not ⊆ its Field bound: high "
                f"{spec.high} > le {_to_float(m.le)}"
            )
        if isinstance(m, at.Lt) and spec.high is not None and spec.high >= _to_float(m.lt):
            raise ValueError(
                f"TuneSpec on {key!r} is not ⊆ its Field bound: high "
                f"{spec.high} >= lt {_to_float(m.lt)} (strict)"
            )


def _interval_to_bounds(interval: at.Interval) -> list[Any]:
    """Expand an ``Interval`` into the equivalent ``Ge``/``Gt``/``Le``/``Lt`` set."""
    out: list[Any] = []
    if interval.ge is not None:
        out.append(at.Ge(interval.ge))
    if interval.gt is not None:
        out.append(at.Gt(interval.gt))
    if interval.le is not None:
        out.append(at.Le(interval.le))
    if interval.lt is not None:
        out.append(at.Lt(interval.lt))
    return out


def _resolve_tune_spec(
    key: str,
    spec: TuneSpec,
    core: Any,
    metadata: list[Any],
    *,
    description: str,
) -> Knob | Excluded:
    """Build a Tier-1 ``Knob`` (or ``Excluded``) from an explicit ``TuneSpec``.

    Args:
        key: The field's root-relative key.
        spec: The ``TuneSpec`` marker.
        core: The bare (Optional/Annotated-stripped) field type.
        metadata: The combined field + annotation-tree metadata (for the ⊆ check).
        description: The field's docstring-derived description.

    Returns:
        A ``Knob`` (``source="tune_spec"``, ``needs_review=False``), or an
        ``Excluded(reason="tune_spec_off")`` when ``tunable=False``.

    Raises:
        ValueError: If the ``TuneSpec`` range escapes a co-located Field bound.
    """
    if not spec.tunable:
        return Excluded(
            key=key, reason="tune_spec_off", field_type=_annotation_str(core)
        )

    # categories override -> Categorical (no numeric ⊆ check applies).
    if spec.categories is not None:
        return Knob(
            target=parse_key(key),
            domain=Categorical(choices=tuple(spec.categories)),
            source="tune_spec",
            needs_review=False,
            description=description,
        )

    _assert_subset(key, spec, metadata)

    if spec.low is None or spec.high is None:
        # An incomplete numeric TuneSpec (no full range, no categories) can't
        # form a domain — surface it rather than fabricate one.
        return Excluded(
            key=key, reason="non_numeric", field_type=_annotation_str(core)
        )

    if core is int:
        step = int(spec.step) if spec.step is not None else 1
        domain: Any = IntRange(
            low=int(spec.low), high=int(spec.high), step=step, log=spec.log
        )
    else:
        domain = FloatRange(low=spec.low, high=spec.high, log=spec.log)
    return Knob(
        target=parse_key(key),
        domain=domain,
        source="tune_spec",
        needs_review=False,
        description=description,
    )


def _infer_field(
    op: Any,
    position: int,
    field_name: str,
    field_info: Any,
    *,
    factor: float = _DEFAULT_UNBOUNDED_FACTOR,
) -> Knob | Excluded:
    """Map one operation field to a ``Knob`` or an ``Excluded`` record.

    A Tier-1 ``TuneSpec`` (when present) wins over the heuristics (added in
    P3-4); this is the Tier-2 type/constraint dispatch. The flat key is
    ``"<position>.<field_name>"``.

    Args:
        op: The operation instance (for the current value + schema description).
        position: The op's position in the pipeline (key prefix).
        field_name: The pydantic field name.
        field_info: ``type(op).model_fields[field_name]``.
        factor: Multiplicative half-window for the unbounded heuristic.

    Returns:
        A ``Knob`` (tunable) or an ``Excluded`` record.
    """
    key = f"{position}.{field_name}"
    annotation = field_info.annotation
    description = _schema_description(op, field_name)
    field_type = _annotation_str(annotation)
    value = getattr(op, field_name, field_info.default)

    # pydantic lifts Field/annotated_types constraints onto ``field_info.metadata``
    # and unwraps the outer ``Annotated``; markers nested deeper in the tree
    # (TuneSpec/ColumnRef under Optional) are found by walking the annotation.
    metadata = list(field_info.metadata) + _walk_metadata(annotation)

    # Name-ref marker (ColumnRef) — an open set, excluded.
    if _has_marker(metadata, "_ColumnRefMarker"):
        return Excluded(key=key, reason="name_ref", field_type=field_type)
    # Operation-valued field — handled by nested recursion (next chunk), not a
    # scalar knob here.
    if _has_marker(metadata, "_OperationFieldMarker"):
        return Excluded(key=key, reason="unsupported_type", field_type=field_type)

    # T | None -> infer over T; multi-type union -> unsupported.
    inner, is_multi_union = _strip_optional(annotation)
    if is_multi_union:
        return Excluded(key=key, reason="unsupported_type", field_type=field_type)

    core_inner = _core_type(_strip_optional(_core_type(inner))[0])

    # Tier 1: an explicit ``TuneSpec`` wins over the Tier-2 heuristics.
    spec = _find_tune_spec(metadata)
    if spec is not None:
        return _resolve_tune_spec(
            key, spec, core_inner, metadata, description=description
        )

    return _dispatch_core(
        key,
        core_inner,
        value,
        metadata,
        factor=factor,
        description=description,
        field_type=field_type,
    )


def _dispatch_core(
    key: str,
    core: Any,
    value: Any,
    metadata: list[Any],
    *,
    factor: float,
    description: str,
    field_type: str,
) -> Knob | Excluded:
    """Tier-2 type dispatch over the bare (Optional/Annotated-stripped) type."""
    # ndarray (raw or NdArrayField) — not scalar-tunable.
    if core is np.ndarray:
        return Excluded(key=key, reason="ndarray", field_type=field_type)
    # Paths — open set.
    if isinstance(core, type) and issubclass(core, Path):
        return Excluded(key=key, reason="path", field_type=field_type)

    # bool (check before int — bool is an int subclass).
    if core is bool:
        return Knob(
            target=parse_key(key),
            domain=Categorical(choices=(True, False)),
            source="bool",
            needs_review=False,
            description=description,
        )

    # Literal[...] -> Categorical(literal members).
    if get_origin(core) is Literal:
        return Knob(
            target=parse_key(key),
            domain=Categorical(choices=tuple(get_args(core))),
            source="literal",
            needs_review=False,
            description=description,
        )

    # Enum -> Categorical(member *values*, not members). Storing the raw values
    # keeps the domain JSON-native: ``model_dump(mode="json")`` →
    # ``model_validate`` round-trips to the same Python type (a member would
    # serialize to a bare string and reload as ``str``, losing the enum type).
    # The build path re-applies a chosen value through the op constructor, whose
    # field_validator coerces the value back to the enum, so apply still works.
    if isinstance(core, type) and issubclass(core, enum.Enum):
        return Knob(
            target=parse_key(key),
            domain=Categorical(choices=tuple(m.value for m in core)),
            source="enum",
            needs_review=False,
            description=description,
        )

    # Numeric (int / float).
    if core in (int, float):
        low, high = _numeric_bounds(metadata)
        if low is not None and high is not None:
            return _bounded_knob(
                key, core, low, high, description=description
            )
        return _unbounded_knob_or_excluded(
            key,
            core,
            value,
            factor=factor,
            description=description,
            field_type=field_type,
        )

    # Free-form str (and anything else non-numeric / unrecognised).
    if core is str:
        # Open set — excluded. ``non_numeric`` is the only fitting closed-set
        # reason; it conservatively raises the proposal review flag.
        return Excluded(key=key, reason="non_numeric", field_type=field_type)

    return Excluded(key=key, reason="unsupported_type", field_type=field_type)


def _annotation_str(annotation: Any) -> str:
    """Render an annotation for the ``Excluded.field_type`` display field."""
    name = getattr(annotation, "__name__", None)
    if isinstance(name, str):
        return name
    return str(annotation)


def _is_recursable_op(value: Any) -> bool:
    """Return ``True`` for a leaf operation worth recursing into (not a pipeline).

    A nested ``OperationField`` value may be a single leaf operation, a nested
    ``ImagePipeline``, or ``None``. We recurse only into a leaf op — one that
    exposes ``model_fields`` but is **not** itself a pipeline (a pipeline has
    ``get_ops``, and recursing into its ops would be a different, deferred
    naming scheme). ``None`` slots and pipelines are skipped.
    """
    return (
        value is not None
        and hasattr(type(value), "model_fields")
        and not hasattr(value, "get_ops")
    )


def _reparent_key(child_key: str, prefix: str) -> str:
    """Rewrite a depth-0 child key ``"0.<leaf>"`` as ``"<prefix>.<leaf>"``.

    ``_infer_field`` always builds keys as ``"<position>.<field>"`` with the
    position it is handed. When recursing, we infer each nested leaf field at a
    throwaway position ``0`` and then splice the real nested prefix
    (``"1.ops[0]"``) in front of the leaf name — so the canonical nested
    key becomes ``"1.ops[0].<leaf>"``.
    """
    _, _, leaf = child_key.partition(".")
    return f"{prefix}.{leaf}"


def _recurse_into_op(
    leaf_op: Any,
    prefix: str,
    *,
    factor: float,
    conditional_on: tuple[tuple[KnobTarget, Any], ...] | None,
) -> tuple[list[Knob], list[Excluded]]:
    """Infer one nested op's scalar fields (depth cap = 1).

    Applies the same Tier-1 → Tier-2 dispatch to each of ``leaf_op``'s fields,
    re-parents the resulting keys under ``prefix`` (``"<pos>.<field>[<i>]"`` or
    ``"<pos>.<field>"``), and attaches ``conditional_on`` to every emitted knob.
    The nested op's **own** operation-valued fields are excluded here — depth is
    capped at one level (no chaining).

    Args:
        leaf_op: The nested operation instance to recurse into.
        prefix: The root-relative path of the nested slot (e.g.
            ``"1.ops[0]"``).
        factor: Multiplicative half-window for the unbounded heuristic.
        conditional_on: Parent-presence gate to stamp on each nested knob (a
            ``((key, value),)`` tuple), or ``None`` when the parent is not
            presence-wrapped.

    Returns:
        ``(knobs, excluded)`` for the nested op's scalar fields.
    """
    knobs: list[Knob] = []
    excluded: list[Excluded] = []
    for field_name, field_info in type(leaf_op).model_fields.items():
        result = _infer_field(leaf_op, 0, field_name, field_info, factor=factor)
        new_key = _reparent_key(result.key, prefix)
        if isinstance(result, Knob):
            knobs.append(
                result.model_copy(
                    update={
                        "target": parse_key(new_key),
                        "conditional_on": conditional_on,
                    }
                )
            )
        else:
            excluded.append(result.model_copy(update={"key": new_key}))
    return knobs, excluded


def _infer_nested_field(
    op: Any,
    position: int,
    field_name: str,
    *,
    factor: float,
) -> tuple[list[Knob], list[Excluded]]:
    """Recurse one level into an operation-valued field's live value.

    Reads ``op.<field_name>``. List members are indexed (``<field>[<i>]``) and
    recursed via :func:`_recurse_into_op`; ``None`` slots, nested pipelines, and
    single operation-valued fields are skipped.

    ``conditional_on`` ties a nested knob to the parent's ``__enabled__`` **only
    when the parent op is presence-wrapped** (``type(op)._tune_optional`` is
    truthy). In v1 nested ops are never presence-wrapped and top-level presence
    wrapping is not emitted by inference, so this resolves to ``None`` — kept
    explicit so a future presence layer needs no change here.

    Args:
        op: The parent operation instance owning the nested field.
        position: The parent's position in the pipeline.
        field_name: The operation-valued field name.
        factor: Multiplicative half-window for the unbounded heuristic.

    Returns:
        ``(knobs, excluded)`` from the one-level recursion.
    """
    value = getattr(op, field_name, None)
    conditional_on = _parent_presence_condition(op, position)

    knobs: list[Knob] = []
    excluded: list[Excluded] = []
    if isinstance(value, list):
        for index, member in enumerate(value):
            if not _is_recursable_op(member):
                continue  # skip None slots and nested pipelines (depth cap)
            prefix = f"{position}.{field_name}[{index}]"
            k, e = _recurse_into_op(
                member, prefix, factor=factor, conditional_on=conditional_on
            )
            knobs.extend(k)
            excluded.extend(e)
    return knobs, excluded


def _parent_presence_condition(
    op: Any, position: int
) -> tuple[tuple[KnobTarget, Any], ...] | None:
    """Return the nested knob's ``conditional_on`` gate, or ``None``.

    A nested knob is gated on its parent's ``__enabled__`` toggle **only** when
    the parent op is presence-wrapped (``type(op)._tune_optional``). The gate is
    a structured ``Presence`` **target** (not a bare string): the live
    ``Knob.conditional_on`` is ``tuple[tuple[KnobTarget, Any], ...]``, and the
    nested-recursion stamps it via ``model_copy`` (which bypasses ``Knob``'s
    string-coercion before-validator), so the parent must already be a target —
    otherwise ``Knob.is_active`` (``ptarget.key``) would hit a raw ``str``. In v1
    no op sets ``_tune_optional`` and inference emits no top-level presence knob,
    so this returns ``None``.
    """
    if getattr(type(op), "_tune_optional", False):
        cls_name = type(op).__name__
        return ((parse_key(f"{position}.{cls_name}.__enabled__"), True),)
    return None


[docs] def infer_search_space( pipeline: Any, *, unbounded_factor: float = _DEFAULT_UNBOUNDED_FACTOR, recurse_nested: bool = True, ) -> InferredSearchSpace: """Mine a pipeline into a generous, reviewable ``InferredSearchSpace``. Walks the pipeline's ops in position order and infers each scalar field via the Tier-2 type heuristics (Tier-1 ``TuneSpec`` override added in P3-4). Knob keys are flat ``"<position>.<field>"`` paths. Args: pipeline: A live ``ImagePipeline`` whose ``get_ops()`` yields the ops in insertion order. unbounded_factor: Multiplicative half-window ``[d/f, d·f]`` for the unbounded numeric heuristic (default ``4.0`` → 16× span). recurse_nested: Recurse **exactly one level** into operation-valued (``OperationField``) fields — a single nested op or a list of them. On by default; ``False`` yields the flat-only proposal (no nested knobs), byte-identical to the pre-recursion space. The recursion is strictly additive: a pipeline with no nested ops is unchanged. Returns: The ``InferredSearchSpace`` proposal. """ ops = list(pipeline.get_ops().values()) knobs: list[Knob] = [] excluded: list[Excluded] = [] for position, op in enumerate(ops): for field_name, field_info in type(op).model_fields.items(): result = _infer_field( op, position, field_name, field_info, factor=unbounded_factor ) if isinstance(result, Knob): knobs.append(result) else: excluded.append(result) # One-level recursion into operation-valued fields. The flat pass # above records the field as ``Excluded(unsupported_type)``; the # nested leaves are emitted here (additive — keeps the marker record). if recurse_nested and _field_holds_operation(field_info): n_knobs, n_excluded = _infer_nested_field( op, position, field_name, factor=unbounded_factor ) knobs.extend(n_knobs) excluded.extend(n_excluded) knobs = [_stamp_op_classes(k, ops) for k in knobs] return InferredSearchSpace(knobs=tuple(knobs), excluded=tuple(excluded))
def _stamp_op_classes(knob: Knob, ops: list) -> Knob: """Fill ``op_class`` on a knob's own target **and** its conditional parents. The knob's ``target`` is always stamped (posture C — every programmatic target is wrong-op cross-checked). Each ``conditional_on`` parent target is stamped too, so a presence-conditional knob's gate resolves against the correct parent op once presence-wrapping is enabled. The latter is latent in v1 (``_tune_optional`` is never set, so ``conditional_on`` is always ``None``), but stamping defensively keeps a future presence layer correct without a change here. Args: knob: The inferred knob to enrich. ops: The pipeline's ops in position order (the ``op_class`` source). Returns: A copy of ``knob`` with ``op_class`` populated on its target and on every ``conditional_on`` parent target. """ update: dict[str, Any] = {"target": with_op_class(knob.target, ops)} if knob.conditional_on is not None: update["conditional_on"] = tuple( (with_op_class(parent, ops), value) for parent, value in knob.conditional_on ) return knob.model_copy(update=update) def _field_holds_operation(field_info: Any) -> bool: """Return ``True`` if a field carries the ``_OperationFieldMarker``. Detected by walking the annotation tree (the marker may sit under ``Optional`` / inside a ``list``), matching by class *name* so this module never imports the ``sdk_`` marker. """ metadata = list(field_info.metadata) + _walk_metadata(field_info.annotation) return _has_marker(metadata, "_OperationFieldMarker") __all__ = ["infer_search_space", "_infer_field", "ExcludeReason"]