"""Shared CLI ↔ GUI artifact-layout constants.
Single source of truth for everything written to disk by the forward CLI,
the SLURM recompile worker, and the chunk writer — and read back by the
GUI viewer / analysis sub-app. Both subpackages should import from here
rather than re-spelling. This module replaces the scattered inline
``"master_measurements.parquet"`` / ``"progress"`` / etc. literals that
previously lived as private constants in ~10 different files.
Module layout
-------------
* **Filenames** (`MASTER_MEASUREMENTS_*`, `PIPELINE_JSON`, …) — bare strings
written/read by both producer (CLI) and consumer (GUI).
* **Directory names** (`DIR_*`) — same.
* **Templated filenames** — private ``_FOO_TEMPLATE: Final[str]``
constants paired with public ``foo_filename(...)`` render functions.
The render function's typed signature is the public API; the template
itself is private. This is the project's first deployment of the
CLAUDE.md "parameterized strings are not enumerations" pattern.
* **Path helpers** — `progress_dir(output)`, `event_log_path(output)`,
etc. Take a base ``output_dir: Path`` and return the canonical
artifact path. Replace ``output_dir / "literal"`` constructions
scattered across the CLI. Two parameter conventions are used:
- **`output_dir: Path`** — the run output root (e.g. ``./out``).
Use these from any caller that has the run root in scope:
``master_measurements_csv_path``, ``manifest_json_path``,
``job_metadata_path``, ``pipeline_json_path``, ``task_status_path``,
``logs_dir``, ``slurm_scripts_dir``, ``analysis_html_path``,
``processing_report_html_path``, ``measurements_by_feature_dir``,
etc.
- **`progress_dir_: Path`** — the already-resolved progress dir
(i.e. ``output_dir / "progress"``). Used for helpers that produce
paths to *internal mid-run artifacts* the SLURM sentinel + chunk
writer hand around between each other:
``analysis_full_parquet_path``, ``analysis_scatter_json_path``,
``sentinel_resubmitted_path``, ``chunk_lock_path``, ``chunks_dir``,
``chunk_parquet_path``, ``checkpoint_lock_path``, ``recompile_dir``,
``recompile_status_dir``. The trailing underscore on the parameter
name disambiguates it from the ``progress_dir(output_dir)``
function.
When in doubt, prefer the ``output_dir``-rooted form — it composes
cleanly with ``progress_dir(output_dir)`` if you need the progress
directory separately. Helpers that take ``progress_dir_`` are noted
in their individual docstrings.
* **Reader helpers** — `read_run_manifest`, `load_master_measurements`,
`resolve_execution_mode` consolidate three high-frequency duplicates.
* **JSON contract keys** (`JobMetadataKey`, `DashboardManifestKey`,
`ChunkStateKey`, `ChunkManifestKey`, `HdfAttr`) — namespace classes
whose class-level ``Final[str]`` attributes are the keys writers and
readers must reference instead of bare strings. Keeps the contract
on-disk format mechanically discoverable.
* **`ModulePath`** — importable module paths used in ``importlib.import_module``
dispatch by the analysis GUI's recipe loader.
* **`EnvVar`** — environment variable names read by the CLI (SLURM /
scratch).
See also
--------
:mod:`phenotypic.sdk_.constants_`
Image-data and framework-config enums (``IMAGE_MODE``, ``IMAGE_TYPES``,
``GAMMA_ENCODINGS``, ``PIPE_STATUS``). The ``METADATA`` enum and the
experimental-tag vocabulary now live in :mod:`phenotypic.schema`.
:mod:`phenotypic.sdk_.typing_`
Literal aliases for closed value sets used at public boundaries
(``ExecutionMode``, ``ImageTypeName``, …).
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Final, Iterable, Optional
from .typing_ import (
CheckpointType,
ExecutionMode,
ImageTypeName,
)
if TYPE_CHECKING:
import polars as pl # type: ignore[import-not-found]
from phenotypic._core._grid_image import GridImage as _GridImage
from phenotypic._core._image import Image as _Image
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# CLI artifact filenames
# ---------------------------------------------------------------------------
LEGACY_JSON_SUFFIX: Final[str] = ".json"
CONFIG_SUFFIX_PIPELINE: Final[str] = ".json.pht-pipe"
CONFIG_SUFFIX_OPERATION: Final[str] = ".json.pht-op"
CONFIG_SUFFIX_COLOR_CHECKER: Final[str] = ".json.pht-cc"
CONFIG_SUFFIX_TUNING: Final[str] = ".json.pht-tune"
CONFIG_SUFFIXES: Final[frozenset[str]] = frozenset(
{
CONFIG_SUFFIX_PIPELINE,
CONFIG_SUFFIX_OPERATION,
CONFIG_SUFFIX_COLOR_CHECKER,
CONFIG_SUFFIX_TUNING,
}
)
PIPELINE_CONFIG_SUFFIXES: Final[frozenset[str]] = frozenset(
{CONFIG_SUFFIX_PIPELINE, LEGACY_JSON_SUFFIX}
)
TUNING_CONFIG_SUFFIXES: Final[frozenset[str]] = frozenset(
{CONFIG_SUFFIX_TUNING, LEGACY_JSON_SUFFIX}
)
[docs]
def has_config_suffix(path: str | Path, suffixes: Iterable[str]) -> bool:
"""Return whether ``path`` ends with any configured suffix.
Matching is case-insensitive so callers can discover user-provided files
from case-preserving filesystems without rewriting their names.
"""
text = str(path).lower()
return any(text.endswith(suffix.lower()) for suffix in suffixes)
[docs]
def matches_any_suffix(path: str | Path, suffixes: Iterable[str]) -> bool:
"""Return whether ``path`` ends with any suffix in ``suffixes``."""
return has_config_suffix(path, suffixes)
[docs]
def ensure_typed_json_suffix(path: str | Path, suffix: str) -> Path:
"""Return ``path`` with the canonical typed JSON suffix appended.
Bare stems receive the full typed suffix. Legacy ``.json`` paths receive
only the typed tail, preserving the user-provided stem and case.
"""
target = Path(path)
text = str(target)
lowered = text.lower()
canonical_suffix = suffix.lower()
if lowered.endswith(canonical_suffix):
return target
if lowered.endswith(LEGACY_JSON_SUFFIX):
typed_tail = suffix.removeprefix(LEGACY_JSON_SUFFIX)
return Path(f"{text}{typed_tail}")
return Path(f"{text}{suffix}")
#: Master archive of all aggregated measurements (clean, pre-post). Written by
#: :func:`phenotypic._cli._cli_output_manager.aggregate_measurements` after
#: every per-image Parquet has been concatenated and joined with optional
#: external metadata. Treated as the authoritative source by downstream
#: tooling; never edited in place.
MASTER_MEASUREMENTS_CSV: Final[str] = "master_measurements.csv"
#: Parquet companion of :data:`MASTER_MEASUREMENTS_CSV`. Preserves dtypes the
#: CSV cannot and is the format the GUI viewer prefers when present.
MASTER_MEASUREMENTS_PARQUET: Final[str] = "master_measurements.parquet"
#: Editable curated CSV mirror seeded by the CLI as a copy of
#: :data:`MASTER_MEASUREMENTS_CSV` after :func:`_apply_post_to_master`. The
#: results viewer rewrites this file in place when the user removes/restores
#: colonies. Re-running the CLI overwrites it with a fresh full copy.
MEASUREMENTS_CSV: Final[str] = "measurements.csv"
#: Editable curated Parquet companion to :data:`MEASUREMENTS_CSV`. The
#: parquet is the GUI's source of truth at boot (CSV is the human-readable
#: mirror); both are written atomically together.
MEASUREMENTS_PARQUET: Final[str] = "measurements.parquet"
#: Output filename of the model-fit summary written by the CLI when the
#: pipeline has a ``model`` configured (and re-emitted by the analysis
#: GUI's "Run analysis" button). Human-readable mirror of
#: :data:`ANALYSIS_PARQUET`.
ANALYSIS_CSV: Final[str] = "analysis.csv"
#: Parquet companion of :data:`ANALYSIS_CSV` — primary downstream
#: artifact since it preserves dtypes the CSV cannot.
ANALYSIS_PARQUET: Final[str] = "analysis.parquet"
#: Canonical pipeline-spec filename written into the output root by the
#: CLI (and rewritten by the analysis GUI on every recipe edit). Captures
#: operations, measurements, post, filters, and model — i.e. the whole
#: reproducibility surface.
PIPELINE_JSON: Final[str] = f"pipeline{CONFIG_SUFFIX_PIPELINE}"
_LEGACY_PIPELINE_JSON: Final[str] = f"pipeline{LEGACY_JSON_SUFFIX}"
#: Resume-state JSON written by ``ProcessingState.save`` and read by
#: ``ProcessingState.load`` when ``--resume`` is passed.
PROCESSING_STATE_JSON: Final[str] = "processing_state.json"
#: Human-readable run README generated by the CLI's ``READMEGenerator``
#: (output-layout + measurement-schema documentation). A user-facing
#: deliverable, so it lives in :data:`DIR_DELIVERABLES` — see
#: :func:`readme_md_path`.
README_MD: Final[str] = "README.md"
#: The resolved tuning spec echoed by ``python -m phenotypic.tune``
#: into :data:`DIR_DELIVERABLES` — the self-contained, re-runnable recipe.
TUNING_SPEC_JSON: Final[str] = f"tuning_spec{CONFIG_SUFFIX_TUNING}"
_LEGACY_TUNING_SPEC_JSON: Final[str] = f"tuning_spec{LEGACY_JSON_SUFFIX}"
#: The winning best pipeline written by the tune CLI into
#: :data:`DIR_DELIVERABLES`; reloads as a runnable ``ImagePipeline``.
BEST_PIPELINE_JSON: Final[str] = f"best_pipeline{CONFIG_SUFFIX_PIPELINE}"
_LEGACY_BEST_PIPELINE_JSON: Final[str] = f"best_pipeline{LEGACY_JSON_SUFFIX}"
#: Sidecar with the selected headline trial's params and scores, written by the
#: tune CLI into :data:`DIR_DELIVERABLES` for GUI Monitor display.
BEST_PARAMS_JSON: Final[str] = "best_params.json"
#: The RF-permutation ``param_importance.json`` report written by the tune
#: CLI into :data:`DIR_DELIVERABLES`.
PARAM_IMPORTANCE_JSON: Final[str] = "param_importance.json"
#: The tune trial journal ``trials.parquet`` written at the output-dir root
#: (powers CLI resume), not under :data:`DIR_DELIVERABLES`.
TRIALS_PARQUET: Final[str] = "trials.parquet"
#: The canonical Optuna study database ``study.db`` (SQLite WAL) written inside
#: the hidden tune cache (:data:`DIR_PHT_TUNE_CACHE`). Holds the Optuna-backed
#: store's persistent, resumable sampler state when the ``tune`` extra is used.
#: A legacy run wrote it at the output root; :func:`resolve_study_db_path` reads
#: either location (no migration).
STUDY_DB: Final[str] = "study.db"
#: The robust-eval held-out split assignment ``split.json`` written inside
#: :data:`DIR_SPLITS`. A machine-state sidecar — it must survive a
#: fresh-master rewrite and gate resume — so it lives in the hidden tune cache
#: (:data:`DIR_PHT_TUNE_CACHE`), not under :data:`DIR_DELIVERABLES`. See
#: :func:`tune_cache_split_assignment_path`.
SPLIT_ASSIGNMENT_JSON: Final[str] = "split.json"
#: The tune-run marker ``run.json`` written into :data:`DIR_PHT_TUNE_CACHE` at
#: run START (before any deliverable exists), so the GUI shell classifier can
#: recognise a live or finished tune output even before ``deliverables/`` lands.
#: Carries the study identity + storage URL + run policy. See
#: :func:`tune_cache_run_marker_path`.
RUN_MARKER_JSON: Final[str] = "run.json"
#: The robust-eval generalization report ``generalization.json`` written into
#: :data:`DIR_DELIVERABLES` — a user-facing deliverable (the winner's held-out
#: gap verdict). See :func:`generalization_path`.
GENERALIZATION_JSON: Final[str] = "generalization.json"
#: The Pareto-front parquet written by a **multi-objective** tune run into
#: :data:`DIR_PARETO` (under ``deliverables/``). One row per non-dominated trial
#: (the same schema as :data:`TRIALS_PARQUET`, with ``objectives_json`` populated).
PARETO_FRONT_PARQUET: Final[str] = "pareto_front.parquet"
#: Per-objective best-pipeline filename template written into :data:`DIR_PARETO`:
#: ``best_<objective>`` with the pipeline config suffix. One per
#: objective axis — the pipeline maximizing that single objective on the front.
#: Rendered by :func:`pareto_best_pipeline_path`; kept private (a parameterized
#: string is not an enumeration — see the code-style note on render functions).
_PARETO_BEST_PIPELINE_FILENAME_TEMPLATE: Final[str] = (
f"best_{{objective}}{CONFIG_SUFFIX_PIPELINE}"
)
#: Per-objective param-importance filename template written into :data:`DIR_PARETO`:
#: ``param_importance_<objective>.json`` (e.g. ``param_importance_s0.json``). The
#: multi-objective sibling of :data:`PARAM_IMPORTANCE_JSON` — one RF-permutation
#: importance report per objective axis. Rendered by :func:`pareto_importance_path`;
#: kept private (a parameterized string is not an enumeration).
_PARETO_IMPORTANCE_FILENAME_TEMPLATE: Final[str] = "param_importance_{objective}.json"
# ---------------------------------------------------------------------------
# QC artifact filenames (live inside DIR_QC)
# ---------------------------------------------------------------------------
#: Per-group QC summary written by
#: :func:`phenotypic.sdk_._qc_recipe._runner.run_qc`. One row per
#: ``(instance_id, groupby key)``: the worst-direction metric, tri-state
#: status, flag, member/flagged counts, and a worst-first ``rank``.
QC_SUMMARY_PARQUET: Final[str] = "qc_summary.parquet"
#: Per-group member colonies written by :func:`phenotypic.sdk_._qc_recipe._runner.run_qc`.
#: One row per ``(instance_id, group member)`` carrying the curation key
#: (``Metadata_ImageFile`` + ``Object_Label``) plus the member's
#: contributing value, so the Review gallery can render each group's tiles.
QC_MEMBERS_PARQUET: Final[str] = "qc_members.parquet"
#: Snapshot of the ``qc`` config entries (``instance_id``/``class``/
#: ``enabled``/``params``) that produced the current ``qc/`` artifact.
#: Written by :func:`phenotypic.sdk_._qc_recipe._runner.run_qc`.
QC_CONFIG_JSON: Final[str] = "qc_config.json"
#: Per-module GUI review progress (``instance_id`` -> reviewed group keys +
#: last position). Written **only** by the results-viewer QC Review tab;
#: :func:`phenotypic.sdk_._qc_recipe._runner.run_qc` never touches it. The CLI finalize
#: path clears it on every rerun (a fresh run resets review progress).
QC_REVIEW_STATE_JSON: Final[str] = "review_state.json"
# ---------------------------------------------------------------------------
# Run-time progress sidecar files (live inside DIR_PROGRESS)
# ---------------------------------------------------------------------------
#: Append-only JSONL event log of per-image processing transitions.
#: Producers: ``_cli_update_state.append_event``. Consumers: dashboard
#: generator, recompile worker. Was previously re-spelled in 10 files.
PROCESSING_EVENTS_LOG: Final[str] = "processing_events.log"
#: SLURM job-metadata sidecar written once at job submission. Keys are
#: documented in :class:`JobMetadataKey`.
JOB_METADATA_JSON: Final[str] = "job_metadata.json"
#: Append-only JSONL of per-image failures. Each row carries a
#: :data:`phenotypic.sdk_.typing_.FailureSource` tag.
FAILURES_JSONL: Final[str] = "failures.jsonl"
#: SLURM checkpoint chunk manifest (mid-run partial-result feed).
CHUNK_MANIFEST_JSON: Final[str] = "chunk_manifest.json"
#: SLURM checkpoint chunk state (which per-image files have been chunked).
CHUNK_STATE_JSON: Final[str] = "chunk_state.json"
#: Manifest of overlay PNGs (one entry per per-image overlay).
OVERLAY_MANIFEST_JSON: Final[str] = "overlay_manifest.json"
#: Top-level dashboard manifest read by the dashboard HTML JS shim.
#: Keys are documented in :class:`DashboardManifestKey`.
MANIFEST_JSON: Final[str] = "manifest.json"
#: Per-dataset pre-aggregated Parquet emitted by the chunk writer for the
#: GPFS-optimized aggregation path. Underscore prefix marks the file as
#: an aggregator-internal intermediate (chunk-writer scan should skip).
DATASET_AGGREGATED_PARQUET: Final[str] = "_dataset_aggregated.parquet"
# ---------------------------------------------------------------------------
# Dashboard / log files
# ---------------------------------------------------------------------------
#: Generated HTML dashboard with charts + chunked-data viewer.
DASHBOARD_HTML: Final[str] = "dashboard.html"
#: Per-run stdout capture written by the run console's local runner.
STDOUT_LOG: Final[str] = "stdout.log"
#: Generated standalone analysis HTML emitted alongside the dashboard.
ANALYSIS_HTML: Final[str] = "analysis.html"
#: Top-level processing report HTML emitted by recompile mode.
PROCESSING_REPORT_HTML: Final[str] = "processing_report.html"
#: Mid-run combined Parquet that the chunk writer rewrites incrementally
#: as new per-image measurements arrive (lives in ``DIR_PROGRESS``).
ANALYSIS_FULL_PARQUET: Final[str] = "analysis_full.parquet"
#: Pre-computed scatter-plot data for the dashboard analysis pane.
ANALYSIS_SCATTER_JSON: Final[str] = "analysis_scatter.json"
#: Sentinel marker file written by the SLURM sentinel after a re-submission;
#: presence prevents an infinite resubmission loop on the next checkpoint.
SENTINEL_RESUBMITTED_MARKER: Final[str] = "sentinel_resubmitted"
#: Hidden chunk-aggregation lock file (lives in ``DIR_PROGRESS``).
CHUNK_LOCK: Final[str] = ".chunk_lock"
#: Recompile task manifest JSON (one per recompile invocation; lives at
#: ``<output>/progress/recompile/task_manifest.json``). Lists every per-image
#: shard the recompile worker is responsible for; consumed by
#: ``_cli_recompile_worker._main``.
RECOMPILE_TASK_MANIFEST_JSON: Final[str] = "task_manifest.json"
# ---------------------------------------------------------------------------
# CLI artifact directory names
# ---------------------------------------------------------------------------
#: ``<output>/results/`` — per-dataset subdirectories live below here.
DIR_RESULTS: Final[str] = "results"
#: ``<output>/progress/`` — sidecar JSON / JSONL state for in-flight runs.
DIR_PROGRESS: Final[str] = "progress"
#: Per-dataset measurements subdirectory: ``<output>/results/<ds>/measurements/``.
DIR_MEASUREMENTS: Final[str] = "measurements"
#: Per-feature spreadsheet split written by
#: :func:`phenotypic._cli._cli_output_manager.split_master_by_feature`.
DIR_MEASUREMENTS_BY_FEATURE: Final[str] = "measurements_by_feature"
#: SLURM stdout/stderr / sbatch-script subdirectory.
DIR_LOGS: Final[str] = "logs"
#: HDF5 image-state subdirectory: ``<output>/results/<ds>/hdf/``.
DIR_HDF: Final[str] = "hdf"
#: Overlay PNG subdirectory: ``<output>/results/<ds>/overlays/``.
DIR_OVERLAYS: Final[str] = "overlays"
#: Inspect-figure PNG subdirectory:
#: ``<output>/results/<ds>/inspect/<measurer-step>/``. Provisioned only
#: when the CLI's ``--save-inspect`` flag is set; per-measurer
#: subdirectories are created lazily by ``OutputManager.save_inspect``.
DIR_INSPECT: Final[str] = "inspect"
#: Mid-run chunk parquet subdirectory: ``<progress>/chunks/``.
DIR_CHUNKS: Final[str] = "chunks"
#: Recompile-worker shard / status subdirectory: ``<progress>/recompile/``.
DIR_RECOMPILE: Final[str] = "recompile"
#: Per-task JSON status subdirectory: ``<progress>/recompile/status/``.
DIR_RECOMPILE_STATUS: Final[str] = "status"
#: Per-shard parquet subdirectory inside the recompile dir:
#: ``<progress>/recompile/measurement_shards/``.
DIR_RECOMPILE_SHARDS: Final[str] = "measurement_shards"
#: Generated SLURM script subdirectory: ``<output>/slurm_scripts/``.
DIR_SLURM_SCRIPTS: Final[str] = "slurm_scripts"
#: QC artifact subdirectory: ``<output>/qc/``. Holds
#: :data:`QC_SUMMARY_PARQUET`, :data:`QC_MEMBERS_PARQUET`,
#: :data:`QC_CONFIG_JSON` (written by ``run_qc``), and
#: :data:`QC_REVIEW_STATE_JSON` (written by the GUI Review tab).
DIR_QC: Final[str] = "qc"
#: ``<output>/deliverables/`` — all user-facing run outputs collected in one
#: folder: master + post-applied measurements, the per-feature splits, the
#: analysis frames, the generated dashboard/analysis/report HTML, the
#: :data:`README_MD`, and the canonical :data:`PIPELINE_JSON`. Distinct from
#: machine-state sidecars (``progress/``, ``processing_state.json``) and from
#: per-image artifacts (``results/``), which stay at the output root. Every
#: artifact-path helper below that previously rooted at ``<output>/`` now
#: roots at :func:`deliverables_dir`.
DIR_DELIVERABLES: Final[str] = "deliverables"
#: Per-category error-object parquet subdirectory under deliverables:
#: ``<output>/deliverables/errors/<category>.parquet``. Holds the master rows
#: for each triaged error category. **Dual-owned:** the GUI writes them live as
#: the user curates (via ``CurationLabels._save_locked``) and CLI finalize
#: re-emits them (all categories) from the durable ``qc/curation_labels.parquet``
#: via ``reemit_error_deliverables`` — so headless == live.
DIR_ERRORS: Final[str] = "errors"
#: Durable curation-labels store: ``<output>/qc/curation_labels.parquet``.
#: The source of truth for categorized removals; the CLI re-keys but never
#: wipes it (contrast :data:`QC_REVIEW_STATE_JSON`).
CURATION_LABELS_PARQUET: Final[str] = "curation_labels.parquet"
#: Ordered custom-category registry sidecar: ``<output>/qc/custom_categories.json``.
CUSTOM_CATEGORIES_JSON: Final[str] = "custom_categories.json"
#: Ranked error-cutoff analysis deliverables (``ErrorCutoffFinder`` output;
#: columns ``[category, *RESULT_COLUMNS]``). **Dual-owned, but with differing
#: scope:** the GUI's Error tab writes the parquet/csv live for the *focused*
#: category (transient, last-viewed-in-session) on each recompute, while CLI
#: finalize (``reemit_error_deliverables``) authoritatively rewrites them across
#: *all* labeled categories from the durable labels store. The HTML is written
#: only on an explicit GUI ``Save analysis report`` or by CLI finalize — never on
#: a live recompute.
ERROR_ANALYSIS_PARQUET: Final[str] = "error_analysis.parquet"
ERROR_ANALYSIS_CSV: Final[str] = "error_analysis.csv"
ERROR_ANALYSIS_HTML: Final[str] = "error_analysis.html"
#: Filename of the GUI-written verified-good baseline archive (spec §9). It is
#: derived from ``qc/review_state.json`` (which CLI finalize RESETS), so it is
#: GUI-owned and never CLI-emitted; finalize leaves any existing file untouched.
VERIFIED_PARQUET: Final[str] = "verified.parquet"
#: ``splits/`` — the robust-eval held-out **split assignment** sidecar folder
#: (holds :data:`SPLIT_ASSIGNMENT_JSON`). Lives inside the hidden tune cache
#: (:data:`DIR_PHT_TUNE_CACHE`), **not** under :data:`DIR_DELIVERABLES`: the
#: split is machine state that must survive a fresh-master rewrite and gate
#: resume. See :func:`tune_cache_splits_dir`.
DIR_SPLITS: Final[str] = "splits"
#: ``<output>/deliverables/pareto/`` — the **multi-objective** sub-folder holding
#: the Pareto front parquet (:data:`PARETO_FRONT_PARQUET`) and the per-objective
#: best pipelines (:func:`pareto_best_pipeline_path`). Written only by a
#: multi-objective tune run; a single-objective run never creates it (the
#: back-compat lock — plan §0b). Rooted under :func:`deliverables_dir`.
DIR_PARETO: Final[str] = "pareto"
#: ``<output>/.phenotypic/`` — hidden machine-state cache root. Holds the
#: run's progress/, processing_state.json, and processing_events.log. Hidden
#: so it does not clutter the user-facing output folder and is skipped by the
#: GUI's run/dataset candidate scan. NOTE: distinct from the GUI's
#: ``.phenotypic-gui`` sandbox dir (presets/state) — different root, different
#: purpose.
DIR_PHENOTYPIC: Final[str] = ".phenotypic"
#: ``<output>/.pht-tune-cache/`` — hidden machine-state cache root for a **tune**
#: run, the sibling of :data:`DIR_PHENOTYPIC` for the forward CLI. Holds the
#: Optuna ``study.db`` (+ WAL), the held-out ``splits/split.json``, and the
#: GUI-discovery ``run.json`` marker. Hidden so it does not clutter the
#: user-facing output and is skipped by the GUI candidate scan; the tune cache
#: is kept distinct from ``.phenotypic`` so a directory that hosts both a
#: forward run and a tune run never collides their machine-state. Note that
#: ``trials.parquet`` is NOT relocated here — it stays at the output root as the
#: dual-purpose Optuna-resume + user-facing trial journal.
DIR_PHT_TUNE_CACHE: Final[str] = ".pht-tune-cache"
# ---------------------------------------------------------------------------
# Templated filenames — Final[str] template + typed render function
# ---------------------------------------------------------------------------
# Pattern: keep the raw ``"…{x}…"`` template private as ``_FOO_TEMPLATE``,
# expose a typed render function whose parameters are the public API.
# Callers must reach the template through the function — never .format() it
# directly. This is the project's first deployment of CLAUDE.md's
# "parameterized strings are not enumerations" rule.
_TASK_STATUS_FILENAME_TEMPLATE: Final[str] = "task_{task_index}.json"
[docs]
def task_status_filename(task_index: int) -> str:
"""Filename of a per-task SLURM-recompile status JSON.
Args:
task_index: Zero-based recompile task index.
Returns:
Filename relative to ``<progress>/recompile/status/``.
"""
return _TASK_STATUS_FILENAME_TEMPLATE.format(task_index=task_index)
_SHARD_PARQUET_FILENAME_TEMPLATE: Final[str] = "shard_{shard_id}.parquet"
[docs]
def shard_parquet_filename(shard_id: int) -> str:
"""Filename of a per-shard Parquet inside the recompile worker.
Args:
shard_id: Zero-based shard index.
Returns:
Filename relative to the recompile shard directory.
"""
return _SHARD_PARQUET_FILENAME_TEMPLATE.format(shard_id=shard_id)
_CHUNK_PARQUET_FILENAME_TEMPLATE: Final[str] = "chunk_{chunk_id:03d}.parquet"
[docs]
def chunk_parquet_filename(chunk_id: int) -> str:
"""Filename of a dashboard chunk Parquet (zero-padded chunk id).
Args:
chunk_id: Zero-based chunk index, formatted ``{chunk_id:03d}``.
Returns:
Filename relative to ``<progress>/chunks/``.
"""
return _CHUNK_PARQUET_FILENAME_TEMPLATE.format(chunk_id=chunk_id)
_DEFAULT_OUTPUT_DIR_NAME_TEMPLATE: Final[str] = "phenotypic_results_{timestamp}"
_DEFAULT_OUTPUT_TIMESTAMP_FORMAT: Final[str] = "%Y%m%d_%H%M%S"
[docs]
def default_output_dir_name(now: Optional[datetime] = None) -> str:
"""Default name for an auto-generated output directory.
Legacy helper for timestamped output-directory names.
Args:
now: Override clock for tests; defaults to ``datetime.now()``.
Returns:
``"phenotypic_results_YYYYMMDD_HHMMSS"``.
"""
when = now if now is not None else datetime.now()
return _DEFAULT_OUTPUT_DIR_NAME_TEMPLATE.format(
timestamp=when.strftime(_DEFAULT_OUTPUT_TIMESTAMP_FORMAT)
)
_CHECKPOINT_LOCK_FILENAME_TEMPLATE: Final[str] = ".{checkpoint_type}_lock"
[docs]
def checkpoint_lock_filename(checkpoint_type: CheckpointType) -> str:
"""Filename of the SLURM-sentinel exclusive lock for a checkpoint task.
Args:
checkpoint_type: ``"manifest"`` or ``"finalize"``. Validated by
the :data:`phenotypic.sdk_.typing_.CheckpointType` Literal
alias at type-check time.
Returns:
Filename relative to ``<progress>/`` (hidden file, leading ``.``).
"""
return _CHECKPOINT_LOCK_FILENAME_TEMPLATE.format(checkpoint_type=checkpoint_type)
# ---------------------------------------------------------------------------
# Path-builder helpers — replace inline ``output / "literal"`` constructions
# ---------------------------------------------------------------------------
[docs]
def phenotypic_cache_dir(output_dir: Path) -> Path:
"""Return ``<output>/.phenotypic/`` — the hidden machine-state root.
Pure path expression; callers ``mkdir`` when they intend to write.
"""
return output_dir / DIR_PHENOTYPIC
[docs]
def progress_dir(output_dir: Path) -> Path:
"""Return ``<output>/.phenotypic/progress/``.
Pure path expression; callers are responsible for ``mkdir`` when they
intend to write into it.
"""
return phenotypic_cache_dir(output_dir) / DIR_PROGRESS
[docs]
def results_dir(output_dir: Path) -> Path:
"""Return ``<output>/results/``."""
return output_dir / DIR_RESULTS
[docs]
def deliverables_dir(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/`` — the user-facing-output folder.
Pure path expression; callers are responsible for ``mkdir`` when they
intend to write into it. Writers that go through
:func:`phenotypic._cli._cli_output_manager._atomic_write` get the
``mkdir`` for free (it creates ``target.parent``); direct
``write_text``/``write_bytes`` writers must ``mkdir`` explicitly.
Every artifact helper that previously rooted at ``<output>/`` (master /
measurements / per-feature split / analysis / dashboard / report /
pipeline.json / README) now composes from here, so a future relocation
is a one-line change.
"""
return output_dir / DIR_DELIVERABLES
[docs]
def event_log_path(output_dir: Path) -> Path:
"""Return ``<output>/.phenotypic/processing_events.log``."""
return phenotypic_cache_dir(output_dir) / PROCESSING_EVENTS_LOG
[docs]
def processing_state_path(output_dir: Path) -> Path:
"""Return ``<output>/.phenotypic/processing_state.json``."""
return phenotypic_cache_dir(output_dir) / PROCESSING_STATE_JSON
def _legacy_progress_dir(output_dir: Path) -> Path:
"""Pre-migration location: ``<output>/progress/``."""
return output_dir / DIR_PROGRESS
def _legacy_processing_state_path(output_dir: Path) -> Path:
"""Pre-migration location: ``<output>/processing_state.json``."""
return output_dir / PROCESSING_STATE_JSON
[docs]
def resolve_progress_dir(output_dir: Path) -> Path:
"""Return the progress dir that exists, preferring ``.phenotypic/``.
Read-only helper for resume/discovery so a pre-migration run (progress
at the output root) is still found. Falls back to the new location when
neither exists (the default for fresh writes).
"""
new = progress_dir(output_dir)
if new.exists():
return new
legacy = _legacy_progress_dir(output_dir)
if legacy.exists():
return legacy
return new
[docs]
def resolve_processing_state_path(output_dir: Path) -> Path:
"""Return the processing-state file that exists, preferring ``.phenotypic/``."""
new = processing_state_path(output_dir)
if new.exists():
return new
legacy = _legacy_processing_state_path(output_dir)
if legacy.exists():
return legacy
return new
[docs]
def resolve_manifest_json_path(output_dir: Path) -> Path:
"""Return ``<progress>/manifest.json`` resolving the progress dir for legacy runs."""
return resolve_progress_dir(output_dir) / MANIFEST_JSON
[docs]
def resolve_event_log_path(output_dir: Path) -> Path:
"""Return the event log sibling of the resolved progress dir.
The event log lives beside ``progress/`` (D14): in ``.phenotypic/`` for a
migrated/new run, at the output root for a not-yet-migrated legacy read.
Read-only helper for resume/discovery; never mutates the run dir.
"""
return resolve_progress_dir(output_dir).parent / PROCESSING_EVENTS_LOG
[docs]
def migrate_legacy_machine_state(output_dir: Path) -> bool:
"""Move a pre-migration run's machine-state into ``.phenotypic/``.
If legacy machine-state (``progress/``, ``processing_state.json``,
``processing_events.log``) is present at the output root, move each artifact
into the ``.phenotypic/`` cache so the run proceeds coherently against a
single location. A no-op when no legacy state is present or everything is
already migrated.
Robust to interruption and concurrency (the SLURM array case): each artifact
is moved only when its source still exists and its destination does not, so a
migration interrupted mid-move *completes* on the next call rather than
leaving split state; and a lost move race (a concurrent worker moved the
artifact first) is ignored rather than crashing. Keying per-artifact instead
of on ``cache.exists()`` is what makes both safe.
Returns:
``True`` if this call moved anything, else ``False``.
"""
import shutil
cache = phenotypic_cache_dir(output_dir)
legacy_progress = _legacy_progress_dir(output_dir)
legacy_state = _legacy_processing_state_path(output_dir)
legacy_events = output_dir / PROCESSING_EVENTS_LOG
if not (legacy_progress.exists() or legacy_state.exists() or legacy_events.exists()):
return False
cache.mkdir(parents=True, exist_ok=True)
moved = False
for src, dst in (
(legacy_progress, cache / DIR_PROGRESS),
(legacy_state, cache / PROCESSING_STATE_JSON),
(legacy_events, cache / PROCESSING_EVENTS_LOG),
):
if src.exists() and not dst.exists():
try:
shutil.move(str(src), str(dst))
moved = True
except (FileNotFoundError, shutil.Error):
# Lost a migration race with a concurrent worker; the winner is
# moving (or has moved) this artifact — safe to skip.
pass
return moved
[docs]
def clear_machine_state(output_dir: Path) -> bool:
"""Remove **all** of a run's machine-state for a clean ``--restart``.
Deletes the ``.phenotypic/`` cache (``progress/``, ``processing_state.json``,
``processing_events.log``) and any pre-migration root-level machine-state,
while leaving user-facing output artifacts (``deliverables/``, ``results/``,
``qc/``, ``logs/``, …) untouched. This is the difference between ``--restart``
(re-run the orchestration against clean state, keep outputs) and
``--overwrite`` (delete the whole output dir). Clearing the event log here is
what stops a restart from appending to — and rebuilding its manifest/failure
records from — the prior run's events.
Returns:
``True`` if any machine-state was removed, else ``False``.
"""
import shutil
removed = False
# Current layout: the hidden cache holds state + event log + progress/.
cache = phenotypic_cache_dir(output_dir)
if cache.exists():
shutil.rmtree(cache)
removed = True
# Pre-migration (legacy) root-level machine-state, if a legacy run is restarted.
legacy_progress = _legacy_progress_dir(output_dir)
if legacy_progress.exists():
shutil.rmtree(legacy_progress)
removed = True
for legacy_file in (
_legacy_processing_state_path(output_dir),
output_dir / PROCESSING_EVENTS_LOG,
):
if legacy_file.exists():
legacy_file.unlink()
removed = True
return removed
[docs]
def master_measurements_csv_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/master_measurements.csv``."""
return deliverables_dir(output_dir) / MASTER_MEASUREMENTS_CSV
[docs]
def master_measurements_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/master_measurements.parquet``."""
return deliverables_dir(output_dir) / MASTER_MEASUREMENTS_PARQUET
[docs]
def measurements_csv_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/measurements.csv`` (post-applied mirror)."""
return deliverables_dir(output_dir) / MEASUREMENTS_CSV
[docs]
def measurements_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/measurements.parquet`` (post-applied mirror)."""
return deliverables_dir(output_dir) / MEASUREMENTS_PARQUET
[docs]
def pipeline_json_path(output_dir: Path) -> Path:
"""Return the canonical typed pipeline config path under ``deliverables/``."""
return deliverables_dir(output_dir) / PIPELINE_JSON
def _legacy_pipeline_json_path(output_dir: Path) -> Path:
"""Return the legacy plain-JSON pipeline config path under ``deliverables/``."""
return deliverables_dir(output_dir) / _LEGACY_PIPELINE_JSON
[docs]
def resolve_pipeline_config_path(output_dir: Path) -> Path:
"""Return the best existing pipeline config path for ``output_dir``.
Resolution prefers the canonical typed path, falls back to legacy
``pipeline.json`` when present, and returns the canonical path when neither
exists so writers naturally create typed config files.
"""
canonical = pipeline_json_path(output_dir)
if canonical.exists():
return canonical
legacy = _legacy_pipeline_json_path(output_dir)
if legacy.exists():
return legacy
return canonical
[docs]
def tuning_spec_path(output_dir: Path) -> Path:
"""Return the canonical typed tuning spec path under ``deliverables/``."""
return deliverables_dir(output_dir) / TUNING_SPEC_JSON
def _legacy_tuning_spec_path(output_dir: Path) -> Path:
"""Return the legacy plain-JSON tuning spec path under ``deliverables/``."""
return deliverables_dir(output_dir) / _LEGACY_TUNING_SPEC_JSON
[docs]
def resolve_tuning_spec_path(output_dir: Path) -> Path:
"""Return the best existing tuning spec path for ``output_dir``."""
canonical = tuning_spec_path(output_dir)
if canonical.exists():
return canonical
legacy = _legacy_tuning_spec_path(output_dir)
if legacy.exists():
return legacy
return canonical
[docs]
def best_pipeline_path(output_dir: Path) -> Path:
"""Return the canonical typed tuned-winner pipeline path."""
return deliverables_dir(output_dir) / BEST_PIPELINE_JSON
def _legacy_best_pipeline_path(output_dir: Path) -> Path:
"""Return the legacy plain-JSON tuned-winner pipeline path."""
return deliverables_dir(output_dir) / _LEGACY_BEST_PIPELINE_JSON
[docs]
def resolve_best_pipeline_path(output_dir: Path) -> Path:
"""Return the best existing tuned-winner pipeline path for ``output_dir``."""
canonical = best_pipeline_path(output_dir)
if canonical.exists():
return canonical
legacy = _legacy_best_pipeline_path(output_dir)
if legacy.exists():
return legacy
return canonical
[docs]
def param_importance_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/param_importance.json`` (the report)."""
return deliverables_dir(output_dir) / PARAM_IMPORTANCE_JSON
[docs]
def best_params_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/best_params.json`` (winner params sidecar)."""
return deliverables_dir(output_dir) / BEST_PARAMS_JSON
[docs]
def trials_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/trials.parquet`` (the trial journal; output-dir root)."""
return Path(output_dir) / TRIALS_PARQUET
[docs]
def tune_cache_dir(output_dir: Path) -> Path:
"""Return ``<output>/.pht-tune-cache/`` — the tune run's machine-state root.
The tune-side sibling of :func:`phenotypic_cache_dir`. Pure path expression;
callers ``mkdir`` when they intend to write.
Args:
output_dir: The run output directory.
Returns:
``<output_dir>/.pht-tune-cache/``.
"""
return Path(output_dir) / DIR_PHT_TUNE_CACHE
[docs]
def tune_cache_run_marker_path(output_dir: Path) -> Path:
"""Return ``<output>/.pht-tune-cache/run.json`` — the tune-run marker.
Written at run START (before any deliverable lands) so a live or finished
tune output is GUI-discoverable. See :data:`RUN_MARKER_JSON`.
Args:
output_dir: The run output directory.
Returns:
``<output_dir>/.pht-tune-cache/run.json``.
"""
return tune_cache_dir(output_dir) / RUN_MARKER_JSON
[docs]
def tune_cache_study_db_path(output_dir: Path) -> Path:
"""Return ``<output>/.pht-tune-cache/study.db`` (the Optuna study DB).
The canonical SQLite-WAL storage for the Optuna-backed
:class:`OptunaStudyStore` when the ``tune`` extra is installed, relocated
into the hidden tune cache. A legacy run wrote it at the output root; use
:func:`resolve_study_db_path` to read either location.
Args:
output_dir: The run output directory.
Returns:
``<output_dir>/.pht-tune-cache/study.db``.
"""
return tune_cache_dir(output_dir) / STUDY_DB
[docs]
def tune_cache_splits_dir(output_dir: Path) -> Path:
"""Return ``<output>/.pht-tune-cache/splits/`` — the held-out split folder.
Machine state that must survive a fresh-master rewrite and gate resume, so
it lives in the hidden tune cache, **not** under :func:`deliverables_dir`.
Pure path expression; callers ``mkdir`` when they intend to write.
Args:
output_dir: The run output directory.
Returns:
``<output_dir>/.pht-tune-cache/splits/``.
"""
return tune_cache_dir(output_dir) / DIR_SPLITS
[docs]
def tune_cache_split_assignment_path(output_dir: Path) -> Path:
"""Return ``<output>/.pht-tune-cache/splits/split.json`` — the held-out split.
The persisted calibration / held-out partition (plate names + split kind +
dataset identity + seed entropy). Read-if-exists-else-derive on resume, so
a re-run reuses the original partition regardless of the new master seed. A
legacy run wrote it under ``<output>/splits/``; use
:func:`resolve_split_assignment_path` to read either location.
Args:
output_dir: The run output directory.
Returns:
``<output_dir>/.pht-tune-cache/splits/split.json``.
"""
return tune_cache_splits_dir(output_dir) / SPLIT_ASSIGNMENT_JSON
def _legacy_study_db_path(output_dir: Path) -> Path:
"""Pre-relocation location: ``<output>/study.db``."""
return Path(output_dir) / STUDY_DB
def _legacy_split_assignment_path(output_dir: Path) -> Path:
"""Pre-relocation location: ``<output>/splits/split.json``."""
return Path(output_dir) / DIR_SPLITS / SPLIT_ASSIGNMENT_JSON
[docs]
def resolve_study_db_path(output_dir: Path) -> Path:
"""Return the study DB that exists, preferring ``.pht-tune-cache/``.
Read-only resolver: a relocated run keeps ``study.db`` under the hidden tune
cache; a legacy run kept it at the output root. Falls back to the new
location when neither exists (so a cold sampler restart from a missing
``study.db`` is harmless — no migration is performed).
Args:
output_dir: The run output directory.
Returns:
The study DB path that exists, else the new cache location.
"""
new = tune_cache_study_db_path(output_dir)
if new.exists():
return new
legacy = _legacy_study_db_path(output_dir)
if legacy.exists():
return legacy
return new
[docs]
def resolve_split_assignment_path(output_dir: Path) -> Path:
"""Return the split assignment that exists, preferring ``.pht-tune-cache/``.
Read-only resolver mirroring :func:`resolve_progress_dir`. The held-out
split is checked in the hidden tune cache FIRST, THEN at the legacy output
root — a missing split silently RE-DERIVES a fresh held-out partition on
resume (a reproducibility / held-out-leak bug), so resume MUST find a
legacy-root ``split.json``. Falls back to the new location when neither
exists (the default for a fresh derive-and-write).
Args:
output_dir: The run output directory.
Returns:
The split-assignment path that exists, else the new cache location.
"""
new = tune_cache_split_assignment_path(output_dir)
if new.exists():
return new
legacy = _legacy_split_assignment_path(output_dir)
if legacy.exists():
return legacy
return new
[docs]
def generalization_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/generalization.json`` — the held-out report.
The winner's generalization verdict (calibration vs held-out score, the gap,
and the pass/fail margin), a user-facing deliverable. The held-out pass that
writes it is Phase 4.5 part 2; this helper resolves the canonical location.
Args:
output_dir: The run output directory.
Returns:
``<output_dir>/deliverables/generalization.json``.
"""
return deliverables_dir(output_dir) / GENERALIZATION_JSON
[docs]
def pareto_dir(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/pareto/`` — the multi-objective sub-folder.
Holds a multi-objective tune run's Pareto front + per-objective best
pipelines. A single-objective run never creates it (the back-compat lock).
"""
return deliverables_dir(output_dir) / DIR_PARETO
[docs]
def pareto_front_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/pareto/pareto_front.parquet`` (the front)."""
return pareto_dir(output_dir) / PARETO_FRONT_PARQUET
[docs]
def pareto_best_pipeline_path(output_dir: Path, objective: str) -> Path:
"""Return ``deliverables/pareto/best_<objective>.json`` (a per-axis winner).
The pipeline maximizing the single ``objective`` axis on the Pareto front.
``objective`` is the objective name as it appears in ``objectives_json`` (a
scorer-defined label, e.g. ``"Dice"`` or a composite child handle ``"s0"``).
Args:
output_dir: The run directory.
objective: The objective-axis name (the ``best_<objective>.json`` stem).
Returns:
The per-objective best-pipeline path under :func:`pareto_dir`.
"""
return pareto_dir(output_dir) / _PARETO_BEST_PIPELINE_FILENAME_TEMPLATE.format(
objective=objective
)
[docs]
def pareto_importance_path(output_dir: Path, objective: str) -> Path:
"""Return ``deliverables/pareto/param_importance_<objective>.json``.
The per-objective RF-permutation importance report (the multi-objective
sibling of :func:`param_importance_path`). ``objective`` is the objective
name as it appears in ``objectives_json`` (a scorer-defined label, e.g.
``"Dice"`` or a composite child handle ``"s0"``).
Args:
output_dir: The run directory.
objective: The objective-axis name (the filename's ``<objective>`` slot).
Returns:
The per-objective importance-report path under :func:`pareto_dir`.
"""
return pareto_dir(output_dir) / _PARETO_IMPORTANCE_FILENAME_TEMPLATE.format(
objective=objective
)
[docs]
def phenotypic_cache_pipeline_json_path(output_dir: Path) -> Path:
"""Return ``<output>/.phenotypic/pipeline.json`` — the process-only run's
reproducibility copy. Distinct from :func:`pipeline_json_path`, which roots
under ``deliverables/`` (process-only writes no deliverables)."""
return phenotypic_cache_dir(output_dir) / PIPELINE_JSON
[docs]
def analysis_csv_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/analysis.csv``."""
return deliverables_dir(output_dir) / ANALYSIS_CSV
[docs]
def analysis_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/analysis.parquet``."""
return deliverables_dir(output_dir) / ANALYSIS_PARQUET
[docs]
def dashboard_html_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/dashboard.html``."""
return deliverables_dir(output_dir) / DASHBOARD_HTML
[docs]
def dataset_results_dir(output_dir: Path, dataset: str) -> Path:
"""Return ``<output>/results/<dataset>/``."""
return results_dir(output_dir) / dataset
[docs]
def dataset_measurements_dir(output_dir: Path, dataset: str) -> Path:
"""Return ``<output>/results/<dataset>/measurements/``."""
return dataset_results_dir(output_dir, dataset) / DIR_MEASUREMENTS
[docs]
def dataset_hdf_dir(output_dir: Path, dataset: str) -> Path:
"""Return ``<output>/results/<dataset>/hdf/``."""
return dataset_results_dir(output_dir, dataset) / DIR_HDF
[docs]
def dataset_overlays_dir(output_dir: Path, dataset: str) -> Path:
"""Return ``<output>/results/<dataset>/overlays/``."""
return dataset_results_dir(output_dir, dataset) / DIR_OVERLAYS
[docs]
def measurements_by_feature_dir(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/measurements_by_feature/``."""
return deliverables_dir(output_dir) / DIR_MEASUREMENTS_BY_FEATURE
[docs]
def logs_dir(output_dir: Path) -> Path:
"""Return ``<output>/logs/`` (SLURM stdout/stderr + sbatch scripts)."""
return output_dir / DIR_LOGS
[docs]
def chunks_dir(progress_dir_: Path) -> Path:
"""Return ``<progress>/chunks/`` for the dashboard chunk parquets."""
return progress_dir_ / DIR_CHUNKS
[docs]
def recompile_dir(progress_dir_: Path) -> Path:
"""Return ``<progress>/recompile/``."""
return progress_dir_ / DIR_RECOMPILE
[docs]
def recompile_status_dir(progress_dir_: Path) -> Path:
"""Return ``<progress>/recompile/status/``."""
return recompile_dir(progress_dir_) / DIR_RECOMPILE_STATUS
[docs]
def task_status_path(output_dir: Path, task_index: int) -> Path:
"""Return ``<progress>/recompile/status/task_<idx>.json``."""
return recompile_status_dir(progress_dir(output_dir)) / task_status_filename(task_index)
[docs]
def chunk_parquet_path(progress_dir_: Path, chunk_id: int) -> Path:
"""Return ``<progress>/chunks/chunk_<id:03d>.parquet``."""
return chunks_dir(progress_dir_) / chunk_parquet_filename(chunk_id)
[docs]
def checkpoint_lock_path(progress_dir_: Path, checkpoint_type: CheckpointType) -> Path:
"""Return ``<progress>/.{checkpoint_type}_lock``."""
return progress_dir_ / checkpoint_lock_filename(checkpoint_type)
[docs]
def failures_jsonl_path(output_dir: Path) -> Path:
"""Return ``<output>/progress/failures.jsonl``."""
return progress_dir(output_dir) / FAILURES_JSONL
[docs]
def manifest_json_path(output_dir: Path) -> Path:
"""Return ``<output>/progress/manifest.json`` (dashboard manifest)."""
return progress_dir(output_dir) / MANIFEST_JSON
[docs]
def chunk_manifest_path(output_dir: Path) -> Path:
"""Return ``<output>/progress/chunk_manifest.json``."""
return progress_dir(output_dir) / CHUNK_MANIFEST_JSON
[docs]
def chunk_state_path(output_dir: Path) -> Path:
"""Return ``<output>/progress/chunk_state.json``."""
return progress_dir(output_dir) / CHUNK_STATE_JSON
[docs]
def overlay_manifest_path(output_dir: Path) -> Path:
"""Return ``<output>/progress/overlay_manifest.json``."""
return progress_dir(output_dir) / OVERLAY_MANIFEST_JSON
[docs]
def analysis_html_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/analysis.html``."""
return deliverables_dir(output_dir) / ANALYSIS_HTML
[docs]
def processing_report_html_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/processing_report.html``."""
return deliverables_dir(output_dir) / PROCESSING_REPORT_HTML
[docs]
def readme_md_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/README.md``."""
return deliverables_dir(output_dir) / README_MD
[docs]
def analysis_full_parquet_path(progress_dir_: Path) -> Path:
"""Return ``<progress>/analysis_full.parquet`` (incremental combined frame).
Takes a *progress_dir* (not the run output root) since this file lives
inside ``progress/`` — it's an internal mid-run artifact, not a user-
facing output.
"""
return progress_dir_ / ANALYSIS_FULL_PARQUET
[docs]
def analysis_scatter_json_path(progress_dir_: Path) -> Path:
"""Return ``<progress>/analysis_scatter.json``."""
return progress_dir_ / ANALYSIS_SCATTER_JSON
[docs]
def sentinel_resubmitted_path(progress_dir_: Path) -> Path:
"""Return ``<progress>/sentinel_resubmitted`` marker file path."""
return progress_dir_ / SENTINEL_RESUBMITTED_MARKER
[docs]
def chunk_lock_path(progress_dir_: Path) -> Path:
"""Return ``<progress>/.chunk_lock``."""
return progress_dir_ / CHUNK_LOCK
[docs]
def slurm_scripts_dir(output_dir: Path) -> Path:
"""Return ``<output>/slurm_scripts/``."""
return output_dir / DIR_SLURM_SCRIPTS
[docs]
def qc_dir(output_dir: Path) -> Path:
"""Return ``<output>/qc/``.
Pure path expression; ``run_qc`` is responsible for ``mkdir`` when it
writes into it (via :func:`_atomic_write`).
"""
return output_dir / DIR_QC
[docs]
def qc_summary_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/qc/qc_summary.parquet``."""
return qc_dir(output_dir) / QC_SUMMARY_PARQUET
[docs]
def qc_members_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/qc/qc_members.parquet``."""
return qc_dir(output_dir) / QC_MEMBERS_PARQUET
[docs]
def qc_config_json_path(output_dir: Path) -> Path:
"""Return ``<output>/qc/qc_config.json``."""
return qc_dir(output_dir) / QC_CONFIG_JSON
[docs]
def qc_review_state_path(output_dir: Path) -> Path:
"""Return ``<output>/qc/review_state.json`` (GUI-owned review progress)."""
return qc_dir(output_dir) / QC_REVIEW_STATE_JSON
[docs]
def errors_dir(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/errors/`` (per-category error parquets)."""
return deliverables_dir(output_dir) / DIR_ERRORS
[docs]
def error_category_parquet_path(output_dir: Path, category: str) -> Path:
"""Return ``<output>/deliverables/errors/<category>.parquet``.
Args:
output_dir: Run output directory.
category: A bare, already-sanitized category token (e.g.
``"background_noise"``). The caller is responsible for sanitization.
"""
return errors_dir(output_dir) / f"{category}.parquet"
[docs]
def error_analysis_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/error_analysis.parquet``."""
return deliverables_dir(output_dir) / ERROR_ANALYSIS_PARQUET
[docs]
def error_analysis_csv_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/error_analysis.csv``."""
return deliverables_dir(output_dir) / ERROR_ANALYSIS_CSV
[docs]
def error_analysis_html_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/error_analysis.html``."""
return deliverables_dir(output_dir) / ERROR_ANALYSIS_HTML
[docs]
def verified_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/deliverables/verified.parquet`` (GUI-written, §9)."""
return deliverables_dir(output_dir) / VERIFIED_PARQUET
[docs]
def curation_labels_parquet_path(output_dir: Path) -> Path:
"""Return ``<output>/qc/curation_labels.parquet`` (durable labels store)."""
return qc_dir(output_dir) / CURATION_LABELS_PARQUET
[docs]
def custom_categories_json_path(output_dir: Path) -> Path:
"""Return ``<output>/qc/custom_categories.json`` (custom-category registry)."""
return qc_dir(output_dir) / CUSTOM_CATEGORIES_JSON
# ---------------------------------------------------------------------------
# Loader / reader helpers (consolidate 3 duplicated read sites each)
# ---------------------------------------------------------------------------
[docs]
def read_run_manifest(output_dir: Path) -> Optional[dict]:
"""Read the run manifest if present, resolving legacy layouts.
Reads ``<output>/.phenotypic/progress/manifest.json``, falling back to the
pre-migration ``<output>/progress/manifest.json`` for legacy runs (via
:func:`resolve_manifest_json_path`). Replaces 4 inline
``json.loads(manifest_path.read_text())`` blocks.
Args:
output_dir: Run output directory containing ``progress/``.
Returns:
Parsed manifest dict, or :data:`None` when the file is missing
or unparseable (callers can decide whether absence is fatal).
"""
path = resolve_manifest_json_path(output_dir)
if not path.exists():
return None
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
logger.warning("Failed to parse %s; treating as missing", path, exc_info=True)
return None
[docs]
def load_master_measurements(output_dir: Path) -> Optional["pl.DataFrame"]:
"""Read ``<output>/master_measurements.csv`` into a polars DataFrame.
Args:
output_dir: Run output directory.
Returns:
DataFrame, or :data:`None` when the file is missing.
"""
import polars as pl # type: ignore[import-not-found] # lazy
path = master_measurements_csv_path(output_dir)
if not path.exists():
return None
return pl.read_csv(path)
[docs]
def resolve_execution_mode(job_meta: Optional[dict]) -> ExecutionMode:
"""Extract :data:`ExecutionMode` from job metadata, defaulting to ``"local"``.
Replaces a 5-site copy-paste of the
``job_meta.get("execution_mode", "local") if job_meta else "local"``
pattern.
**Silent coercion:** any value that isn't exactly ``"slurm"`` collapses
to ``"local"`` — including ``None`` (no metadata file), ``{}`` (no
key), garbage strings (e.g. ``"validate"``, ``""``), and the literal
``None`` value. The function never raises. Callers who need to detect
an unknown mode and warn / refuse should inspect ``job_meta`` directly
before calling this helper.
Args:
job_meta: Parsed ``progress/job_metadata.json`` content, or
:data:`None` when the file is absent.
Returns:
``"local"`` or ``"slurm"``.
"""
if not job_meta:
return "local"
raw = job_meta.get(JobMetadataKey.EXECUTION_MODE, "local")
if raw == "slurm":
return "slurm"
return "local"
# ---------------------------------------------------------------------------
# Cross-file JSON contract keys
# ---------------------------------------------------------------------------
[docs]
class DashboardManifestKey:
"""Keys inside ``<output>/progress/manifest.json`` (dashboard manifest).
The manifest is built by :func:`_cli._dashboard._manifest_builder.build_manifest`
and consumed by both the dashboard JS and the GUI run-console's runs
registry (``_runs_registry.py``). Writers and readers must reference
these constants rather than spelling the bare string.
"""
VERSION: Final[str] = "version"
LAST_UPDATED: Final[str] = "last_updated"
EXECUTION_MODE: Final[str] = "execution_mode"
TOTAL_IMAGES: Final[str] = "total_images"
COMPLETED: Final[str] = "completed"
FAILED: Final[str] = "failed"
STARTED: Final[str] = "started"
PENDING: Final[str] = "pending"
SUCCESS_RATE: Final[str] = "success_rate"
IS_COMPLETE: Final[str] = "is_complete"
START_TIME: Final[str] = "start_time"
INPUT_PATH: Final[str] = "input_path"
DATASETS: Final[str] = "datasets"
FAILURE_CATEGORIES: Final[str] = "failure_categories"
ANALYSIS_DATA_VERSION: Final[str] = "analysis_data_version"
SLURM_INFO: Final[str] = "slurm_info"
[docs]
class DashboardManifestSlurmInfoKey:
"""Keys inside the ``slurm_info`` sub-dict of the dashboard manifest.
Distinct from :class:`JobMetadataKey`, even when string values overlap —
these describe the *manifest* contract, not the job-metadata sidecar.
"""
CHUNK_SCRIPTS: Final[str] = "chunk_scripts"
TOTAL_CHUNKS: Final[str] = "total_chunks"
CHUNK_JOB_IDS: Final[str] = "chunk_job_ids"
ACTIVE_CHUNKS: Final[str] = "active_chunks"
COMPLETED_CHUNKS: Final[str] = "completed_chunks"
PENDING_CHUNKS: Final[str] = "pending_chunks"
[docs]
class ChunkStateKey:
"""Keys inside ``<output>/progress/chunk_state.json``."""
CHUNKED_FILES: Final[str] = "chunked_files"
NEXT_CHUNK_ID: Final[str] = "next_chunk_id"
[docs]
class ProcessingStateKey:
"""Keys inside ``<output>/processing_state.json`` (resume-state file).
Distinct from :class:`JobMetadataKey` even where string values overlap
(e.g. ``EXECUTION_MODE``, ``INPUT_PATH``) — these describe the
`processing_state.json` contract, not the SLURM job metadata sidecar.
Some values intentionally match across the two contracts so that a
single field (like ``execution_mode``) can be migrated atomically;
the ``test_processing_state_keys_match_job_metadata_keys`` regression
test asserts the overlap.
"""
VERSION: Final[str] = "version"
PIPELINE_PATH: Final[str] = "pipeline_path"
INPUT_PATH: Final[str] = "input_path"
OUTPUT_DIR: Final[str] = "output_dir"
TIMESTAMP: Final[str] = "timestamp"
EXECUTION_MODE: Final[str] = "execution_mode"
LAST_UPDATED: Final[str] = "last_updated"
DATASETS: Final[str] = "datasets"
CONFIG: Final[str] = "config"
# Per-dataset state-dict sub-keys
COMPLETED: Final[str] = "completed"
FAILED: Final[str] = "failed"
STARTED: Final[str] = "started"
ERRORS: Final[str] = "errors"
INITIAL_IMAGES: Final[str] = "initial_images"
[docs]
class ChunkManifestKey:
"""Keys inside ``<output>/progress/chunk_manifest.json``."""
CHUNKS: Final[str] = "chunks"
ROWS: Final[str] = "rows"
DATASETS: Final[str] = "datasets"
TOTAL_ROWS: Final[str] = "total_rows"
NAME: Final[str] = "name"
[docs]
class HdfAttr:
"""Top-level attribute keys on per-image HDF5 files."""
PHENOTYPIC_CLASS: Final[str] = "phenotypic_class"
# ---------------------------------------------------------------------------
# Module-path constants for importlib dispatch
# ---------------------------------------------------------------------------
[docs]
class ModulePath:
"""Importable module paths used in dynamic ``importlib.import_module`` dispatch.
Spelled out here so a renamed sub-package fails at type-check time
(consumers reference ``ModulePath.POST`` — a typo there is caught
by mypy) rather than silently at runtime.
"""
POST: Final[str] = "phenotypic.post"
ANALYSIS: Final[str] = "phenotypic.analysis"
# ---------------------------------------------------------------------------
# Environment variable names
# ---------------------------------------------------------------------------
[docs]
class EnvVar:
"""Environment variable names read or set by the CLI.
SLURM injects these into batch scripts; the CLI reads them to
discover its execution context (job id, array task id, …) and
to find node-local scratch storage.
"""
SCRATCH: Final[str] = "SCRATCH"
SLURM_JOB_ID: Final[str] = "SLURM_JOB_ID"
SLURM_ARRAY_JOB_ID: Final[str] = "SLURM_ARRAY_JOB_ID"
SLURM_ARRAY_TASK_ID: Final[str] = "SLURM_ARRAY_TASK_ID"
SLURM_ARRAY_TASK_COUNT: Final[str] = "SLURM_ARRAY_TASK_COUNT"
SLURM_CPUS_PER_TASK: Final[str] = "SLURM_CPUS_PER_TASK"
SLURM_MEM_PER_NODE: Final[str] = "SLURM_MEM_PER_NODE"
# ---------------------------------------------------------------------------
# HDF image-class reader (eliminates 3-site duplication)
# ---------------------------------------------------------------------------
[docs]
def load_image_from_hdf(
hdf_path: Path,
*,
fallback: ImageTypeName = "Image",
) -> "_Image | _GridImage":
"""Open an HDF5, read its ``phenotypic_class`` attr, dispatch to the right Image class.
Replaces the 3 ad-hoc ``h5py.File(...) → fh.attrs.get('phenotypic_class', 'Image')
→ GridImage if cls_attr == 'GridImage' else Image`` patterns in
:mod:`_cli_recompile_worker`, :mod:`_cli_execution_strategies`, and
:mod:`phenotypicCLI`.
Args:
hdf_path: Path to a per-image HDF5 file.
fallback: Image class name to use when the HDF lacks the
``phenotypic_class`` attribute (legacy files). Type-checked
(statically) against :data:`ImageTypeName` — there is no
runtime validation; the only effect of an unrecognized
string is that the dispatch falls through to :class:`Image`.
Returns:
An :class:`Image` or :class:`GridImage` instance loaded from the HDF.
"""
import h5py # type: ignore[import-untyped]
from phenotypic import GridImage, Image # lazy: avoids circular import at module load
from phenotypic.sdk_.constants_ import IMAGE_TYPES
with h5py.File(hdf_path, "r") as fh:
cls_attr = fh.attrs.get(HdfAttr.PHENOTYPIC_CLASS, fallback)
if isinstance(cls_attr, bytes):
cls_attr = cls_attr.decode("utf-8", errors="replace")
image_cls = GridImage if cls_attr == IMAGE_TYPES.GRID.value else Image
return image_cls.load_hdf5(hdf_path)