From a0a02500fb3f7dbdf56e5096d57a27605a3f6575 Mon Sep 17 00:00:00 2001 From: yannrichet-asnr Date: Wed, 29 Jul 2026 11:36:06 +0200 Subject: [PATCH 1/2] feat: configurable case directory naming (case_naming), thread-safe signal handling fzr() gains a case_naming option ("path" default, "hash", "index") to avoid exceeding filesystem filename length limits when many input variables are used (var1=val1,var2=val2,... can exceed ~255 chars). With "hash"/"index", a single cases.csv manifest is written at the results root mapping each case directory to its variables; fzo() reads it back (falling back to each case's own info.txt) when directory names don't parse as "key=val,...". fzd() now runs its internal per-iteration fzr() calls with case_naming="index" by default, since algorithm-generated design points can carry many variables with long float values; cache:// matching is unaffected since it's based on .fz_hash content, not directory names. Also fixes fzr/fzd installing a SIGINT handler unconditionally, which raised ValueError when called from a non-main thread (Streamlit reruns, a ThreadPoolExecutor worker, or any background thread embedding fz). Co-Authored-By: Claude Sonnet 5 --- NEWS.md | 27 +++++++ README.md | 23 ++++++ doc/INDEX.md | 4 ++ doc/core-functions.md | 14 ++++ doc/parallel-and-caching.md | 5 ++ fz/cli.py | 16 ++++- fz/config.py | 16 ++++- fz/core.py | 113 ++++++++++++++++++++++++++--- fz/helpers.py | 140 ++++++++++++++++++++++++++++++++---- skills/fz/reference.md | 24 ++++++- tests/test_case_naming.py | 137 +++++++++++++++++++++++++++++++++++ tests/test_skill_static.py | 8 ++- 12 files changed, 498 insertions(+), 29 deletions(-) create mode 100644 tests/test_case_naming.py diff --git a/NEWS.md b/NEWS.md index e5e361c..2135cc1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,32 @@ # FZ Release Notes +## Unreleased + +### Configurable case directory naming (`case_naming`), thread-safe signal handling + +- `fzr()`/CLI `fzr`/`fz run` gain a `case_naming` parameter (`--case_naming`, + env `FZ_CASE_NAMING`): `"path"` (default, unchanged `var1=val1,var2=val2,...` + subdirectories), `"hash"` (short content hash of the variable combination), + or `"index"` (`case_`). `"path"` can exceed filesystem filename length + limits (~255 chars) with many input variables; `"hash"`/`"index"` avoid + that. With `"hash"`/`"index"`, a single `cases.csv` manifest is written + at the results root mapping each case directory to its variables; each + case's own `info.txt` still has them too, as a fallback if the manifest + is missing or incomplete. `fzo()` now recovers variable columns from + whichever is available when a directory name doesn't parse as + `key=val,...`. +- Fixed: `fzr`/`fzd` installed a `SIGINT` handler unconditionally, which + raises `ValueError` when called from a non-main thread (e.g. Streamlit + reruns, a `ThreadPoolExecutor` worker, or a background thread embedding + fz). Signal handler install/restore is now skipped outside the main + thread instead of raising. +- `fzd()` now runs its internal per-iteration `fzr()` calls (file-based + models) with `case_naming="index"` rather than the default `"path"`: + algorithm-generated design points can carry many variables with long + float values, so `iter/case_/` avoids filename length limits. + `cache://` matching is by `.fz_hash` content, not directory name, so + cross-iteration cache reuse is unaffected. + ## Unreleased (feat/vector-objectives-fzd) ### Multi-objective (vector) objectives in fzd diff --git a/README.md b/README.md index 5bdb6ac..9c88808 100644 --- a/README.md +++ b/README.md @@ -704,6 +704,7 @@ fzr input.txt \ ``` --calculator URI Calculator URI (can be specified multiple times) --results DIR Results directory (default: results) +--case_naming SCHEME Case directory naming: path (default), hash, or index ``` ### Complete CLI Examples @@ -1025,6 +1026,12 @@ print(output) # 2 T_celsius=30,V_L=1 2520.74 30.0 1.0 ``` +If subdirectories were instead named with `case_naming="hash"` or `"index"` (see below), +`fzo` recovers the variable columns from `cases.csv`, a single manifest `fzr` writes +at the results root mapping each case directory to its variables (falling back to +each case's own `info.txt`, which always has `input.=` lines, if the +manifest is missing or incomplete). + ### fzr - Run Parametric Calculations Execute complete parametric study with automatic parallelization: @@ -1064,6 +1071,17 @@ print(results) - `model`: Model definition (dict or alias) - `calculators`: Calculator URI(s) - string or list - `results_dir`: Results directory path +- `case_naming`: How each case's result/temp subdirectory is named (default `"path"`): + - `"path"`: `var1=val1,var2=val2,...` - human-readable, but can exceed filesystem + filename length limits (~255 chars) with many variables + - `"hash"`: short content hash of the variable combination - always short and stable + - `"index"`: `case_` - shortest, order-dependent + + With `"hash"`/`"index"`, a single `cases.csv` manifest is written at the results + root mapping each case directory to its variables, and `fzo()` reads it back when + the directory name isn't a `key=val,...` pattern (falling back to each case's own + `info.txt` if the manifest is missing or incomplete). Defaults to the + `FZ_CASE_NAMING` env var, or `"path"`. **Returns**: pandas DataFrame with all results @@ -2444,6 +2462,11 @@ export FZ_SHELL_PATH=/usr/local/bin:/usr/bin # Run timeout in seconds (default: 600 = 10 minutes) export FZ_RUN_TIMEOUT=3600 + +# Case directory naming scheme: "path" (var=val,... subdirs, default), "hash" +# (short content hash, avoids filesystem filename length limits with many +# variables), or "index" (case_) +export FZ_CASE_NAMING=path ``` ### Shell Path Configuration (FZ_SHELL_PATH) diff --git a/doc/INDEX.md b/doc/INDEX.md index fdc4ac0..66c54b1 100644 --- a/doc/INDEX.md +++ b/doc/INDEX.md @@ -217,11 +217,13 @@ Quick reference index for finding specific topics in the FZ context documentatio | Set up parallel execution | parallel-and-caching.md → "Basic Parallel Execution" | | Use caching | calculators.md → "Cache Calculator" | | Debug my calculation | quick-examples.md → "Troubleshooting Examples" | +| Avoid filename length limits with many variables | core-functions.md → "fzr" → `case_naming` | ## Configuration & Advanced Topics | Topic | File | Section | |-------|------|---------| +| Case directory naming (`case_naming`, `FZ_CASE_NAMING`) | core-functions.md | "fzr" | | FZ_SHELL_PATH overview | shell-path.md | "Overview" | | Shell path setup | shell-path.md | "Usage" | | Windows path configuration | shell-path.md | "Common Configurations" → "Windows with MSYS2" | @@ -267,3 +269,5 @@ Quick keyword search: - **Interrupt**: parallel-and-caching.md → "Interrupt Handling" - **Retry**: parallel-and-caching.md → "Retry Mechanism" - **Performance**: parallel-and-caching.md → "Performance Optimization" +- **case_naming / FZ_CASE_NAMING**: core-functions.md → "fzr" +- **cases.csv manifest**: core-functions.md → "fzo" → "Automatic Variable Extraction" diff --git a/doc/core-functions.md b/doc/core-functions.md index 4ce45c5..4b6ac5a 100644 --- a/doc/core-functions.md +++ b/doc/core-functions.md @@ -330,6 +330,13 @@ If subdirectory names follow the pattern `key1=val1,key2=val2,...`, variables ar # Automatically creates columns: mesh=100, dt=0.01, solver="fast" ``` +If the directory names don't follow that pattern (e.g. `fzr` was run with +`case_naming="hash"` or `"index"`), `fzo` recovers the variable columns from +`cases.csv`, a single manifest `fzr` writes at the results root mapping each +case directory to its variables (falling back to each case's own `info.txt`, +which always has `input.=` lines, if the manifest is missing or +incomplete). + ### Output Type Casting Values are automatically cast to appropriate types: @@ -372,6 +379,13 @@ results_df = fz.fzr( - `model` (dict or str): Model definition or alias - `calculators` (str or list): Calculator URI(s) - `results_dir` (str): Results directory path (default: "results") +- `case_naming` (str): How each case's result/temp subdirectory is named - `"path"` + (`var1=val1,var2=val2,...`, default; human-readable but can exceed filesystem + filename length limits with many variables), `"hash"` (short content hash, always + short and stable), or `"index"` (`case_`, shortest). With `"hash"`/`"index"`, a + single `cases.csv` manifest is written at the results root mapping each case + directory to its variables (each case's own `info.txt` also has them, as a + fallback). Defaults to the `FZ_CASE_NAMING` env var, or `"path"`. **Returns**: pandas DataFrame with all results and metadata diff --git a/doc/parallel-and-caching.md b/doc/parallel-and-caching.md index 34e4921..3d465c9 100644 --- a/doc/parallel-and-caching.md +++ b/doc/parallel-and-caching.md @@ -137,6 +137,11 @@ f6e5d4c3b2a1... config.dat 3. If match found and outputs are valid → reuse results 4. If no match → run calculation +Matching is by `.fz_hash` content, not by directory name, so it's unaffected by +`case_naming` (see core-functions.md → "fzr") — a `cache://` calculator still finds +matches whether the cache directory was written with `case_naming="path"`, +`"hash"`, or `"index"`. + ### Strategy 1: Resume Interrupted Runs ```python diff --git a/fz/cli.py b/fz/cli.py index d2e6d04..e7f0768 100644 --- a/fz/cli.py +++ b/fz/cli.py @@ -607,6 +607,11 @@ def fzr_main(): _add_variables_arg(parser) parser.add_argument("--results_dir", "--results", "-r", dest="results_dir", default="results", help="Results directory (default: results)") + parser.add_argument("--case_naming", dest="case_naming", default=None, + choices=["path", "hash", "index"], + help="Case directory naming scheme: 'path' (var=val,... subdirs, default), " + "'hash' (short content hash, avoids filename length limits), or " + "'index' (case_). Defaults to FZ_CASE_NAMING env var, or 'path'.") _add_calculators_arg(parser) _add_format_arg(parser) @@ -620,7 +625,8 @@ def fzr_main(): result = fzr_func(input_path, variables, model, results_dir=args.results_dir, - calculators=calculators) + calculators=calculators, + case_naming=args.case_naming) print(format_output(result, args.format)) # Exit non-zero when no case succeeded, so shell scripts and agents # can detect total failure without parsing the per-case status column @@ -739,6 +745,11 @@ def main(): _add_variables_arg(parser_run) parser_run.add_argument("--results_dir", "--results", "-r", dest="results_dir", default="results", help="Results directory (default: results)") + parser_run.add_argument("--case_naming", dest="case_naming", default=None, + choices=["path", "hash", "index"], + help="Case directory naming scheme: 'path' (var=val,... subdirs, default), " + "'hash' (short content hash, avoids filename length limits), or " + "'index' (case_). Defaults to FZ_CASE_NAMING env var, or 'path'.") _add_calculators_arg(parser_run) _add_format_arg(parser_run) @@ -834,7 +845,8 @@ def main(): result = fzr_func(input_path, variables, model, results_dir=args.results_dir, - calculators=calculators) + calculators=calculators, + case_naming=args.case_naming) print(format_output(result, args.format)) elif args.command == "design": diff --git a/fz/config.py b/fz/config.py index 6fb5277..cce485d 100755 --- a/fz/config.py +++ b/fz/config.py @@ -84,6 +84,16 @@ def _load_from_environment(self): # Shell path configuration (overrides system PATH for binary resolution) self.shell_path = os.getenv('FZ_SHELL_PATH', None) + # Case directory naming scheme: "path" (key=val,... subdirs, default), + # "hash" (short hash of the variable combination), or "index" (case_). + # "hash"/"index" avoid exceeding filesystem filename length limits when + # there are many input variables; the variable values are still + # recoverable from each case's info.txt. + case_naming = os.getenv('FZ_CASE_NAMING', 'path').lower() + if case_naming not in ('path', 'hash', 'index'): + case_naming = 'path' + self.case_naming = case_naming + def _parse_int_env(self, key: str, default: Optional[int]) -> Optional[int]: """Parse integer environment variable""" value = os.getenv(key) @@ -129,7 +139,8 @@ def get_summary(self) -> dict: 'ssh_auto_accept_hostkeys': self.ssh_auto_accept_hostkeys, 'ssh_keepalive': self.ssh_keepalive, 'run_timeout': self.run_timeout, - 'shell_path': self.shell_path + 'shell_path': self.shell_path, + 'case_naming': self.case_naming } @@ -222,6 +233,9 @@ def print_config(): print("\n🔍 SHELL PATH:") print(f" FZ_SHELL_PATH = {summary['shell_path'] or '(not set, use system PATH)'}") + print("\n📁 CASE DIRECTORY NAMING:") + print(f" FZ_CASE_NAMING = {summary['case_naming']}") + print("\n" + "=" * 60) print("Set environment variables to customize these defaults") print("Example: export FZ_LOG_LEVEL=INFO FZ_MAX_RETRIES=3") diff --git a/fz/core.py b/fz/core.py index ca0848d..c239769 100644 --- a/fz/core.py +++ b/fz/core.py @@ -63,7 +63,7 @@ def utf8_open( import shutil from .logging import log_error, log_warning, log_info, log_debug -from .config import get_interpreter +from .config import get_interpreter, get_config from .helpers import ( fz_temporary_directory, _cleanup_fzr_resources, @@ -74,6 +74,7 @@ def utf8_open( run_cases_parallel, compile_to_result_directories, prepare_temp_directories, + read_case_naming_manifest, ) from .shell import run_command, replace_commands_in_string from .outparsers import ( @@ -459,9 +460,19 @@ def _signal_handler(signum, frame): def _install_signal_handler(): - """Install custom SIGINT handler - Windows and Unix compatible""" + """Install custom SIGINT handler - Windows and Unix compatible. + + signal.signal() only works from the main thread of the main interpreter + (Python raises ValueError otherwise). fzr/fzd are frequently called from + worker threads, Streamlit reruns, or process-pool workers, so silently + skip installation there instead of crashing the caller. + """ global _original_sigint_handler + if threading.current_thread() is not threading.main_thread(): + log_debug("Not in main thread: skipping SIGINT handler installation") + return + # On Windows, signal handling needs special care if platform.system() == "Windows": try: @@ -472,14 +483,22 @@ def _install_signal_handler(): log_warning(f"⚠️ Could not install signal handler on Windows: {e}") log_warning("⚠️ Graceful interrupt may not work. Use Ctrl+Break for forceful termination.") else: - _original_sigint_handler = signal.signal(signal.SIGINT, _signal_handler) + try: + _original_sigint_handler = signal.signal(signal.SIGINT, _signal_handler) + except ValueError as e: + log_debug(f"Could not install SIGINT handler: {e}") def _restore_signal_handler(): """Restore original SIGINT handler""" global _original_sigint_handler if _original_sigint_handler: - signal.signal(signal.SIGINT, _original_sigint_handler) + if threading.current_thread() is not threading.main_thread(): + return + try: + signal.signal(signal.SIGINT, _original_sigint_handler) + except ValueError: + pass _original_sigint_handler = None @@ -1403,6 +1422,60 @@ def fzo( # Keep as string cast_values.append(v) df[key] = cast_values + elif not all_parseable: + # Directory names don't follow "key=val,..." (e.g. fzr was run with + # case_naming="hash" or "index"). First try the single manifest fzr + # writes at the results root (cases.json: case dir name -> variables); + # fall back to each case's own info.txt (which always has + # "input.=" lines regardless of naming scheme) if the + # manifest is missing or incomplete for these paths. + manifest_vars = {} + manifest_ok = True + manifests_by_parent = {} + for output_path_single in output_paths: + parent = output_path_single.parent + if parent not in manifests_by_parent: + manifests_by_parent[parent] = read_case_naming_manifest(parent) + manifest = manifests_by_parent[parent] + if manifest is None or output_path_single.name not in manifest: + manifest_ok = False + break + for key, val in manifest[output_path_single.name].items(): + manifest_vars.setdefault(key, []).append(val) + + if manifest_ok and manifest_vars and all(len(v) == len(output_paths) for v in manifest_vars.values()): + for key, values in manifest_vars.items(): + df[key] = values + else: + info_vars = {} + info_all_found = True + for output_path_single in output_paths: + info_path = output_path_single / "info.txt" + if not info_path.exists(): + info_all_found = False + break + row_vars = {} + for line in info_path.read_text().splitlines(): + if line.startswith("input.") and "=" in line: + key, val = line[len("input."):].split("=", 1) + row_vars[key] = val + for key, val in row_vars.items(): + info_vars.setdefault(key, []).append(val) + + # Only keep columns present in every case, so rows stay aligned + # (e.g. avoid misalignment if a case's info.txt is missing a variable) + if info_all_found and info_vars and all(len(v) == len(output_paths) for v in info_vars.values()): + for key, values in info_vars.items(): + cast_values = [] + for v in values: + try: + if "." not in v: + cast_values.append(int(v)) + else: + cast_values.append(float(v)) + except ValueError: + cast_values.append(v) + df[key] = cast_values # Flatten any dict-valued columns into separate columns df = flatten_dict_columns(df) @@ -1423,6 +1496,7 @@ def fzr( calculators: Union[str, Dict, List[Union[str, Dict]]] = None, callbacks: Optional[Dict[str, callable]] = None, timeout: int = None, + case_naming: str = None, ) -> Union[Dict[str, List[Any]], "pandas.DataFrame"]: """ Run full parametric calculations @@ -1443,6 +1517,14 @@ def fzr( - 'on_progress': Called periodically. Args: (completed, total, eta_seconds) - 'on_complete': Called when all cases finish. Args: (total_cases, completed_cases, results) timeout: Timeout in seconds for each calculation (None uses FZ_RUN_TIMEOUT from config, default 600) + case_naming: How to name each case's result/temp subdirectory: + - "path" (default): "var1=val1,var2=val2,..." - human-readable, but can exceed + filesystem filename length limits (~255 chars) with many variables. + - "hash": short content hash of the variable combination - always short and stable. + - "index": "case_" - shortest, order-dependent. + Regardless of scheme, the exact variable values are always recoverable from + each case's info.txt, and fzo() falls back to reading it when the directory + name doesn't parse as "key=val,...". Defaults to FZ_CASE_NAMING env var, or "path". Returns: DataFrame with variable values and results (if pandas available), otherwise Dict with lists @@ -1472,6 +1554,12 @@ def fzr( if not isinstance(results_dir, (str, Path)): raise TypeError(f"results_dir must be a string or Path, got {type(results_dir).__name__}") + # Resolve case_naming: explicit arg > FZ_CASE_NAMING env var (via config) > "path" + if case_naming is None: + case_naming = get_config().case_naming + elif case_naming not in ("path", "hash", "index"): + raise ValueError(f"case_naming must be one of 'path', 'hash', 'index', got {case_naming!r}") + if calculators is not None: if not isinstance(calculators, (str, list, dict)): raise TypeError(f"calculators must be a string, dict, or list, got {type(calculators).__name__}") @@ -1601,11 +1689,11 @@ def fzr( # Compile all combinations directly to result directories, then prepare temp directories compile_to_result_directories( - input_path, model, input_variables, var_combinations, results_dir + input_path, model, input_variables, var_combinations, results_dir, case_naming ) # Create temp directories and copy from result directories (excluding .fz_hash) - prepare_temp_directories(var_combinations, temp_path, results_dir, has_input_variables) + prepare_temp_directories(var_combinations, temp_path, results_dir, has_input_variables, case_naming) # Run calculations in parallel across cases try: @@ -1622,6 +1710,7 @@ def fzr( has_input_variables, callbacks, timeout, + case_naming, ) # Collect results in the correct order, filtering out None (interrupted/incomplete cases) @@ -1999,7 +2088,10 @@ def fzd( - Dict: {"batch_size": 10, "max_iter": 100} - JSON string: '{"batch_size": 10, "max_iter": 100}' - JSON file path: "options.json" - analysis_dir: Analysis results directory (default: "analysis"; the CLI uses "results_fzd") + analysis_dir: Analysis results directory (default: "analysis"; the CLI uses "results_fzd"). + Each iteration's cases live in "/iter/case_/" — file-based + models are run via fzr() internally with case_naming="index", since design + points from an algorithm can carry many variables/long float values. Returns: Dict with algorithm results including: @@ -2212,7 +2304,12 @@ def fzd( pd.DataFrame(unique_design, columns=all_var_names), model, results_dir=str(iteration_result_dir), - calculators=[*cache_paths, *calculators] # Cache paths first, then actual calculators + calculators=[*cache_paths, *calculators], # Cache paths first, then actual calculators + # fzd design points can have many variables and long float + # values; "index" keeps case directory names short regardless + # (cache:// matching is by .fz_hash content, not directory name, + # so this doesn't affect cross-iteration cache reuse above). + case_naming="index", ) # Expand result_df back to full current_design length (re-map duplicates) diff --git a/fz/helpers.py b/fz/helpers.py index d9cb984..c281958 100644 --- a/fz/helpers.py +++ b/fz/helpers.py @@ -82,7 +82,107 @@ def fz_temporary_directory(session_cwd=None): pass -def _get_result_directory(var_combo: Dict, case_index: int, resultsdir: Path, total_cases: int, has_input_variables: bool = True) -> Tuple[Path, str]: +def _case_subdir_name(var_combo: Dict, case_index: int, total_cases: int, case_naming: str = "path") -> str: + """ + Compute the case subdirectory name for a variable combination. + + Args: + var_combo: Variable combination dict + case_index: Index of this case + total_cases: Total number of cases (used to zero-pad "index" naming) + case_naming: Naming scheme - "path" (key=val,... - default, human-readable + but can exceed filesystem filename length limits with many + variables), "hash" (short content hash of the combination, + always short and stable), or "index" (case_, shortest and + fully order-dependent). Variable values remain recoverable + from each case's info.txt regardless of scheme. + + Returns: + Subdirectory name (may be empty string if var_combo is empty under "path") + """ + if case_naming == "hash": + import hashlib + import json + # Sort keys for a stable hash regardless of dict insertion order + canonical = json.dumps(var_combo, sort_keys=True, default=str) + digest = hashlib.sha1(canonical.encode("utf-8")).hexdigest()[:12] + return f"case_{digest}" + elif case_naming == "index": + width = len(str(max(total_cases - 1, 0))) + return f"case_{case_index:0{width}d}" + else: + # "path" (default, backward-compatible): key=val,key2=val2,... + return ",".join(f"{k}={v}" for k, v in var_combo.items()) + + +CASES_MANIFEST_FILENAME = "cases.csv" + + +def _cast_manifest_value(v: str): + """Cast a CSV string field back to int/float when possible, else keep as str.""" + try: + if "." not in v: + return int(v) + return float(v) + except ValueError: + return v + + +def write_case_naming_manifest(var_combinations: List[Dict], resultsdir: Path, case_naming: str) -> None: + """ + Write a single CSV manifest at the results root mapping each case's directory + name to its variable combination, for "hash"/"index" naming where the + directory name itself no longer shows the variable values. + + Each case's own info.txt already has this ("input.=" lines); + this manifest just avoids opening one file per case to recover it. + + Args: + var_combinations: List of variable combinations (cases), in case order + resultsdir: Results directory (manifest is written at its root) + case_naming: Case directory naming scheme (only "hash"/"index" call this) + """ + import csv + + if not var_combinations: + return + + var_names = list(var_combinations[0].keys()) + with open(resultsdir / CASES_MANIFEST_FILENAME, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["case"] + var_names) + for i, var_combo in enumerate(var_combinations): + case_name = _case_subdir_name(var_combo, i, len(var_combinations), case_naming) + writer.writerow([case_name] + [var_combo[v] for v in var_names]) + + +def read_case_naming_manifest(resultsdir: Path) -> Optional[Dict[str, Dict]]: + """ + Read the case-naming manifest (see write_case_naming_manifest) from a + results directory, if present. + + Returns: + Dict mapping case directory name to its variable combination (values + cast to int/float where possible), or None if no manifest exists at + resultsdir/cases.csv. + """ + import csv + + manifest_path = Path(resultsdir) / CASES_MANIFEST_FILENAME + if not manifest_path.exists(): + return None + try: + manifest = {} + with open(manifest_path, newline="") as f: + for row in csv.DictReader(f): + case_name = row.pop("case") + manifest[case_name] = {k: _cast_manifest_value(v) for k, v in row.items()} + return manifest + except Exception: + return None + + +def _get_result_directory(var_combo: Dict, case_index: int, resultsdir: Path, total_cases: int, has_input_variables: bool = True, case_naming: str = "path") -> Tuple[Path, str]: """ Get result directory path and case name for a given variable combination @@ -92,6 +192,7 @@ def _get_result_directory(var_combo: Dict, case_index: int, resultsdir: Path, to resultsdir: Base results directory total_cases: Total number of cases has_input_variables: Whether input_variables dict is non-empty. If False, output files go directly in resultsdir. + case_naming: Case directory naming scheme - "path", "hash", or "index" (see _case_subdir_name) Returns: Tuple of (result_dir_path, case_name) @@ -100,7 +201,7 @@ def _get_result_directory(var_combo: Dict, case_index: int, resultsdir: Path, to # (even if there's only one case, or if var_combo is empty due to grid expansion with no variables) if has_input_variables: # Always create subdirectory based on variable values when input_variables is not empty - case_subdir = ",".join(f"{k}={v}" for k, v in var_combo.items()) + case_subdir = _case_subdir_name(var_combo, case_index, total_cases, case_naming) result_dir = resultsdir / case_subdir case_name = case_subdir if case_subdir else "case_0" else: @@ -111,7 +212,7 @@ def _get_result_directory(var_combo: Dict, case_index: int, resultsdir: Path, to return result_dir, case_name -def _get_case_directories(var_combo: Dict, case_index: int, temp_path: Path, resultsdir: Path, total_cases: int, has_input_variables: bool = True) -> Tuple[Path, Path, str]: +def _get_case_directories(var_combo: Dict, case_index: int, temp_path: Path, resultsdir: Path, total_cases: int, has_input_variables: bool = True, case_naming: str = "path") -> Tuple[Path, Path, str]: """ Determine temp and result directory paths for a case @@ -125,17 +226,18 @@ def _get_case_directories(var_combo: Dict, case_index: int, temp_path: Path, res resultsdir: Base results directory total_cases: Total number of cases has_input_variables: Whether input_variables dict is non-empty + case_naming: Case directory naming scheme - "path", "hash", or "index" (see _case_subdir_name) Returns: Tuple of (tmp_dir, result_dir, case_name) """ # Get result directory path and case name - result_dir, case_name = _get_result_directory(var_combo, case_index, resultsdir, total_cases, has_input_variables) + result_dir, case_name = _get_result_directory(var_combo, case_index, resultsdir, total_cases, has_input_variables, case_naming) # Temp directory: mirror the result directory structure under temp_path if has_input_variables: # Always create subdirectory in temp when input_variables is not empty - case_subdir = ",".join(f"{k}={v}" for k, v in var_combo.items()) + case_subdir = _case_subdir_name(var_combo, case_index, total_cases, case_naming) tmp_dir = temp_path / case_subdir if case_subdir else temp_path / "case_0" else: # When input_variables is empty, use base temp directory @@ -763,13 +865,14 @@ def run_single_case(case_info: Dict) -> Dict[str, Any]: has_input_variables = case_info.get("has_input_variables", True) # Directory structure flag callbacks = case_info.get("callbacks") # Optional callbacks for progress monitoring timeout = case_info.get("timeout") # Optional timeout for calculations + case_naming = case_info.get("case_naming", "path") # Case directory naming scheme # Get thread ID for debugging thread_id = threading.get_ident() # Determine case directories using centralized function to prevent mixing tmp_dir, result_dir, case_name = _get_case_directories( - var_combo, case_index, temp_path, resultsdir, len(case_info["total_cases"]), has_input_variables + var_combo, case_index, temp_path, resultsdir, len(case_info["total_cases"]), has_input_variables, case_naming ) log_debug(f"🔄 [Thread {thread_id}] Starting {case_name}") @@ -1283,7 +1386,7 @@ def run_cases_parallel(var_combinations: List[Dict], temp_path: Path, resultsdir calculators: List[str], model: Dict, original_input_was_dir: bool, var_names: List[str], output_keys: List[str], original_cwd: str = None, has_input_variables: bool = True, callbacks: Optional[Dict[str, callable]] = None, - timeout: int = None) -> List[Dict[str, Any]]: + timeout: int = None, case_naming: str = "path") -> List[Dict[str, Any]]: """ Run multiple cases in parallel across available calculators @@ -1299,6 +1402,7 @@ def run_cases_parallel(var_combinations: List[Dict], temp_path: Path, resultsdir has_input_variables: Whether input_variables dict is non-empty callbacks: Optional dict of callback functions for progress monitoring timeout: Timeout in seconds for each calculation (None uses FZ_RUN_TIMEOUT from config, default 600) + case_naming: Case directory naming scheme - "path", "hash", or "index" (see _case_subdir_name) Returns: List of case results in the same order as var_combinations @@ -1345,10 +1449,11 @@ def run_cases_parallel(var_combinations: List[Dict], temp_path: Path, resultsdir "spinner": spinner, # Add spinner instance "has_input_variables": has_input_variables, # Add flag for directory structure "callbacks": callbacks, # Add callbacks for progress monitoring - "timeout": timeout # Add timeout for calculations + "timeout": timeout, # Add timeout for calculations + "case_naming": case_naming # Add case directory naming scheme } case_infos.append(case_info) - case_name = ",".join(f"{k}={v}" for k, v in var_combo.items()) if len(var_combinations) > 1 else "single case" + case_name = _case_subdir_name(var_combo, i, len(var_combinations), case_naming) if len(var_combinations) > 1 else "single case" log_info(f"🚀 Case {i}: {case_name}") # Determine number of worker threads (number of non-cache calculators) @@ -1541,7 +1646,7 @@ def run_cases_parallel(var_combinations: List[Dict], temp_path: Path, resultsdir def compile_to_result_directories(input_path: str, model: Dict, input_variables: Dict, var_combinations: List[Dict], - resultsdir: Path) -> None: + resultsdir: Path, case_naming: str = "path") -> None: """ Compile input files directly to result directories for each case @@ -1551,6 +1656,7 @@ def compile_to_result_directories(input_path: str, model: Dict, input_variables: input_variables: Dict of variable values. If non-empty, subdirectories are created for each case. var_combinations: List of variable combinations (cases) resultsdir: Results directory + case_naming: Case directory naming scheme - "path", "hash", or "index" (see _case_subdir_name) """ from .interpreter import replace_variables_in_content, evaluate_formulas from .io import create_hash_file @@ -1575,10 +1681,17 @@ def compile_to_result_directories(input_path: str, model: Dict, input_variables: # Ensure main results directory exists resultsdir.mkdir(parents=True, exist_ok=True) + # With "hash"/"index" naming, the directory name no longer shows the + # variable values; write a single manifest at the results root mapping + # each case directory to its variables, so they don't have to be + # recovered one info.txt at a time. + if case_naming in ("hash", "index") and has_input_variables: + write_case_naming_manifest(var_combinations, resultsdir, case_naming) + for case_index, var_combo in enumerate(var_combinations): # Use dedicated result directory function to avoid any temp_path contamination result_dir, case_name = _get_result_directory( - var_combo, case_index, resultsdir, len(var_combinations), has_input_variables + var_combo, case_index, resultsdir, len(var_combinations), has_input_variables, case_naming ) # Create result directory @@ -1629,7 +1742,7 @@ def compile_file(src_path: Path, dst_path: Path): -def prepare_temp_directories(var_combinations: List[Dict], temp_path: Path, resultsdir: Path, has_input_variables: bool = True) -> None: +def prepare_temp_directories(var_combinations: List[Dict], temp_path: Path, resultsdir: Path, has_input_variables: bool = True, case_naming: str = "path") -> None: """ Create temporary directories and copy files from result directories (excluding .fz_hash) @@ -1638,11 +1751,12 @@ def prepare_temp_directories(var_combinations: List[Dict], temp_path: Path, resu temp_path: Temporary path for calculations resultsdir: Results directory with compiled files and hashes has_input_variables: Whether input_variables dict is non-empty + case_naming: Case directory naming scheme - "path", "hash", or "index" (see _case_subdir_name) """ for case_index, var_combo in enumerate(var_combinations): # Use centralized directory determination tmp_dir, result_dir, case_name = _get_case_directories( - var_combo, case_index, temp_path, resultsdir, len(var_combinations), has_input_variables + var_combo, case_index, temp_path, resultsdir, len(var_combinations), has_input_variables, case_naming ) # Create temp directory for this case, cleaning up any existing files first diff --git a/skills/fz/reference.md b/skills/fz/reference.md index 7c48a1c..c6e7b3a 100644 --- a/skills/fz/reference.md +++ b/skills/fz/reference.md @@ -37,7 +37,12 @@ fz.fzo(output_path: str, model: str | dict) -> pandas.DataFrame Runs each `model["output"]` command in every directory matched by `output_path` (plain path or glob like `results/*`). Returns one row per directory. Directory names following -`key=val,key=val` are parsed back into variable columns. Results are auto-cast (int, +`key=val,key=val` are parsed back into variable columns; if they don't follow that +pattern (e.g. `fzr` ran with `case_naming="hash"`/`"index"`), `fzo` reads `cases.csv` +(a single manifest `fzr` writes at the results root mapping each case directory to its +variables) instead, falling back to each case's own `info.txt` +(`input.=` lines) if the manifest is missing or incomplete. +Results are auto-cast (int, float, list, dict) when possible. An output entry may resolve to a **list** (vector output: time series, per-node profile, ...) via `python://grep(..., all=True)`, `csv_file(column=...)`, `hdf5_file(dataset=...)`, `jq://`/`yq://` filters selecting an @@ -56,12 +61,19 @@ fz.fzr(input_path: str, results_dir: str = "results", calculators: str | list[str] = None, # default "sh://" callbacks: dict = None, - timeout: int = None) -> pandas.DataFrame + timeout: int = None, + case_naming: str = None) -> pandas.DataFrame # "path" (default), "hash", "index" ``` - dict `input_variables` ⇒ factorial (Cartesian product); DataFrame ⇒ one case per row. - Returns a DataFrame: variable columns + output columns + `status` ("done", "error", "cached"), `calculator`, `error`, `command`. +- `case_naming` controls each case's result/temp subdirectory name: `"path"` + (`var1=val1,var2=val2,...`, default, but can exceed filesystem filename length + limits with many variables), `"hash"` (short content hash, always short/stable), or + `"index"` (`case_`). With `"hash"`/`"index"`, a single `cases.csv` manifest is + written at the results root (case dir name → variables); each case's own `info.txt` + also has them, as a fallback. Defaults to the `FZ_CASE_NAMING` env var, or `"path"`. - `callbacks` supports `on_start(total_cases, calculators)`, plus per-case progress callbacks (see docstring of `fz.fzr`). - Ctrl+C interrupts gracefully; completed cases stay in `results_dir` and can be reused @@ -83,6 +95,11 @@ fz.fzd(input_path: str | None, Returns `{"XY": DataFrame, "analysis": ..., "iterations": int, "total_evaluations": int, "summary": str}`. Duplicate points within a batch are deduplicated; previously evaluated points are cached across iterations and re-runs. +For file-based models, each iteration's cases live under +`/iter/case_/` — fzd always calls `fzr()` internally with +`case_naming="index"` (not overridable), since algorithm-generated design points +can carry many variables/long float values; `cache://` matching is by `.fz_hash` +content, not directory name, so this doesn't affect cross-iteration cache reuse. `output_expression` (a str, or a list of str for multi-objective algorithms — one scalar per expression is passed to the algorithm) also reduces vector-valued outputs (lists, e.g. a time series) to the scalar fzd needs: besides the usual math functions and indexing/slicing (`series[-1]`), `sum()`, `len()`, `sorted()`, `mean()`, `median()`, `stdev()`, @@ -131,7 +148,7 @@ fzi [input_path] --input_path/-i --model/-m --format/-f fzc [input_path] --input_path/-i --model/-m --input_variables/-v --output_dir/-o fzo [output_path] --output_path/-o --model/-m --format/-f fzr [input_path] --input_path/-i --model/-m --input_variables/-v --results_dir/-r - --calculators/-c --format/-f + --calculators/-c --format/-f --case_naming {path,hash,index} fzl --models/-m --calculators/-c --check --format/-f fzd --input_dir/-i --input_vars/-v --model/-m --output_expression/-e --algorithm/-a --results_dir/-r --calculators/-c --options/-o @@ -228,6 +245,7 @@ FZ_MAX_RETRIES attempts for failed cases (default 5) FZ_SSH_AUTO_ACCEPT_HOSTKEYS 1 to skip interactive host-key prompt (CI; use with care) FZ_SSH_KEEPALIVE SSH keepalive seconds FZ_SHELL_PATH bash location on Windows (MSYS2/Git Bash bin dirs) +FZ_CASE_NAMING fzr case dir naming: path (default) | hash | index ``` ## Variable syntax in input files diff --git a/tests/test_case_naming.py b/tests/test_case_naming.py new file mode 100644 index 0000000..e592652 --- /dev/null +++ b/tests/test_case_naming.py @@ -0,0 +1,137 @@ +""" +Tests for fzr's case_naming option ("path", "hash", "index"), the single +cases.csv manifest written at the results root for "hash"/"index" naming, +and the fzo() fallback (manifest first, then per-case info.txt) that +recovers variable values when directory names aren't "key=val,...". +""" +import csv +from pathlib import Path + +import fz + + +def _read_manifest(manifest_path): + with open(manifest_path, newline="") as f: + return {row["case"]: {k: v for k, v in row.items() if k != "case"} for row in csv.DictReader(f)} + + +def _write_input(tmp_path): + input_file = tmp_path / "input.txt" + input_file.write_text("x=$x\ny=$y\n") + return input_file + + +def test_case_naming_path_default(tmp_path): + input_file = _write_input(tmp_path) + results_dir = tmp_path / "results" + res = fz.fzr( + str(input_file), {"x": [1, 2], "y": [10, 20]}, + {"output": {"echo": "echo done"}}, + results_dir=str(results_dir), calculators="sh://true", + ) + paths = sorted(res["path"]) + assert paths == [ + f"{results_dir}/x=1,y=10", + f"{results_dir}/x=1,y=20", + f"{results_dir}/x=2,y=10", + f"{results_dir}/x=2,y=20", + ] + # No cases.csv manifest for the default "path" naming - it would be redundant + assert not (results_dir / "cases.csv").exists() + + +def test_case_naming_hash_manifest_and_fzo_fallback(tmp_path): + input_file = _write_input(tmp_path) + results_dir = tmp_path / "results_hash" + res = fz.fzr( + str(input_file), {"x": [1, 2], "y": [10, 20]}, + {"output": {"echo": "echo done"}}, + results_dir=str(results_dir), calculators="sh://true", case_naming="hash", + ) + for p in res["path"]: + assert Path(p).name.startswith("case_") + assert "=" not in Path(p).name + # Each case's own info.txt still has the values too (used as fallback + # if the manifest is missing/incomplete) + assert (Path(p) / "info.txt").exists() + + # Single manifest at the results root maps each case dir to its variables + manifest_path = results_dir / "cases.csv" + assert manifest_path.exists() + manifest = _read_manifest(manifest_path) + assert set(manifest.keys()) == {Path(p).name for p in res["path"]} + for case_name, case_vars in manifest.items(): + row = res[res["path"] == str(results_dir / case_name)].iloc[0] + assert int(case_vars["x"]) == row["x"] + assert int(case_vars["y"]) == row["y"] + + # fzo must recover x/y columns via the manifest, since directory names + # don't parse as "key=val,..." + out = fz.fzo(f"{results_dir}/*", {"output": {"echo": "echo done"}}) + assert sorted(out["x"].tolist()) == [1, 1, 2, 2] + assert sorted(out["y"].tolist()) == [10, 10, 20, 20] + + +def test_case_naming_index_manifest_and_fzo_fallback(tmp_path): + input_file = _write_input(tmp_path) + results_dir = tmp_path / "results_index" + res = fz.fzr( + str(input_file), {"x": [1, 2], "y": [10, 20]}, + {"output": {"echo": "echo done"}}, + results_dir=str(results_dir), calculators="sh://true", case_naming="index", + ) + names = sorted(Path(p).name for p in res["path"]) + assert names == ["case_0", "case_1", "case_2", "case_3"] + + manifest = _read_manifest(results_dir / "cases.csv") + assert set(manifest.keys()) == set(names) + + out = fz.fzo(f"{results_dir}/*", {"output": {"echo": "echo done"}}) + assert sorted(out["x"].tolist()) == [1, 1, 2, 2] + assert sorted(out["y"].tolist()) == [10, 10, 20, 20] + + +def test_case_naming_fzo_falls_back_to_info_txt_without_manifest(tmp_path): + """If cases.csv is missing/deleted, fzo still recovers variables from info.txt.""" + input_file = _write_input(tmp_path) + results_dir = tmp_path / "results_no_manifest" + fz.fzr( + str(input_file), {"x": [1, 2]}, + {"output": {"echo": "echo done"}}, + results_dir=str(results_dir), calculators="sh://true", case_naming="index", + ) + (results_dir / "cases.csv").unlink() + + out = fz.fzo(f"{results_dir}/*", {"output": {"echo": "echo done"}}) + assert sorted(out["x"].tolist()) == [1, 2] + + +def test_case_naming_invalid_raises(tmp_path): + input_file = _write_input(tmp_path) + try: + fz.fzr( + str(input_file), {"x": [1]}, {"output": {"echo": "echo done"}}, + results_dir=str(tmp_path / "r"), calculators="sh://true", case_naming="bogus", + ) + assert False, "expected ValueError" + except ValueError: + pass + + +def test_case_naming_env_var_default(tmp_path, monkeypatch): + from fz.config import reload_config + + monkeypatch.setenv("FZ_CASE_NAMING", "index") + reload_config() + try: + input_file = _write_input(tmp_path) + results_dir = tmp_path / "results_env" + res = fz.fzr( + str(input_file), {"x": [1, 2]}, {"output": {"echo": "echo done"}}, + results_dir=str(results_dir), calculators="sh://true", + ) + names = sorted(Path(p).name for p in res["path"]) + assert names == ["case_0", "case_1"] + finally: + monkeypatch.delenv("FZ_CASE_NAMING", raising=False) + reload_config() diff --git a/tests/test_skill_static.py b/tests/test_skill_static.py index 941d28f..e6f27d9 100644 --- a/tests/test_skill_static.py +++ b/tests/test_skill_static.py @@ -98,8 +98,12 @@ def test_documented_cli_flags_exist(self): def test_documented_format_choices(self): """--format values listed in reference.md equal the argparse choices""" code_choices = set() - for block in re.findall(r"choices=\[([^\]]+)\]", CLI_SRC): - code_choices |= {c.strip().strip("\"'") for c in block.split(",")} + # Scope to add_argument calls for --format specifically: other flags + # (e.g. --case_naming) also use choices=[...] for unrelated option sets. + for call in re.findall(r'add_argument\([^)]*"--format"[^)]*\)', CLI_SRC, re.S): + m = re.search(r"choices=\[([^\]]+)\]", call) + if m: + code_choices |= {c.strip().strip("\"'") for c in m.group(1).split(",")} ref = (SKILL_DIR / "reference.md").read_text(encoding="utf-8") m = re.search(r"`--format` accepts: (.+?)\.", ref) assert m, "reference.md must list the --format choices" From 13794f33c95f520bf71be5483fe95ae96a7b3ad9 Mon Sep 17 00:00:00 2001 From: yannrichet-asnr Date: Wed, 29 Jul 2026 12:56:18 +0200 Subject: [PATCH 2/2] fix: use OS-agnostic path comparison in test_case_naming_path_default Windows CI failed: the test built expected paths with a hardcoded "/" separator, which doesn't match fzr's native "\" paths on Windows. Compare directory basenames via Path instead. Co-Authored-By: Claude Sonnet 5 --- tests/test_case_naming.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/test_case_naming.py b/tests/test_case_naming.py index e592652..d124cf3 100644 --- a/tests/test_case_naming.py +++ b/tests/test_case_naming.py @@ -29,13 +29,8 @@ def test_case_naming_path_default(tmp_path): {"output": {"echo": "echo done"}}, results_dir=str(results_dir), calculators="sh://true", ) - paths = sorted(res["path"]) - assert paths == [ - f"{results_dir}/x=1,y=10", - f"{results_dir}/x=1,y=20", - f"{results_dir}/x=2,y=10", - f"{results_dir}/x=2,y=20", - ] + names = sorted(Path(p).name for p in res["path"]) + assert names == ["x=1,y=10", "x=1,y=20", "x=2,y=10", "x=2,y=20"] # No cases.csv manifest for the default "path" naming - it would be redundant assert not (results_dir / "cases.csv").exists()