Source code for phenotypic.gui.tune._run_root

"""``TuneRunRoot`` — validate + describe a tune output directory (read-only).

The single seam the ``/tune/`` GUI uses to turn an output path into a typed,
validated handle: where the trial journal is, which study (URL + name) backs it,
whether the run was multi-objective, and where the calibration images / tuned
winner live. :meth:`TuneRunRoot.discover` reads three markers in **precedence
order** so a live run (only ``run.json`` written) and a finished run (full
``deliverables/``) both resolve, and a non-tune directory is rejected:

1. ``.pht-tune-cache/run.json`` — the run-START marker (Chunk 0). It carries the
   resolved (non-null) ``storage_url``, ``study_name``, ``images_dir``, and the
   ``is_multi_objective`` flag, so it is the cheapest and most authoritative
   source; it wins outright when present.
2. ``deliverables/tuning_spec.json`` — the resolved recipe. The storage URL comes
   from ``strategy.storage_url`` (only an ``OptunaConfig`` strategy carries one)
   and the objective ``directions`` from the scorer.
3. The legacy output root — recognized solely by a ``trials.parquet``.

**Optuna-free.** This module reads only the markers (JSON) and the spec model; it
never imports ``optuna`` or any engine/run-loop code (the GUI never re-optimizes).
"""
from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path

from phenotypic.sdk_ import (
    best_pipeline_path,
    resolve_tuning_spec_path,
    trials_parquet_path,
    tune_cache_run_marker_path,
)

#: The study name every tune run uses (mirrors ``_tune_cli._run._STUDY_NAME``;
#: kept in lockstep by ``test_study_name_cutover.py``). Used as the fallback when
#: discovering from a ``tuning_spec.json`` (which, unlike the ``run.json`` marker,
#: does not record a study name).
_DEFAULT_STUDY_NAME: str = "tune_cost_v1"

#: The placeholder multi-objective ``directions`` synthesized from the
#: ``run.json`` ``is_multi_objective`` flag. The marker records only the boolean
#: (not the per-axis names), so a multi-objective run is represented as a 2-axis
#: minimize vector — enough for ``is_multi_objective(root)`` (len > 1) and the
#: GUI's "this is a Pareto run" branch. Every tuning objective is a cost
#: (lower-is-better — cost convention), so the synthesized axes are both
#: ``"minimize"``.
_MULTI_OBJECTIVE_PLACEHOLDER_DIRECTIONS: list[str] = ["minimize", "minimize"]


[docs] class TuneRunRootError(ValueError): """A directory is not a recognizable tune output. Raised by :meth:`TuneRunRoot.discover` when none of the three discovery markers resolves: no ``run.json``, no study URL from a ``tuning_spec.json``, and no ``trials.parquet``. """
[docs] @dataclass(frozen=True) class TuneRunRoot: """A validated handle on a tune output directory. Args: path: The tune output directory (the run's ``--output`` root). trials_path: The trial journal ``trials.parquet``, or ``None`` when it has not been written yet (a live run discovered via ``run.json``). storage_url: The resolved Optuna storage URL, or ``None`` when the run is parquet-journal-only. study_name: The study name (``"tune_cost_v1"``). directions: The per-objective Optuna ``directions`` (length ≥ 2) for a multi-objective run, or ``None`` for a single-objective study. images_dir: The calibration image directory, or ``None`` when unknown (e.g. discovered from a ``tuning_spec.json`` or a legacy root). best_pipeline_path: Where the tuned winner ``best_pipeline.json`` lives (a pure path expression; the file may not exist yet). """ path: Path trials_path: Path | None storage_url: str | None study_name: str directions: list[str] | None images_dir: Path | None best_pipeline_path: Path
[docs] @classmethod def discover(cls, path: Path) -> "TuneRunRoot": """Validate ``path`` as a tune output and describe it. Reads the three discovery markers in precedence order (``run.json`` → ``tuning_spec.json`` → legacy ``trials.parquet`` root). Locates the trial journal via :func:`trials_parquet_path` and the tuned winner via :func:`best_pipeline_path` regardless of which marker matched. Args: path: The candidate tune output directory. Returns: The validated :class:`TuneRunRoot`. Raises: TuneRunRootError: When ``path`` carries none of {``run.json``, a study URL from a ``tuning_spec.json``, a ``trials.parquet``}. """ path = Path(path) trials = trials_parquet_path(path) trials_path = trials if trials.exists() else None marker = cls._read_run_marker(path) if marker is not None: storage_url, study_name, directions, images_dir = marker return cls( path=path, trials_path=trials_path, storage_url=storage_url, study_name=study_name, directions=directions, images_dir=images_dir, best_pipeline_path=best_pipeline_path(path), ) spec = cls._read_spec_markers(path) if spec is not None: storage_url, directions = spec # A spec only validates the directory when it yields a real study URL # or a trial journal exists — a spec with neither (no URL, no parquet) # is not yet a readable tune output, so fall through to the error. if storage_url is not None or trials_path is not None: return cls( path=path, trials_path=trials_path, storage_url=storage_url, study_name=_DEFAULT_STUDY_NAME, directions=directions, images_dir=None, best_pipeline_path=best_pipeline_path(path), ) if trials_path is not None: # Legacy root: recognized solely by a trial journal (no marker, no # study). Parquet-only, single-objective view. return cls( path=path, trials_path=trials_path, storage_url=None, study_name=_DEFAULT_STUDY_NAME, directions=None, images_dir=None, best_pipeline_path=best_pipeline_path(path), ) raise TuneRunRootError( f"{path} is not a tune output: no .pht-tune-cache/run.json, no study " "URL in deliverables/tuning_spec.json, and no trials.parquet." )
@staticmethod def _read_run_marker( path: Path, ) -> tuple[str | None, str, list[str] | None, Path | None] | None: """Parse ``.pht-tune-cache/run.json`` → (url, study, directions, images). The marker is the run-START sidecar (Chunk 0). It carries the resolved ``storage_url``, ``study_name``, ``images_dir``, and the ``is_multi_objective`` flag — directions are synthesized from that flag (the marker does not record per-axis names; see :data:`_MULTI_OBJECTIVE_PLACEHOLDER_DIRECTIONS`). A missing flag leaves ``directions`` ``None``. Args: path: The tune output directory. Returns: ``(storage_url, study_name, directions, images_dir)`` when the marker exists and parses, else ``None``. """ marker_path = tune_cache_run_marker_path(path) if not marker_path.exists(): return None marker = json.loads(marker_path.read_text()) storage_url = marker.get("storage_url") study_name = marker.get("study_name") or _DEFAULT_STUDY_NAME is_multi = marker.get("is_multi_objective") directions = ( list(_MULTI_OBJECTIVE_PLACEHOLDER_DIRECTIONS) if is_multi else None ) images_raw = marker.get("images_dir") images_dir = Path(images_raw) if images_raw else None return storage_url, study_name, directions, images_dir @staticmethod def _read_spec_markers(path: Path) -> tuple[str | None, list[str] | None] | None: """Parse ``deliverables/tuning_spec.json`` → (storage_url, directions). The fallback when no ``run.json`` exists. Loads the resolved :class:`~phenotypic.tune._spec.TuningSpec`, reads the storage URL via ``getattr(spec.strategy, "storage_url", None)`` (only ``OptunaConfig`` carries the field), and the objective ``directions`` via the private :func:`phenotypic.tune._multi_objective.objective_directions` (``None`` for a single-objective scorer). Returns ``None`` when no spec file exists. Args: path: The tune output directory. Returns: ``(storage_url, directions)`` when the spec exists, else ``None``. """ spec_path = resolve_tuning_spec_path(path) if not spec_path.exists(): return None # Lazy, function-local imports keep module import optuna-free and cheap: # the spec model + the private multi-objective helper are only pulled in # when a spec actually needs decoding. from phenotypic.tune._multi_objective import objective_directions from phenotypic.tune._spec import TuningSpec spec = TuningSpec.model_validate_json(spec_path.read_text()) storage_url = getattr(spec.strategy, "storage_url", None) directions = objective_directions(spec.scorer) return storage_url, directions