Source code for parasolpy.interactive

"""Interactive CLI helpers for epsilon experiment workflows.

These functions handle user prompting and input validation; they contain no
analysis logic. Import them in scripts that need a terminal-based interface
for iterating on epsilon non-dominance parameters.
"""

import json
from pathlib import Path


[docs] class ExitInteractiveSession(Exception): """Raised when the user requests to exit an interactive CLI workflow."""
def _raise_if_exit_requested(value): """Exit the interactive workflow when the user types a quit command.""" if value.lower() in {"q", "quit", "exit"}: raise ExitInteractiveSession()
[docs] def prompt_experiment_name(prompt_text="\nExperiment name: "): """Prompt the user for a filesystem-safe experiment name. Returns: str: A sanitized, non-empty experiment name suitable for use in filenames. """ while True: name = input(prompt_text).strip() _raise_if_exit_requested(name) if not name: print(" Name cannot be empty.") continue safe = "".join(c if c.isalnum() or c in "_-" else "_" for c in name) if safe != name: print(f" Sanitized to: {safe}") return safe
[docs] def prompt_epsilons(objective_names, current_epsilons): """Prompt for an epsilon value per objective, carrying forward the current value as default. Args: objective_names: List of objective name strings shown as labels. current_epsilons: List of current epsilon floats used as defaults. Returns: list of float: New epsilon values, one per objective. """ if len(objective_names) != len(current_epsilons): raise ValueError( "objective_names and current_epsilons must have the same length." ) print("Enter epsilon for each objective (press Enter to keep current value, or 'q' to quit):") new_epsilons = [] for name, current in zip(objective_names, current_epsilons): while True: raw = input(f" {name} [{current}]: ").strip() _raise_if_exit_requested(raw) if raw == "": new_epsilons.append(current) break try: val = float(raw) if val <= 0: print(" Epsilon must be positive.") continue new_epsilons.append(val) break except ValueError: print(" Invalid number, try again.") return new_epsilons
[docs] def load_experiment_epsilons(output_folder, experiment_name, objective_names): """Load epsilon defaults from a saved experiment JSON file. Args: output_folder: Folder containing per-experiment JSON files. experiment_name: Experiment suffix used in eps_experiment_<name>.json. objective_names: Ordered list of objectives to align the returned epsilons. Returns: list of float: Epsilons ordered to match objective_names. """ log_path = Path(output_folder) / f"eps_experiment_{experiment_name}.json" if not log_path.exists(): raise FileNotFoundError(f"Experiment file not found: {log_path}") with open(log_path, "r", encoding="utf-8") as f: try: record = json.load(f) except json.JSONDecodeError as e: raise ValueError(f"Cannot parse experiment file {log_path}: {e}") from e if not isinstance(record, dict) or "epsilons" not in record: raise ValueError(f"Experiment file is missing an 'epsilons' object: {log_path}") epsilon_map = record["epsilons"] missing = [name for name in objective_names if name not in epsilon_map] if missing: raise ValueError( f"Experiment file {log_path.name} is missing epsilon values for: {missing}" ) return [float(epsilon_map[name]) for name in objective_names]
[docs] def prompt_starting_epsilons(output_folder, objective_names, default_epsilons): """Optionally seed starting epsilons from a previously saved experiment. If no prior experiment is chosen, returns default_epsilons unchanged along with None for the experiment name. """ folder = Path(output_folder) available = sorted( path.stem.removeprefix("eps_experiment_") for path in folder.glob("eps_experiment_*.json") ) if not available: return default_epsilons, None print("Saved epsilon experiments:") print(f" {', '.join(available)}") if len(available) == 1: prompt = ( "Load saved experiment by name" f" (or 'y' to use '{available[0]}')? [Enter to skip]: " ) else: prompt = "Load saved experiment by name? [Enter to skip, q to quit]: " choice = input(prompt).strip() _raise_if_exit_requested(choice) if not choice: return default_epsilons, None if len(available) == 1 and choice.lower() in {"y", "yes"}: choice = available[0] return load_experiment_epsilons(folder, choice, objective_names), choice