From 03a5b6d82473eda636e15d96066f7cc558d32cad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:29:21 +0000 Subject: [PATCH 1/2] docs/skill: constraints page and corrections, re-applied on the restructured doc/ Re-applies the unmerged review of claude/fz-docs-skills-review-8xkpgw on top of #98 (doc/ restructuring) and P0-8 (sh:// path resolution), after re-checking every finding against current main by running fz: - doc/limitations.md (new): verified constraints and pitfalls; sh:// part updated for P0-8 (argument appending remains a trap) - fzr examples passing calculators as 4th positional argument (results_dir) - default delimiters (() for variables without delim), no ?var conversion - fzc per-case sub-directories, fzo on case dirs; skill ladder fixed - FZ_RUN_TIMEOUT=0, first Ctrl+C terminates running cases, cache://_ - os.environ after import -> reload_config(); fz.shell imports - funz:// UDP port, SSH auth/host keys, fz list and --global caveats - notebook 02: ?(name) needs varprefix '?' Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012GxLbauyVHBQPCeSow8hdh --- NEWS.md | 29 +++ README.md | 2 +- commands/run.md | 12 +- doc/INDEX.md | 2 + doc/README.md | 25 +- doc/calculators.md | 218 ++++++++---------- doc/cli-usage.md | 12 +- doc/configuration.md | 3 + doc/core-functions.md | 28 +-- doc/funz-protocol.md | 9 +- doc/installing-models.md | 10 +- doc/interrupt-handling.md | 15 +- doc/limitations.md | 185 +++++++++++++++ doc/model-definition.md | 26 ++- doc/overview.md | 8 +- doc/parallel-and-caching.md | 58 ++--- doc/quick-examples.md | 84 +++---- doc/shell-path.md | 21 +- doc/syntax-guide.md | 28 +-- .../02_variable_syntax_and_formulas.ipynb | 26 +-- examples/dataframe_input.md | 26 +-- examples/generate_notebooks.py | 14 +- skills/fz/SKILL.md | 77 +++++-- skills/fz/algorithm-wrapper.md | 8 +- skills/fz/code-wrapper.md | 18 +- skills/fz/reference.md | 105 ++++++--- 26 files changed, 703 insertions(+), 346 deletions(-) create mode 100644 doc/limitations.md diff --git a/NEWS.md b/NEWS.md index caebc15c..ea38f909 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,35 @@ ## Unreleased +### Documentation: constraints page, corrected examples, skill review + +- New `doc/limitations.md`: constraints and pitfalls checked by running fz (argument + order, parallelism, timeouts, `sh://` argument appending, reserved file names, cache + key, CLI/Python differences, SSH, `fz list`, `--global` installs, security). +- Fixed ~130 `fz.fzr(...)` examples in `doc/` and `examples/` that passed the calculator + as the 4th positional argument (that slot is `results_dir`: a directory named after the + URI was created and every case failed); they now use `calculators=`/`results_dir=`. +- Examples changing `os.environ["FZ_..."]` after `import fz` now call + `fz.reload_config()` / `fz.set_log_level()`; `fz.shell_path` imports replaced by + `fz.shell`; nonexistent `funz://...?timeout=` removed. +- Behaviors now documented as they are: + - a model without `delim` delimits variables with `()` (`${x}` is not a variable) and + formulas with `{}`; the CLI without `--model` uses `{}`; + - `?var` is not converted to `$var` (needs `"varprefix": "?"`); notebook 02 fixed; + - `fzc` writes one sub-directory per case even for scalar values; `fzo` must target + case directories (the skill's verification ladder used `compiled/input.txt`); + - `FZ_RUN_TIMEOUT=0` / `timeout=0` time out immediately (only a model `timeout` of + `null`/`0` disables it); + - first Ctrl+C terminates running cases and `fzr` returns (it does not wait for them); + - `cache://_` resumes into the same `results_dir`; + - `fz list` shows calculators by `uri` and flags installed-wrapper aliases as failed; + - `fz install --global` leaves runner paths relative (runs fail elsewhere); + - no interactive SSH password prompt; `funz://` port is the UDP discovery port; + - `fzd` has no `--format`; `fz list` does not list algorithms; DataFrame designs are + Python-only. +- Agent skill and `/fz:run`: status values, `FZ_MAX_WORKERS` only caps, function-model + `fzd` concurrency, `slurm-array://`, reserved file names, timeouts. + ### Fix: potentially wrong results with `sh://` commands (P0-8) - Path resolution in `sh://` commands converted every word that looked like a diff --git a/README.md b/README.md index 94c909f8..6e20861c 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ Details: [MCP server](doc/mcp-server.md). ## Documentation -- **Documentation in `doc/`** (one file per topic; start at [`doc/INDEX.md`](doc/INDEX.md)): [CLI](doc/cli-usage.md), [Python API](doc/core-functions.md), [models](doc/model-definition.md), [calculators](doc/calculators.md), [parallelism and caching](doc/parallel-and-caching.md), [configuration](doc/configuration.md), [troubleshooting](doc/troubleshooting.md), [examples](doc/quick-examples.md), [custom `fzd` algorithms](doc/custom-algorithms.md), [plugins](doc/installing-models.md), [development](doc/development.md), [breaking changes](doc/breaking-changes.md), [MCP server](doc/mcp-server.md). +- **Documentation in `doc/`** (one file per topic; start at [`doc/INDEX.md`](doc/INDEX.md)): [CLI](doc/cli-usage.md), [Python API](doc/core-functions.md), [models](doc/model-definition.md), [calculators](doc/calculators.md), [parallelism and caching](doc/parallel-and-caching.md), [configuration](doc/configuration.md), [troubleshooting](doc/troubleshooting.md), [examples](doc/quick-examples.md), [custom `fzd` algorithms](doc/custom-algorithms.md), [plugins](doc/installing-models.md), [development](doc/development.md), [breaking changes](doc/breaking-changes.md), [MCP server](doc/mcp-server.md), [constraints and pitfalls](doc/limitations.md). - **Examples**: [`examples/examples.md`](examples/examples.md), [`examples/`](examples/) notebooks and scripts. - **All resources and test examples**: [Resources](doc/resources.md). - **Release notes**: [`NEWS.md`](NEWS.md). diff --git a/commands/run.md b/commands/run.md index 40899dc0..06e511e8 100644 --- a/commands/run.md +++ b/commands/run.md @@ -8,14 +8,18 @@ Request: $ARGUMENTS - If the simulation is not wrapped and verified yet, do that first (`fzi` → `fzc` → one manual run → `fzo` on a single case) before launching the batch. -- Build `input_variables` as a dict of lists (full factorial — Cartesian product) or - as a DataFrame / CSV with one row per case (LHS, constrained or imported designs). +- Build `input_variables` as a dict of lists (full factorial — Cartesian product) or, + from Python only, as a DataFrame with one row per case (LHS, constrained or imported + designs; read a CSV with `pandas.read_csv`). The CLI only takes the dict form. - Choose calculators: `sh://` local, `ssh://user@host/command` remote, `slurm://` for HPC. Repeat a URI or pass a list to run cases in parallel. Put `cache://` first in the list to resume or extend a run — only the missing cases are computed. - Use `--format json` on the CLI (data → stdout, logs → stderr; `fzr` exits 1 when no case succeeds). -- After the run, report the `status` counts (`done`/`error`/`cached`) and show the - results table. For any `error` or `null`-output case, read that case's +- In Python, pass `calculators=` and `results_dir=` as keywords (`results_dir` is the + 4th positional parameter of `fz.fzr`). +- After the run, report the `status` counts (`done`/`failed`/`error`/`timeout`/ + `interrupted`; cache hits are `done` with a `cache://` calculator) and show the + results table. For any non-`done` or `null`-output case, read that case's `err.txt` / `log.txt` before concluding. diff --git a/doc/INDEX.md b/doc/INDEX.md index ea45d697..ca66facb 100644 --- a/doc/INDEX.md +++ b/doc/INDEX.md @@ -18,6 +18,7 @@ Quick reference index for finding specific topics in the FZ context documentatio - [Examples by Use Case](#examples-by-use-case) - [CLI Usage](#cli-usage) - [Troubleshooting](#troubleshooting) +- [Constraints and pitfalls](limitations.md) ## Where the former README went @@ -211,6 +212,7 @@ exists in the code and that all relative links in `doc/` resolve. | Topic | File | Section | |-------|------|---------| +| Constraints and pitfalls (checked against the code) | limitations.md | - | | Debug single case | quick-examples.md | "Troubleshooting Examples" → "Debug Single Case" | | Test calculator manually | quick-examples.md | "Troubleshooting Examples" → "Test Calculator Manually" | | Verify cache matching | quick-examples.md | "Troubleshooting Examples" → "Verify Cache Matching" | diff --git a/doc/README.md b/doc/README.md index 84c6bc98..a92e59cf 100644 --- a/doc/README.md +++ b/doc/README.md @@ -106,6 +106,22 @@ Ready-to-use examples for: **Use when**: Looking for example code for specific use cases +### 9. `limitations.md` - Constraints, Limits and Pitfalls +Verified list of behaviors that surprise users: +- `fzr` argument order (`results_dir` before `calculators`), config read at import +- Parallelism = number of calculator entries; timeout rules (`0` does not disable) +- `sh://` argument appending; reserved file names in case directories +- Cache key contents, CLI/Python differences, SSH host keys, security model + +**Use when**: Before writing a model/calculator, or when a run behaves unexpectedly + +### Other files +- `mcp-server.md` - the `fz-mcp` MCP server for AI agents +- `slurm-architecture.md` - design notes on `slurm://` vs `slurm-array://` +- `funz-protocol.md` - legacy Java Funz TCP/UDP protocol +- `shell-path.md` - `FZ_SHELL_PATH` (bash location, Windows) +- `fzd_content_format.md` - formats of `fzd` analysis content + ## How to Use This Documentation ### For LLM Integration @@ -126,7 +142,7 @@ These files can be used as context for LLMs in several ways: | Configuring models | model-definition.md, syntax-guide.md | | Setting up execution | calculators.md, parallel-and-caching.md | | Performance tuning | parallel-and-caching.md, calculators.md | -| Troubleshooting | quick-examples.md (troubleshooting section) | +| Troubleshooting | limitations.md, quick-examples.md (troubleshooting section) | ### Example Usage in LLM Prompts @@ -167,6 +183,9 @@ doc/ ├── parallel-and-caching.md # Parallel execution and caching ├── fzd_content_format.md # fzd analysis content formats ├── quick-examples.md # Common patterns and examples +├── limitations.md # Constraints, limits and pitfalls +├── mcp-server.md # fz-mcp server for AI agents +├── slurm-architecture.md # slurm:// vs slurm-array:// design notes ├── funz-protocol.md # Legacy Funz server protocol └── shell-path.md # FZ_SHELL_PATH configuration ``` @@ -198,6 +217,6 @@ To improve this documentation: ## Version -These docs are for **fz version 1.0+** +These docs are for **fz version 1.2+** (including unreleased changes listed in `NEWS.md`) -Last updated: 2026-06-13 +Last updated: 2026-09-30 diff --git a/doc/calculators.md b/doc/calculators.md index 8ba356aa..dec872b0 100644 --- a/doc/calculators.md +++ b/doc/calculators.md @@ -2,11 +2,18 @@ ## What is a Calculator? -A calculator is an execution backend that runs your computational code. FZ supports three types: +A calculator is an execution backend that runs your computational code. FZ supports six types: 1. **`sh://`** - Local shell execution -2. **`ssh://`** - Remote SSH execution -3. **`cache://`** - Reuse cached results +2. **`ssh://`** - Remote SSH execution (files transferred by SFTP) +3. **`slurm://`** - SLURM via `srun` (local, or remote through SSH) +4. **`slurm-array://`** - Local SLURM, all cases batched into one `sbatch --array` job +5. **`funz://`** - Legacy Java Funz calculator server (TCP) +6. **`cache://`** - Reuse results of previous runs (no computation) + +Each non-cache calculator entry runs **one case at a time**: the number of parallel cases +equals the number of calculator entries (see [Multiple Calculators](#multiple-calculators)). +See [limitations.md](limitations.md) for the constraints that apply to all calculators. ## Calculator URI Format @@ -85,6 +92,12 @@ to the caller (`sh://bash calc.sh`) therefore works, while per-case files must b by bare name. Earlier versions rewrote every file-looking word, which could make a command read the un-substituted template and write outside the case directory (see `NEWS.md`). +The input file names are appended to the end of the **whole** command line, after any +pipe or redirection: `sh://cat input.txt > res.txt` runs +`cat input.txt > res.txt input.txt`, so `res.txt` holds the input twice. Put anything +beyond a single command in a script (`sh://bash run.sh`), where `$1`, `$2`, ... are the +compiled input files. + ### Example Calculator Script **`calculate.sh`**: @@ -178,38 +191,26 @@ calculators = [ ### Authentication -**Key-based (recommended)**: -```python -# Uses SSH keys from ~/.ssh/ -calculators = "ssh://user@host/bash script.sh" -``` - -**Password-based** (not recommended): -```python -# Password in URI (insecure, avoid in production) -calculators = "ssh://user:password@host/bash script.sh" -``` - -**Interactive**: -```python -# FZ will prompt for password if needed -calculators = "ssh://user@host/bash script.sh" -# Prompt: "Enter password for user@host:" -``` +- **SSH key / agent** (recommended): `ssh://user@host/...`; keys from `~/.ssh/` and the + SSH agent are used. +- **Password in URI**: `ssh://user:password@host/...`; keys and agent are then not + tried. The password is masked in results, logs and manifests (a warning is logged once + per host) but stays in your scripts. +- There is **no interactive password prompt**. Without `user@`, the user name is + `$SSH_USER`, else the local user name. ### Host Key Verification -First-time connection to a new host: -``` -WARNING: Host key verification for host.edu -Fingerprint: SHA256:abc123def456... -Do you want to accept this host key? (yes/no): -``` +Behavior for a host absent from `~/.ssh/known_hosts`: -**Auto-accept** (use with caution): -```bash -export FZ_SSH_AUTO_ACCEPT_HOSTKEYS=1 -``` +| Authentication | Behavior | +|----------------|----------| +| SSH key (no password in URI) | Host key added automatically (paramiko `AutoAddPolicy`, no fingerprint check) | +| Password in URI | Interactive prompt on stdin: `Accept this host key? [y/N/fingerprint]` (blocks unattended runs) | +| `FZ_SSH_AUTO_ACCEPT_HOSTKEYS=1` | Host key added automatically, whatever the authentication | + +When host identity matters, populate `~/.ssh/known_hosts` beforehand +(`ssh-keyscan host >> ~/.ssh/known_hosts`, then check the fingerprint). ### SSH Configuration @@ -219,11 +220,12 @@ export FZ_SSH_KEEPALIVE=300 # Keepalive interval (seconds) export FZ_SSH_AUTO_ACCEPT_HOSTKEYS=1 # Auto-accept host keys ``` -**Python**: +**Python** (environment variables are read at `import fz`; reload after changing them): ```python -import os +import os, fz os.environ['FZ_SSH_KEEPALIVE'] = '300' os.environ['FZ_SSH_AUTO_ACCEPT_HOSTKEYS'] = '0' +fz.reload_config() ``` ### Remote Script Example @@ -355,124 +357,74 @@ export FZ_SSH_KEEPALIVE=300 # For remote SLURM **Python**: ```python -import os +import os, fz os.environ['FZ_RUN_TIMEOUT'] = '7200' # 2 hours +fz.reload_config() # FZ_* variables are read at import ``` ## Funz Server Calculator (`funz://`) -Execute calculations using legacy Java Funz calculator servers via TCP socket protocol. +Execute calculations on legacy Java Funz calculator servers (TCP protocol), located by +UDP discovery. Full protocol description: [funz-protocol.md](funz-protocol.md). ### Basic Syntax ```python -# Local Funz server -calculators = "funz://:port/code" - -# Remote Funz server -calculators = "funz://host:port/code" +calculators = "funz://:19001/R" # listen on UDP port 19001, code "R" +calculators = "funz://server.example.com:19001/R" # TCP connection to that host ``` -**URI Format**: `funz://[host]:/` -- `host`: Server hostname (default: localhost) -- `port`: Server port (required) -- `code`: Calculator code/model name (e.g., "R", "Python", "Modelica", "bash") +**URI Format**: `funz://[host]:/` +- `host`: host to open the TCP connection to (default: `localhost`) +- `udp_port`: **UDP port on which calculators broadcast their availability** (required); + the TCP port is read from the broadcast, not from the URI +- `code`: code name the calculator must offer (e.g. `R`, `Python`, `Modelica`, `bash`) ### Examples -**Example 1: Connect to local Funz server** - -```python -calculators = "funz://:5555/R" -``` - -**Example 2: Connect to remote Funz server** - -```python -calculators = "funz://server.example.com:5555/Python" -``` - -**Example 3: Multiple Funz servers for parallel execution** - -```python -calculators = [ - "funz://:5555/R", - "funz://:5556/R", - "funz://:5557/R" -] -``` - -**Example 4: Complete parametric study** - ```python import fz -model = { - "output": { - "pressure": "grep 'pressure = ' output.txt | awk '{print $3}'" - } -} +model = {"output": {"pressure": "grep 'pressure = ' output.txt | awk '{print $3}'"}} results = fz.fzr( "input.txt", {"temp": [100, 200, 300]}, model, - calculators="funz://:5555/bash" + calculators=["funz://:19001/bash"] * 3, # up to 3 calculators in parallel + results_dir="results", ) ``` ### How it Works -1. **Calculator reservation**: Connects to Funz server and reserves calculator -2. **File upload**: Transfers input files to server -3. **Remote execution**: Executes calculation via Funz protocol -4. **Result download**: Retrieves output files -5. **Unreservation**: Releases calculator and cleans up - -### Funz Protocol - -The Funz calculator uses a text-based TCP socket communication protocol: - -- **RESERVE**: Request calculator reservation with authentication -- **EXECUTE**: Submit calculation job -- **STATUS**: Check job status -- **DOWNLOAD**: Retrieve result files -- **UNRESERVE**: Release calculator - -### UDP Discovery +1. **Discovery**: listen on the UDP port (up to 10 s) for calculator broadcasts; prefer an + idle calculator offering `code`, then any calculator offering it, then the first seen. +2. **Reservation**: connect to the advertised TCP port and reserve the calculator. +3. **Upload / execute / download** through the Funz text protocol. +4. **Unreservation**: release the calculator. -Funz calculators broadcast their availability via UDP: +Discovery can also be called directly: -``` -Port 5555 (UDP): Broadcasts availability every ~5 seconds - Message format: - Line 1: Protocol version (e.g., "FUNZ1.0") - Line 2: TCP port number - Line 3+: Available codes (bash, R, Python, etc.) - -Port (dynamic): Actual calculator communication +```python +from fz import discover_funz_servers +servers = discover_funz_servers(19001, listen_duration=10) +# [{'host': ..., 'tcp_port': 5555, 'name': 'calc1', 'os': ..., 'activity': 'idle', +# 'idle': True, 'codes': ['R', 'Python']}, ...] ``` -See `funz-protocol.md` for detailed protocol documentation. +### UDP broadcast format -### Features - -- **Compatible with legacy Java Funz servers** -- **Automatic file upload/download** -- **TCP socket communication** -- **Calculator reservation system** -- **Interrupt handling support** -- **Authentication support** +Newline-separated, as built by the Java calculator: name, TCP port, start timestamp, +operating system, activity (`idle` when free), number of codes, then one code per line. ### Requirements -- Funz calculator server running (Java-based) -- Network access to server port -- No Python dependencies beyond standard library - -### Starting a Funz Calculator - -See `tools/start_funz_calculator.sh` and `tools/setup_funz_calculator.sh` for helper scripts. +- A running Java Funz calculator (see `tools/setup_funz_calculator.sh` and + `tools/start_funz_calculator.sh`) +- UDP broadcasts from the calculator must reach the machine running fz, and its TCP + port must be reachable +- Default timeout: 3600 s (like `sh://`) ## Cache Calculator (`cache://`) @@ -551,6 +503,20 @@ f6e5d4c3b2a1... config.dat - If match: reuse results (no calculation) - If mismatch: fall through to next calculator +### Reusing the same results directory (`cache://_`) + +An existing `results_dir` is renamed with a timestamp suffix before a run. The special +entry `cache://_` points to that renamed copy, so a study can be resumed or extended in +place: + +```python +fz.fzr("input.txt", variables, model, + calculators=["cache://_", "sh://bash calc.sh"], results_dir="results") +``` + +`cache://results` with `results_dir="results"` finds nothing: it designates the new, +empty directory. + ### Cache Identity (`code_id`) The command is deliberately excluded from the cache key: the same code can be @@ -598,40 +564,40 @@ previous_run/ **Resume interrupted runs**: ```python # First run (interrupted with Ctrl+C) -fz.fzr("input.txt", variables, model, "sh://bash calc.sh", "run1/") +fz.fzr("input.txt", variables, model, calculators="sh://bash calc.sh", results_dir="run1/") # Resume from cache fz.fzr( "input.txt", variables, model, - ["cache://run1", "sh://bash calc.sh"], # Cache + fallback - "run2/" + calculators=["cache://run1", "sh://bash calc.sh"], # Cache + fallback + results_dir="run2/" ) ``` **Expand parameter space**: ```python # Original run: 10 cases -fz.fzr("input.txt", {"temp": range(10)}, model, "sh://bash calc.sh", "run1/") +fz.fzr("input.txt", {"temp": range(10)}, model, calculators="sh://bash calc.sh", results_dir="run1/") # Expanded run: 20 cases (reuses first 10) fz.fzr( "input.txt", {"temp": range(20)}, # 10 new cases model, - ["cache://run1", "sh://bash calc.sh"], - "run2/" + calculators=["cache://run1", "sh://bash calc.sh"], + results_dir="run2/" ) ``` **Compare methods using same inputs**: ```python # Method 1 -fz.fzr("input.txt", variables, model, "sh://method1.sh", "results_m1/") +fz.fzr("input.txt", variables, model, calculators="sh://method1.sh", results_dir="results_m1/") # Method 2 (reuses inputs, different calculator) -fz.fzr("input.txt", variables, model, "sh://method2.sh", "results_m2/") +fz.fzr("input.txt", variables, model, calculators="sh://method2.sh", results_dir="results_m2/") ``` ## Multiple Calculators @@ -777,7 +743,7 @@ if is_heavy_calculation(variables): else: calculators = "sh://bash light.sh" -results = fz.fzr("input.txt", variables, model, calculators) +results = fz.fzr("input.txt", variables, model, calculators=calculators) ``` ### Pattern 4: Development vs Production @@ -843,7 +809,7 @@ calculators = [ ### 5. Monitor Calculator Usage ```python -results = fz.fzr("input.txt", variables, model, calculators, "results/") +results = fz.fzr("input.txt", variables, model, calculators=calculators, results_dir="results/") # Check which calculator was used print(results[['calculator', 'status', 'error']].value_counts()) diff --git a/doc/cli-usage.md b/doc/cli-usage.md index b90fe1fe..c413eb0e 100644 --- a/doc/cli-usage.md +++ b/doc/cli-usage.md @@ -72,7 +72,8 @@ fzi input.txt \ Substitute variables and create compiled input files: ```bash -# Basic usage +# Basic usage (writes compiled/T_celsius=25,V_L=10,n_mol=1/input.txt: +# one sub-directory per case, even for scalar values) fzc input.txt \ --model perfectgas \ --variables '{"T_celsius": 25, "V_L": 10, "n_mol": 1}' \ @@ -88,11 +89,11 @@ fzc input.txt \ **Directory structure created:** ``` compiled_grid/ -├── T_celsius=10,V_L=1/ +├── T_celsius=10,V_L=1,n_mol=1/ │ └── input.txt -├── T_celsius=10,V_L=2/ +├── T_celsius=10,V_L=2,n_mol=1/ │ └── input.txt -├── T_celsius=20,V_L=1/ +├── T_celsius=20,V_L=1,n_mol=1/ │ └── input.txt ... ``` @@ -380,7 +381,8 @@ fz uninstall algorithm myalgo --version Show version --model MODEL Model alias or inline definition --varprefix PREFIX Variable prefix (default: $) ---delim DELIMITERS Formula delimiters (default: {}) +--delim DELIMITERS Variable and formula delimiters ({} when --model is absent; + a --model without "delim" keeps () for variables) --formulaprefix PREFIX Formula prefix (default: @) --commentline CHAR Comment character (default: #) --format FORMAT Output format: json, table, csv, markdown, html diff --git a/doc/configuration.md b/doc/configuration.md index 574ebdab..f2582feb 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -118,6 +118,9 @@ model (the calculation may run indefinitely): model = {"timeout": None, "output": {"result": "cat output.txt"}} ``` +`FZ_RUN_TIMEOUT=0` and `timeout=0` do **not** disable the timeout: every case then times +out immediately. Only the model entry disables it. + ### 3. `fzr()`/`fzc()` `timeout=` Argument (Per-Call) ```python diff --git a/doc/core-functions.md b/doc/core-functions.md index e9489da2..99c5bb1e 100644 --- a/doc/core-functions.md +++ b/doc/core-functions.md @@ -198,7 +198,9 @@ input_variables = { } fz.fzc("input.txt", input_variables, model, "compiled/") -# Creates: compiled/input.txt with values substituted +# Creates: compiled/temp=25,pressure=101.3,volume=1.0/input.txt +# (fzc always writes one sub-directory per case when the input declares variables, +# even for scalar values; an existing compiled/ is renamed with a timestamp first) ``` **Example 2: Multiple compilations (Cartesian product)** @@ -213,12 +215,12 @@ input_variables = { fz.fzc("input.txt", input_variables, model, "compiled_grid/") # Creates 6 subdirectories: -# compiled_grid/temp=10,pressure=1/input.txt -# compiled_grid/temp=10,pressure=10/input.txt -# compiled_grid/temp=20,pressure=1/input.txt -# compiled_grid/temp=20,pressure=10/input.txt -# compiled_grid/temp=30,pressure=1/input.txt -# compiled_grid/temp=30,pressure=10/input.txt +# compiled_grid/temp=10,pressure=1,volume=1.0/input.txt +# compiled_grid/temp=10,pressure=10,volume=1.0/input.txt +# compiled_grid/temp=20,pressure=1,volume=1.0/input.txt +# compiled_grid/temp=20,pressure=10,volume=1.0/input.txt +# compiled_grid/temp=30,pressure=1,volume=1.0/input.txt +# compiled_grid/temp=30,pressure=10,volume=1.0/input.txt ``` **Example 3: With formula evaluation** @@ -233,7 +235,7 @@ input_variables = {"T_celsius": 25} fz.fzc("input.txt", input_variables, model, "compiled/") -# compiled/input.txt: +# compiled/T_celsius=25/input.txt: # Temperature: 25 C # Temperature (K): 298.15 ``` @@ -368,7 +370,7 @@ results_df = fz.fzr( input_path, input_variables, model, - calculators, + calculators=calculators, results_dir="results" ) ``` @@ -866,7 +868,7 @@ input_variables = { # Creates 6 cases: 3 × 2 = 6 # (100,1.0), (100,2.0), (200,1.0), (200,2.0), (300,1.0), (300,2.0) -results = fz.fzr(input_file, input_variables, model, calculators) +results = fz.fzr(input_file, input_variables, model, calculators=calculators) ``` **Use factorial design when:** @@ -890,7 +892,7 @@ input_variables = pd.DataFrame({ # (100,1.0), (200,1.0), (100,2.0), (300,1.5) # Note: (100,2.0) is included but (200,2.0) is not -results = fz.fzr(input_file, input_variables, model, calculators) +results = fz.fzr(input_file, input_variables, model, calculators=calculators) ``` **Use non-factorial design when:** @@ -947,7 +949,7 @@ results = fz.fzr( "input.txt", {var: [1, 2, 3] for var in vars}, # Use discovered variables model, - "sh://bash calc.sh" + calculators="sh://bash calc.sh" ) ``` @@ -975,5 +977,5 @@ test_results = fz.fzo("test_results/", model) print(test_results) # 3. Run full study -results = fz.fzr("input.txt", variables, model, calculators, "results/") +results = fz.fzr("input.txt", variables, model, calculators=calculators, results_dir="results/") ``` diff --git a/doc/funz-protocol.md b/doc/funz-protocol.md index 54883cc8..790e8c2f 100644 --- a/doc/funz-protocol.md +++ b/doc/funz-protocol.md @@ -426,12 +426,15 @@ model = { "output": {"result": "cat output.txt"} } -# Calculator-level timeout -calculators = "funz://:19001/R?timeout=7200" # 2 hours +# Per-call timeout (overrides the model entry and FZ_RUN_TIMEOUT) +results = fz.fzr("input.R", variables, model, + calculators="funz://:19001/R", timeout=7200) # 2 hours -# Environment variable (default) +# Environment variable (default 3600 s); read at import, so either set it +# before starting Python or reload the configuration: import os os.environ['FZ_RUN_TIMEOUT'] = '3600' +fz.reload_config() ``` ### Multiple Servers diff --git a/doc/installing-models.md b/doc/installing-models.md index 27909b4f..39d77715 100644 --- a/doc/installing-models.md +++ b/doc/installing-models.md @@ -19,7 +19,7 @@ fz install model perfectgas --global # Install an fzd algorithm (into ./.fz/algorithms/ or, with --global, ~/.fz/algorithms/) fz install algorithm brent -fz install algorithm https://github.com/Funz/fz-montecarlo +fz install algorithm https://github.com/Funz/fz-PSO # Remove an installed resource (by name) fz uninstall model perfectgas @@ -27,6 +27,7 @@ fz uninstall algorithm brent fz uninstall model perfectgas --global # See what is installed (models and calculators), optionally validating each +# (fz list does not show algorithms: ls .fz/algorithms, or fz.list_installed_algorithms()) fz list fz list --check ``` @@ -81,6 +82,13 @@ alongside it. ## Install location and discovery +> **`--global` and runner scripts.** `fz install model --global` copies the wrapper to `~/.fz/`, but its calculator +> alias keeps the relative command `bash .fz/calculators/.sh`, looked up in the +> launch directory, then the case directory, never in `~/.fz/`: runs from any other +> directory fail (`Command not found locally: '.fz/calculators/.sh'`). Prefer project-local installs, or edit +> `~/.fz/calculators/localhost_.json` to use the absolute path of the script (`~` is +> not expanded). + - **Project-local** (default): `./.fz/` — visible only inside the current project. - **Global** (`--global`): `~/.fz/` — visible from every project for the current user. diff --git a/doc/interrupt-handling.md b/doc/interrupt-handling.md index c706c62b..6a554d79 100644 --- a/doc/interrupt-handling.md +++ b/doc/interrupt-handling.md @@ -17,19 +17,24 @@ python run_study.py ## What Happens 1. **First Ctrl+C**: - - Currently running calculations complete - No new calculations start - - Partial results are saved - - Resources are cleaned up + - Running local processes are terminated (killed after 5 s); remote and SLURM jobs + are cancelled + - Interrupted cases get `status="interrupted"` + - `fzr` **returns** the DataFrame of partial results (no exception); the manifest + records `interrupted: true` - Signal handlers restored 2. **Second Ctrl+C** (not recommended): - - Immediate termination + - `KeyboardInterrupt` is raised immediately - May leave resources in inconsistent state ## Resuming After Interrupt -Use caching to resume from where you left off: +Use caching to resume from where you left off. To resume **into the same** +`results_dir`, use the special entry `cache://_` (the previous content of `results_dir`, +renamed with a timestamp before the run); `cache://results` with `results_dir="results"` +points to the new, empty directory and never hits. ```python # First run (interrupted after 50/100 cases) diff --git a/doc/limitations.md b/doc/limitations.md new file mode 100644 index 00000000..a81b3e8c --- /dev/null +++ b/doc/limitations.md @@ -0,0 +1,185 @@ +# Constraints, Limits and Pitfalls + +This page lists the behaviors of fz that most often surprise users. Each item states the +rule, the consequence, and what to do instead. Items are checked against the code +(`fz/core.py`, `fz/helpers.py`, `fz/runners/`, `fz/config.py`, `fz/cli.py`). + +## Platform and dependencies + +- **Python ≥ 3.9.** 3.9–3.13 are tested in CI; 3.14 is exercised as a pre-release only. +- **bash is required everywhere.** Calculator commands and shell output parsers run + through bash. On Windows, install MSYS2 or Git Bash and point `FZ_SHELL_PATH` at its + `bin` directories (see [shell-path.md](shell-path.md)). Shell-free output parsers + (`python://`) remove the need for grep/awk, but not for bash in `sh://` calculators. +- **Required Python dependencies:** `paramiko`, `pandas`, `charset-normalizer`. + Optional: `rpy2` (R formulas, extra `[r]`), `mcp` (the `fz-mcp` server, extra `[mcp]`, + **Python ≥ 3.10 only**), `h5py` (the `hdf5_file()` output helper), `jq`, `yq` + (mikefarah), `xmllint` (for `jq://`, `yq://`, `xpath://` outputs). +- **R formulas:** `import rpy2` can succeed while `import rpy2.robjects` fails + (`ffi.error`, R/rpy2 version mismatch). fz then reports R as unavailable. + +## Templates and models + +- **Default delimiters differ for variables and formulas.** A model without `delim` + (nor `var_delim`) delimits variables with `()` and formulas with `{}`: `${x}` is then + not a variable. The CLI without `--model` uses `delim: "{}"`. Set `delim` explicitly. +- **No automatic `?var` conversion.** `?var` is a variable only with `"varprefix": "?"`. +- A variable absent from `input_variables` and without `~default` is left as-is in the + compiled file (no error from `fzc`). +- `fzi` returns variables *and* formula expressions (e.g. `'T_celsius + 273.15'`) as keys. + +## Python API + +- **`fzr` argument order is `(input_path, input_variables, model, results_dir, + calculators, ...)`.** `results_dir` comes *before* `calculators`. A call such as + `fz.fzr("input.txt", variables, model, "sh://bash calc.sh")` puts the calculator URI into + `results_dir` (a directory literally named `sh:/bash calc.sh` is created) and runs with + no calculator, so every case fails. **Always pass `calculators=` and `results_dir=` by + keyword.** +- **A DataFrame design must not contain duplicate rows** (`ValueError`): each row is one + case. Variables given but absent from the templates only trigger a warning. +- **`FZ_*` environment variables are read once, at `import fz`.** Setting + `os.environ["FZ_MAX_WORKERS"] = "8"` after the import has no effect until + `fz.reload_config()` is called. Alternatives: set the variable before starting Python, + call `fz.set_log_level("DEBUG")`, or change `fz.get_config().max_workers` directly. +- **`callbacks` is a dict**, not a list: keys `on_start`, `on_case_start`, + `on_case_complete`, `on_progress`, `on_complete`. Any other key raises `ValueError`. + Callbacks run in worker threads; an exception inside a callback is logged and ignored. +- **`fzd` with a Python function as model** (`input_path=None`): `calculators` must be a + positive int (number of concurrent evaluations, default 1). With `calculators > 1` the + function must be thread-safe; any error in parallel mode aborts the whole `fzd` with + `FunctionModelParallelError`. Callables bridged from R (reticulate) must use + `calculators=1`. + +## CLI + +- **CLI and Python differ for `fzd`:** + - `--output_expression` takes a single expression; a *list* of objectives + (multi-objective `fzd`) is only available from Python. + - The CLI default results directory is `results_fzd`; the Python default + `analysis_dir` is `analysis`. + - `fzd` has no `--format` option; function models are Python-only. +- **`fzl` / `fz list` formats** are `json`, `markdown` and `table` only (no `csv`/`html`). +- **Non-factorial designs are Python-only.** `--input_variables` takes a JSON dict + (inline or file) or the short form `'a=1,b=[4,5,6]'`, always crossed as a full + factorial; a list of cases requires a pandas DataFrame from Python. +- **No `--timeout` flag.** Use the model's `"timeout"` entry or `FZ_RUN_TIMEOUT`. +- **`fz list` / `fzl`** shows calculator aliases by their `uri`, not their file name, and + `--check` reports an alias whose command sits in its `models` map + (`{"uri": "sh://", "models": {...}}`, the layout of installed wrappers) as failed + (`Empty sh:// command`) although `fzr` uses it correctly. Algorithms are not listed. +- **`fzr` exits with status 1 when no case succeeds**; data goes to stdout, logs and + progress to stderr. Use `--format json` for machine-readable output. + +## Parallelism and retries + +- **Parallel workers = number of non-cache calculator entries** (capped by the number of + cases). One entry runs cases sequentially; `["sh://bash calc.sh"] * 4` runs four at a + time. `FZ_MAX_WORKERS` only **caps** that number; it never adds workers. Exception: + `slurm-array://` uses one waiting thread per case (capped by `FZ_MAX_WORKERS`) so that + cases can be batched into one job array. +- **Case → calculator assignment** prefers `case_index mod n_calculators` and falls back + to the first free calculator. +- **`FZ_MAX_RETRIES` (default 5)** is the number of calculator failures tolerated per + case; after that the case is marked `failed`. With several calculators, a failed + attempt moves to another calculator. +- **Case `status` values:** `done`, `failed`, `error`, `timeout`, `interrupted`. A cache + hit is `done` with a `calculator` value starting with `cache://`. + +## Timeouts + +- **Resolution order:** `timeout=` argument of `fzr()` > model `"timeout"` entry > + `FZ_RUN_TIMEOUT`. +- **Default:** 3600 s for `sh://` and `funz://`; **no timeout** for `ssh://` and + `slurm://` unless `FZ_RUN_TIMEOUT` is set explicitly (a warning is logged). +- **Only a model `"timeout"` of `null`/`None` or `0` disables the timeout.** + `FZ_RUN_TIMEOUT=0` or `timeout=0` does *not* disable it: every case times out + immediately. + +## `sh://` command line + +- **The command runs inside a per-case temporary directory**, and the case's input file + names are **appended to the end of the whole command line** (`.` when there are none). + With pipes or redirections, they are appended after the last element. +- **File names in the command**: a bare word (`run.sh`, `data.txt`) is made absolute in + the launch directory only if it exists there **and not** in the case directory; + redirection targets stay in the case directory. Consequence of the appended arguments: + `sh://cat input.txt > res.txt` runs `cat input.txt > res.txt input.txt` and `res.txt` + holds the input twice. **Put the work in a script** (`sh://bash run.sh`): inside it, + relative paths refer to the case directory and `$1`, `$2`, ... are the compiled input + files. +- `ssh://` and `slurm://` commands run on the remote side: use absolute remote paths. + +## Files and directories + +- **Reserved names in each case directory:** `out.txt` (stdout), `err.txt` (stderr), + `log.txt`, `info.txt`, `history.txt` and `.fz_hash` are written by fz. A simulation + output with one of these names is **overwritten** (e.g. a code writing `out.txt` loses + it to the captured stdout). Name code outputs differently. +- **Reserved names at the results root:** `manifest.json`, `ro-crate-metadata.json` + (disable with `FZ_RO_CRATE=0`) and, with `case_naming="hash"`/`"index"`, `cases.csv`. +- **Case directory names (`case_naming="path"`, default)** are `var1=val1,var2=val2,...`. + The characters `/ \ : * ? " < > | %` and control characters are percent-encoded, and a + value of exactly `.` or `..` is encoded. Names can exceed the ~255-character filename + limit with many variables or long values: use `case_naming="hash"` or `"index"` + (always used internally by `fzd`). +- **`fzc` always writes one sub-directory per case** (`output_dir/var1=val1,.../`) when + the input declares variables, even when all values are scalars. Run the code and + `fzo` inside that sub-directory (or on the glob `output_dir/*`); `fzo output_dir` + itself returns a row of `None`. +- **Global wrapper installs:** `fz install model --global` copies the wrapper to `~/.fz/`, but its calculator + alias keeps the relative command `bash .fz/calculators/.sh`, looked up in the + launch directory, then the case directory, never in `~/.fz/`: runs from any other + directory fail (`Command not found locally: '.fz/calculators/.sh'`). Prefer project-local installs, or edit + `~/.fz/calculators/localhost_.json` to use the absolute path of the script (`~` is + not expanded). +- **Existing results directories are not overwritten in place**: an existing + `results_dir` is renamed with a timestamp suffix before the new run (and can be reused + via `cache://`). +- **`input_static` absolute paths** must exist at the same path on the calculator side + (shared storage); fz never copies them. Relative paths are symlinked (copied where + symlinks are unavailable) and transferred to remote calculators. + +## Cache (`cache://`) + +- **The cache key is the SHA-256 of the case's input files** (plus `input_static`), and, + when declared, the calculator's `code_id`. It **does not include the calculator + command or the output parsers**: changing the simulation script without changing the + inputs still hits the cache. Declare a `code_id` (or `version_cmd`) in calculator + aliases, or use a fresh `results_dir` without `cache://`, to force recomputation. +- A match between calculators with no declared `code_id` is accepted with a warning; + `FZ_CACHE_STRICT=1` refuses it. +- **Resuming into the same `results_dir`** requires the special entry `cache://_` (the + renamed previous content). `cache://` points to the new, empty directory + and never hits. +- Caches written before the v2 hash format (MD5) are ignored unless + `FZ_CACHE_ACCEPT_LEGACY=1`. +- A cached case is reused only if its outputs, parsed with the *current* model, are all + non-`None`. + +## Remote calculators + +- **`slurm-array://` is local only** (the machine running fz must have `sbatch`/`sacct`). + `slurm://` supports both local and SSH-remote SLURM. +- **SSH host keys:** with key authentication, an unknown host key is added + automatically (paramiko `AutoAddPolicy`, no fingerprint check). With a password in the + URI, fz asks interactively on stdin, which blocks unattended runs, unless + `FZ_SSH_AUTO_ACCEPT_HOSTKEYS=1`. Pre-populate `~/.ssh/known_hosts` when host identity + matters. A password embedded in the URI is masked in results and logs but stays in + memory and in your scripts: prefer keys. +- **No interactive SSH password prompt.** Use keys, or a password in the URI (keys are + then not tried). Without `user@`, the user is `$SSH_USER`, else the local user. +- **Remote cleanup** (`rm -rf`) is refused outside fz's own `.fz/tmp/fz_calc_*` / + `fz_slurm_*` directories. +- **`funz://`** needs a running legacy Java Funz calculator (TCP), optionally discovered + by UDP broadcast (`fz.discover_funz_servers`). + +## Security + +Templates, `#@` formula-context lines, `@{...}` formulas, output commands and calculator +commands are **executed as code with the user's privileges**. There is no sandbox. Do not +run a model, algorithm or calculator alias obtained from an untrusted source without +reading it. `fz-mcp` confines file paths to `FZ_MCP_ROOT` and can restrict models and +calculators to installed aliases (`FZ_MCP_TRUSTED=0`), but its tool annotations are +advisory, not a security boundary. See the "Threat Model" section of the README and +[mcp-server.md](mcp-server.md). diff --git a/doc/model-definition.md b/doc/model-definition.md index a9f27157..6dd2268e 100644 --- a/doc/model-definition.md +++ b/doc/model-definition.md @@ -33,6 +33,27 @@ model = { } ``` +## Defaults and key aliases + +Every syntax field is optional. The defaults, as applied by the code +(`fz/interpreter.py`, `fz/core.py`), are: + +| Field (aliases, in precedence order) | Default | +|--------------------------------------|---------| +| `var_prefix`, `varprefix`, `var_char`, `varchar` | `$` | +| `formula_prefix`, `formulaprefix`, `form_prefix`, `formprefix`, `formula_char`, `form_char` | `@` | +| `var_delim`, then `delim` (delimiters of **variables**) | **`()`** | +| `formula_delim`, then `delim` (delimiters of **formulas**) | `{}` | +| `commentline`, `comment_line`, `comment_char`, `commentchar`, `comment` | `#` | +| `interpreter` | `FZ_INTERPRETER` (default `python`) | + +> **Set `delim` explicitly.** Without a `delim` key, `${x}` is **not** a variable (only +> `$x` and `$(x)` are), while formulas use `@{...}`. The CLI used *without* `--model` +> applies `delim: "{}"` instead, so the same template can behave differently from Python +> and from the CLI. `"delim": "{}"` sets both delimiters to braces; `var_delim` / +> `formula_delim` set them separately (the Java-Funz convention is +> `var_delim: "()"`, `formula_delim: "{}"`). + ## Model Fields ### varprefix (required for fzi, fzc, fzr) @@ -46,8 +67,9 @@ Prefix that marks variables in input files. **Example**: ```python -model = {"varprefix": "$"} +model = {"varprefix": "$", "delim": "{}"} # Matches: $temp, $pressure, ${volume} +# (with no "delim" key, variables use "()": $(volume) matches, ${volume} does not) ``` ### delim (optional) @@ -592,7 +614,7 @@ model = { } } -results = fz.fzr("input.txt", variables, model, calculators) +results = fz.fzr("input.txt", variables, model, calculators=calculators) ``` ## Best Practices diff --git a/doc/overview.md b/doc/overview.md index 7961142b..f28ef658 100644 --- a/doc/overview.md +++ b/doc/overview.md @@ -24,7 +24,7 @@ FZ is a parametric scientific computing framework that automates running computa - **Formula variable prefix fix**: configurable `varprefix` now correctly applied inside `@{...}` formulas - **Variable defaults**: `${var~default}` syntax for default values - **Progress callbacks**: Real-time monitoring of calculation progress -- **Old Funz syntax**: Backward compatibility with `?var` syntax +- **Java Funz syntax**: `$(var)` variables and `@{expr}` formulas (the default when a model sets no `delim`) See `NEWS.md` for complete release notes. @@ -240,15 +240,15 @@ results = fz.fzr( ### Pattern 3: Cache and Resume ```python # First run (may be interrupted) -fz.fzr("input.txt", vars, model, "sh://bash calc.sh", "run1/") +fz.fzr("input.txt", vars, model, calculators="sh://bash calc.sh", results_dir="run1/") # Resume from cache fz.fzr( "input.txt", vars, model, - ["cache://run1", "sh://bash calc.sh"], # Try cache first - "run2/" + calculators=["cache://run1", "sh://bash calc.sh"], # Try cache first + results_dir="run2/" ) ``` diff --git a/doc/parallel-and-caching.md b/doc/parallel-and-caching.md index b94f3918..bebdd870 100644 --- a/doc/parallel-and-caching.md +++ b/doc/parallel-and-caching.md @@ -4,11 +4,11 @@ ### How Parallelization Works -FZ automatically parallelizes calculations when you provide multiple calculators or use environment variables to control worker threads. +FZ parallelizes calculations when you provide several calculator entries: the number of workers is the number of non-cache calculator entries (capped by the number of cases and, optionally, by `FZ_MAX_WORKERS`). `slurm-array://` is the exception: one waiting thread per case, so that cases batch into one job array. **Key principles**: - Each calculator can run one case at a time (thread-safe locking) -- Cases are distributed round-robin across calculators +- Case `i` prefers calculator `i mod n`; if it is busy, the first free calculator is used - Progress tracking with ETA updates - Graceful interrupt handling (Ctrl+C) @@ -48,12 +48,13 @@ results = fz.fzr( N = 4 calculators = ["sh://bash calc.sh"] * N -results = fz.fzr("input.txt", variables, model, calculators) +results = fz.fzr("input.txt", variables, model, calculators=calculators) ``` ### Load Balancing -Cases are distributed round-robin: +Case `i` first tries calculator `i mod n`; when that one is busy, the first free +calculator takes it. With equal case durations this gives a round-robin distribution: ```python # 10 cases, 3 calculators @@ -75,8 +76,9 @@ calculators = ["sh://bash calc.sh"] * 8 **Method 2: Environment variable** ```python -import os +import os, fz os.environ['FZ_MAX_WORKERS'] = '8' +fz.reload_config() # FZ_* variables are read at import time; FZ_MAX_WORKERS only caps # Or from shell: # export FZ_MAX_WORKERS=8 @@ -236,15 +238,15 @@ results2 = fz.fzr( ```python # Method 1: Fast but approximate -fz.fzr("input.txt", variables, model, "sh://fast.sh", "results_fast/") +fz.fzr("input.txt", variables, model, calculators="sh://fast.sh", results_dir="results_fast/") # Method 2: Slow but accurate (reuses same inputs) fz.fzr( "input.txt", variables, model, - "sh://accurate.sh", # Different calculator, same inputs - "results_accurate/" + calculators="sh://accurate.sh", # Different calculator, same inputs + results_dir="results_accurate/" ) # Compare results @@ -264,25 +266,25 @@ calculators = [ "sh://bash calc.sh" # Last resort: compute ] -results = fz.fzr("input.txt", variables, model, calculators) +results = fz.fzr("input.txt", variables, model, calculators=calculators) ``` ### Strategy 5: Selective Recalculation ```python # Run full study -fz.fzr("input.txt", variables, model, "sh://bash calc.sh", "run1/") +fz.fzr("input.txt", variables, model, calculators="sh://bash calc.sh", results_dir="run1/") # Modify only the calculation script (not inputs) # edit calc.sh... # Re-run with different script but same inputs won't use cache # because cache matches input files, not calculator -fz.fzr("input.txt", variables, model, "sh://bash calc_v2.sh", "run2/") +fz.fzr("input.txt", variables, model, calculators="sh://bash calc_v2.sh", results_dir="run2/") # To force re-calculation even with same inputs: # Don't use cache calculator -fz.fzr("input.txt", variables, model, "sh://bash calc.sh", "run3/") +fz.fzr("input.txt", variables, model, calculators="sh://bash calc.sh", results_dir="run3/") ``` ## Combining Parallel and Cache @@ -300,7 +302,7 @@ calculators = [ # First tries cache # If cache miss, distributes across 4 parallel workers -results = fz.fzr("input.txt", variables, model, calculators) +results = fz.fzr("input.txt", variables, model, calculators=calculators) ``` ### Pattern 2: Mixed Remote and Local with Cache @@ -314,7 +316,7 @@ calculators = [ "ssh://user@robust-cluster/bash robust.sh" # Robust remote ] -results = fz.fzr("input.txt", variables, model, calculators) +results = fz.fzr("input.txt", variables, model, calculators=calculators) ``` ### Pattern 3: Staged Execution @@ -365,15 +367,16 @@ set `FZ_RO_CRATE=0` to disable it. A failure to write either file only logs a wa FZ automatically retries failed calculations: ```python -import os +import os, fz os.environ['FZ_MAX_RETRIES'] = '3' +fz.reload_config() # FZ_* variables are read at import time calculators = [ "sh://bash may_fail.sh", "sh://bash backup.sh" ] -results = fz.fzr("input.txt", variables, model, calculators) +results = fz.fzr("input.txt", variables, model, calculators=calculators) ``` **Retry behavior**: @@ -393,8 +396,9 @@ calculators = [ ] os.environ['FZ_MAX_RETRIES'] = '5' +fz.reload_config() -results = fz.fzr("input.txt", variables, model, calculators) +results = fz.fzr("input.txt", variables, model, calculators=calculators) # Check retry statistics print(results[['status', 'calculator', 'error']].value_counts()) @@ -410,13 +414,13 @@ results are kept) and `cache://` resumes it later: see [Interrupt handling](inte ### 1. Profile to Find Bottlenecks ```python -import os +import fz import time -os.environ['FZ_LOG_LEVEL'] = 'DEBUG' +fz.set_log_level('DEBUG') # or FZ_LOG_LEVEL=DEBUG before starting Python start = time.time() -results = fz.fzr("input.txt", variables, model, calculators) +results = fz.fzr("input.txt", variables, model, calculators=calculators) elapsed = time.time() - start print(f"Total time: {elapsed:.2f}s") @@ -434,8 +438,8 @@ def benchmark_workers(n_workers): "input.txt", {"param": list(range(100))}, model, - ["sh://bash calc.sh"] * n_workers, - f"benchmark_{n_workers}_workers" + calculators=["sh://bash calc.sh"] * n_workers, + results_dir=f"benchmark_{n_workers}_workers" ) return time.time() - start @@ -475,8 +479,8 @@ results_light = fz.fzr( "input.txt", light_cases, model, - ["sh://bash calc.sh"] * 8, # Many local workers - "results_light" + calculators=["sh://bash calc.sh"] * 8, # Many local workers + results_dir="results_light" ) # Run heavy cases on HPC @@ -484,8 +488,8 @@ results_heavy = fz.fzr( "input.txt", heavy_cases, model, - "ssh://user@hpc/sbatch heavy.sh", - "results_heavy" + calculators="ssh://user@hpc/sbatch heavy.sh", + results_dir="results_heavy" ) # Combine results @@ -554,7 +558,7 @@ systems, UI updates for long-running studies, profiling. Give only the keys you ```python # In one terminal: run calculations -results = fz.fzr("input.txt", variables, model, calculators, "results/") +results = fz.fzr("input.txt", variables, model, calculators=calculators, results_dir="results/") # In another terminal: monitor progress import os diff --git a/doc/quick-examples.md b/doc/quick-examples.md index 6db0a299..92bc3e0a 100644 --- a/doc/quick-examples.md +++ b/doc/quick-examples.md @@ -30,8 +30,8 @@ results = fz.fzr( "input.txt", {"x": [1, 2, 3], "y": [10, 20]}, # 6 cases model, - "sh://bash calc.sh", - "results" + calculators="sh://bash calc.sh", + results_dir="results" ) print(results) @@ -72,8 +72,8 @@ results = fz.fzr( "input.txt", {"T_celsius": [0, 25, 100]}, model, - "sh://bash calc.sh", - "results" + calculators="sh://bash calc.sh", + results_dir="results" ) ``` @@ -92,8 +92,8 @@ results = fz.fzr( "input.txt", {"param": list(range(100))}, model, - ["sh://bash calc.sh"] * 4, # 4 parallel calculators - "results" + calculators=["sh://bash calc.sh"] * 4, # 4 parallel calculators + results_dir="results" ) print(f"Completed {len(results)} calculations") @@ -116,8 +116,8 @@ results = fz.fzr( "input.txt", {"mesh_size": [100, 200, 400, 800]}, model, - "ssh://user@cluster.edu/bash /path/to/submit.sh", - "hpc_results" + calculators="ssh://user@cluster.edu/bash /path/to/submit.sh", + results_dir="hpc_results" ) ``` @@ -131,8 +131,8 @@ results1 = fz.fzr( "input.txt", {"param": list(range(50))}, model, - "sh://bash slow_calc.sh", - "run1" + calculators="sh://bash slow_calc.sh", + results_dir="run1" ) # Resume with cache @@ -140,8 +140,8 @@ results2 = fz.fzr( "input.txt", {"param": list(range(50))}, model, - ["cache://run1", "sh://bash slow_calc.sh"], # Cache first - "run2" + calculators=["cache://run1", "sh://bash slow_calc.sh"], # Cache first + results_dir="run2" ) ``` @@ -217,8 +217,8 @@ results = fz.fzr( "input.txt", {"temperature": list(range(0, 101, 10))}, # [0, 10, 20, ..., 100] model, - "sh://bash thermal_analysis.sh", - "temp_sweep" + calculators="sh://bash thermal_analysis.sh", + results_dir="temp_sweep" ) # Plot results @@ -247,8 +247,8 @@ results = fz.fzr( "pressure": pressures.tolist() }, # 10×10 = 100 cases model, - ["sh://bash calc.sh"] * 4, # 4 parallel workers - "grid_search" + calculators=["sh://bash calc.sh"] * 4, # 4 parallel workers + results_dir="grid_search" ) # Create heatmap @@ -288,8 +288,8 @@ results = fz.fzr( "input.txt", variations, model, - "sh://bash calc.sh", - "sensitivity" + calculators="sh://bash calc.sh", + results_dir="sensitivity" ) # Analyze sensitivity @@ -322,8 +322,8 @@ results = fz.fzr( "input.txt", samples, model, - ["sh://bash calc.sh"] * 8, # 8 parallel workers - "monte_carlo" + calculators=["sh://bash calc.sh"] * 8, # 8 parallel workers + results_dir="monte_carlo" ) # Statistical analysis @@ -358,8 +358,8 @@ results = fz.fzr( "input.txt", cases, model, - ["sh://bash experiment.sh"] * 4, - "doe_results" + calculators=["sh://bash experiment.sh"] * 4, + results_dir="doe_results" ) # ANOVA or regression analysis @@ -378,8 +378,8 @@ results = fz.fzr( "input.txt", {"mesh": mesh_sizes}, model, - "sh://bash simulation.sh", - "convergence" + calculators="sh://bash simulation.sh", + results_dir="convergence" ) # Check convergence @@ -414,8 +414,8 @@ for method_name, calculator in methods.items(): "input.txt", variables, model, - calculator, - f"results_{method_name}" + calculators=calculator, + results_dir=f"results_{method_name}" ) results['method'] = method_name all_results.append(results) @@ -451,8 +451,8 @@ def objective(params): "input.txt", {"x": params[0], "y": params[1]}, model, - "sh://bash calc.sh", - "optimization" + calculators="sh://bash calc.sh", + results_dir="optimization" ) return results.iloc[0]['result'] @@ -536,8 +536,8 @@ fzc input.txt \ --variables '{"temp": 25, "pressure": 101}' \ --output compiled/ -# Check compiled result -cat compiled/input.txt +# Check compiled result (one sub-directory per case, named after the values) +cat compiled/*/input.txt ``` ### Example 3: Run Parametric Study from CLI @@ -584,15 +584,15 @@ import fz import os # Enable debug logging -os.environ['FZ_LOG_LEVEL'] = 'DEBUG' +fz.set_log_level('DEBUG') # or FZ_LOG_LEVEL=DEBUG before starting Python # Run single case results = fz.fzr( "input.txt", {"param": 1}, # Single case model, - "sh://bash calc.sh", - "debug_test" + calculators="sh://bash calc.sh", + results_dir="debug_test" ) # Check debug directory @@ -621,18 +621,18 @@ cat output.txt import fz import os -os.environ['FZ_LOG_LEVEL'] = 'DEBUG' +fz.set_log_level('DEBUG') # or FZ_LOG_LEVEL=DEBUG before starting Python # First run -fz.fzr("input.txt", {"param": 1}, model, "sh://bash calc.sh", "run1/") +fz.fzr("input.txt", {"param": 1}, model, calculators="sh://bash calc.sh", results_dir="run1/") # Second run with cache (check debug logs) fz.fzr( "input.txt", {"param": 1}, model, - ["cache://run1", "sh://bash calc.sh"], - "run2/" + calculators=["cache://run1", "sh://bash calc.sh"], + results_dir="run2/" ) # Debug logs will show: "Cache hit for case: ..." ``` @@ -646,7 +646,7 @@ import fz import pandas as pd # Run parametric study -results = fz.fzr("input.txt", variables, model, calculators, "results/") +results = fz.fzr("input.txt", variables, model, calculators=calculators, results_dir="results/") # Advanced pandas operations summary = results.groupby('temp').agg({ @@ -670,7 +670,7 @@ results.to_json('results.json', orient='records') import fz import matplotlib.pyplot as plt -results = fz.fzr("input.txt", variables, model, calculators, "results/") +results = fz.fzr("input.txt", variables, model, calculators=calculators, results_dir="results/") # Create subplot for each parameter fig, axes = plt.subplots(2, 2, figsize=(12, 10)) @@ -697,7 +697,7 @@ import fz from IPython.display import display # Run study -results = fz.fzr("input.txt", variables, model, calculators, "results/") +results = fz.fzr("input.txt", variables, model, calculators=calculators, results_dir="results/") # Interactive display display(results.head()) @@ -754,8 +754,8 @@ results = fz.fzr( str(INPUT), {"temp": [10, 20, 30], "pressure": [1, 10]}, "my_model", # Loads from .fz/models/my_model.json - "cluster", # Loads from .fz/calculators/cluster.json - str(RESULTS) + calculators="cluster", # Loads from .fz/calculators/cluster.json + results_dir=str(RESULTS) ) # Save results diff --git a/doc/shell-path.md b/doc/shell-path.md index 31c19549..9cccef1f 100644 --- a/doc/shell-path.md +++ b/doc/shell-path.md @@ -66,7 +66,8 @@ export FZ_SHELL_PATH=/opt/tools/bin:/usr/local/bin:/usr/bin **Python:** ```python import os -os.environ['FZ_SHELL_PATH'] = '/opt/custom/bin:/usr/local/bin' +os.environ['FZ_SHELL_PATH'] = '/opt/custom/bin:/usr/local/bin' # before `import fz` +import fz ``` ### Path Separators @@ -217,7 +218,7 @@ print(f"Shell path: {config.shell_path}") ### Resolving Commands Manually ```python -from fz.shell_path import resolve_command, replace_commands_in_string +from fz.shell import resolve_command, replace_commands_in_string # Resolve single command grep_path = resolve_command("grep") @@ -232,7 +233,7 @@ print(f"Resolved: {resolved}") ### Listing Available Binaries ```python -from fz.shell_path import get_resolver +from fz.shell import get_resolver resolver = get_resolver() binaries = resolver.list_available_binaries() @@ -243,12 +244,14 @@ print(f"Available binaries: {binaries}") ```python import os -from fz.shell_path import reinitialize_resolver +import fz +from fz.shell import reinitialize_resolver # Change shell path os.environ['FZ_SHELL_PATH'] = '/new/path/bin' -# Reinitialize resolver to pick up new path +# Re-read FZ_* variables, then rebuild the resolver from the new configuration +fz.reload_config() reinitialize_resolver() ``` @@ -329,7 +332,7 @@ Cache is cleared when: ### ShellPathResolver Class -Located in `fz/shell_path.py`: +Located in `fz/shell.py`: ```python class ShellPathResolver: @@ -350,11 +353,11 @@ class ShellPathResolver: ```python # Get singleton resolver instance -from fz.shell_path import get_resolver +from fz.shell import get_resolver resolver = get_resolver() # Convenience functions -from fz.shell_path import resolve_command, replace_commands_in_string +from fz.shell import resolve_command, replace_commands_in_string path = resolve_command("grep") resolved_cmd = replace_commands_in_string("grep file.txt") ``` @@ -425,5 +428,5 @@ jobs: - **Shell path example**: `examples/shell_path_example.md` - **Configuration guide**: `doc/overview.md` - **Calculator types**: `doc/calculators.md` -- **Source code**: `fz/shell_path.py` +- **Source code**: `fz/shell.py` - **Tests**: `tests/test_shell_path.py` diff --git a/doc/syntax-guide.md b/doc/syntax-guide.md index d466a86f..19f04575 100644 --- a/doc/syntax-guide.md +++ b/doc/syntax-guide.md @@ -22,7 +22,7 @@ Pressure: ${pressure} ```python model = { "varprefix": "$", # Variable prefix - "delim": "{}" # Optional delimiters (can be empty) + "delim": "{}" # Delimiters; if omitted, variables use "()" and formulas "{}" } ``` @@ -34,26 +34,20 @@ model = { ### Legacy Funz Syntax Compatibility -FZ supports the legacy Java Funz variable syntax for backward compatibility: +Templates written for the Java Funz framework use `$(var)` for variables and `@{expr}` for +formulas. This is exactly what fz applies when the model has **no `delim` key** +(variables delimited by `()`, formulas by `{}`), or explicitly: -```text -# Old Funz syntax (question mark prefix) -Temperature: ?T_celsius -Pressure: ?pressure - -# Equivalent to modern FZ syntax -Temperature: $T_celsius -Pressure: $pressure +```python +model = {"var_prefix": "$", "var_delim": "()", "formula_prefix": "@", "formula_delim": "{}"} ``` -**Automatic detection**: `?var` is automatically converted to `$var` internally. No configuration is needed, and both syntaxes can be mixed in the same file. - -**Use cases**: -- Migrating from Java Funz to Python FZ -- Reusing existing Funz input templates -- Backward compatibility with legacy projects +Also supported from Java Funz: `$(var~default;comment;bounds)` metadata (only the default +is used), `#@: code` static context lines, `#@? ...` test lines (skipped), and +`@{expr | 0.00}` number formats. See `examples/java_funz_syntax_example.py`. -See `examples/java_funz_syntax_example.py` for complete examples. +A template using `?var` as variable marker needs `"varprefix": "?"`; `?var` is **not** +converted to `$var` automatically. ### Default Values diff --git a/examples/02_variable_syntax_and_formulas.ipynb b/examples/02_variable_syntax_and_formulas.ipynb index 98a10b93..0098c6b4 100644 --- a/examples/02_variable_syntax_and_formulas.ipynb +++ b/examples/02_variable_syntax_and_formulas.ipynb @@ -17,7 +17,7 @@ "| Formula | `@{expression}` | `@{x * 2 + 1}` |\n", "| Context code line | `#@ code` | `#@ import math` |\n", "| Static constant | `#@: NAME = value` | `#@: PI = 3.14159` |\n", - "| Legacy Java syntax | `?(name)` | `?(x)` |\n", + "| `?` prefix (model `varprefix: \"?\"`) | `?(name)` | `?(x)` |\n", "\n", "This notebook exhaustively tests each feature.\n" ] @@ -413,7 +413,9 @@ "id": "97cc2667", "metadata": {}, "source": [ - "## 6 · Legacy Java/Funz syntax: `?(name)`" + "## 6 · Templates using `?(name)`\n", + "\n", + "`?(name)` is not converted automatically: declare `?` as the variable prefix and `()` as delimiters in the model." ] }, { @@ -433,14 +435,12 @@ "name": "stdout", "output_type": "stream", "text": [ - "Variables from legacy syntax: {}\n", - " ⚠️ Warning: The following input variables are not found in input files: x, y\n", + "Variables from ?(name) syntax: {'x': None, 'y': None, 'z': 0.0}\n", "\n", "Compiled:\n", - "# Legacy Funz Java variable syntax — fz auto-converts it\n", - "x = ?(x)\n", - "y = ?(y)\n", - "z = ?(z~0.0)\n", + "x = 1.0\n", + "y = 2.0\n", + "z = 0.0\n", "\n" ] } @@ -448,18 +448,18 @@ "source": [ "tmpl = WORK / \"legacy.in\"\n", "tmpl.write_text(\n", - " \"# Legacy Funz Java variable syntax — fz auto-converts it\\n\"\n", " \"x = ?(x)\\n\"\n", " \"y = ?(y)\\n\"\n", " \"z = ?(z~0.0)\\n\"\n", ")\n", - "vars_ = fz.fzi(str(tmpl), MODEL)\n", - "print(\"Variables from legacy syntax:\", vars_)\n", + "LEGACY_MODEL = {\"varprefix\": \"?\", \"formulaprefix\": \"@\", \"delim\": \"()\", \"commentline\": \"#\", \"output\": {}}\n", + "vars_ = fz.fzi(str(tmpl), LEGACY_MODEL)\n", + "print(\"Variables from ?(name) syntax:\", vars_)\n", "\n", "out = WORK / \"legacy_out\"\n", - "fz.fzc(str(tmpl), {\"x\": 1.0, \"y\": 2.0}, MODEL, output_dir=str(out))\n", + "fz.fzc(str(tmpl), {\"x\": 1.0, \"y\": 2.0}, LEGACY_MODEL, output_dir=str(out))\n", "print(\"\\nCompiled:\")\n", - "print(read_compiled(out, \"legacy.in\"))\n" + "print(read_compiled(out, \"legacy.in\"))" ] }, { diff --git a/examples/dataframe_input.md b/examples/dataframe_input.md index d4af658e..6f5118dc 100644 --- a/examples/dataframe_input.md +++ b/examples/dataframe_input.md @@ -33,7 +33,7 @@ input_variables = { # Creates 4 cases: 2 × 2 = 4 # (100, 1.0), (100, 2.0), (200, 1.0), (200, 2.0) -results = fzr(input_file, input_variables, model, calculators) +results = fzr(input_file, input_variables, model, calculators=calculators) ``` ### Non-Factorial (DataFrame) - SPECIFIC Combinations @@ -51,7 +51,7 @@ input_variables = pd.DataFrame({ # (100, 1.0), (200, 1.0), (100, 2.0) # Note: (200, 2.0) is NOT included -results = fzr(input_file, input_variables, model, calculators) +results = fzr(input_file, input_variables, model, calculators=calculators) ``` ## Practical Examples @@ -76,7 +76,7 @@ input_variables = pd.DataFrame({ # Dict would create all 25 combinations (5×5), including invalid ones like: # (1000 RPM, 50 Load) - would stall the engine -results = fzr("engine_input.txt", input_variables, model, calculators) +results = fzr("engine_input.txt", input_variables, model, calculators=calculators) ``` ### 2. Latin Hypercube Sampling (LHS) @@ -103,7 +103,7 @@ input_variables = pd.DataFrame({ # would be 5×5×5 = 125 cases # LHS: Only 20 cases, but covers the design space well -results = fzr("simulation.txt", input_variables, model, calculators) +results = fzr("simulation.txt", input_variables, model, calculators=calculators) ``` ### 3. Sobol Sequence Sampling @@ -124,7 +124,7 @@ input_variables = pd.DataFrame({ "y": sample[:, 1] * 50 # [0, 50] }) -results = fzr("input.txt", input_variables, model, calculators) +results = fzr("input.txt", input_variables, model, calculators=calculators) ``` ### 4. Imported Design from DOE Software @@ -146,7 +146,7 @@ previous_results = pd.read_csv("results.csv") # Re-run with different settings input_variables = previous_results[["temp", "pressure", "flow"]] -results = fzr("input.txt", input_variables, model, calculators) +results = fzr("input.txt", input_variables, model, calculators=calculators) ``` ### 5. Sensitivity Analysis (One-at-a-Time) @@ -178,7 +178,7 @@ for flow in [10, 20, 30, 40, 50]: input_variables = pd.DataFrame(oat_cases) # Creates 13 cases instead of full factorial (5×5×5 = 125) -results = fzr("input.txt", input_variables, model, calculators) +results = fzr("input.txt", input_variables, model, calculators=calculators) ``` ### 6. Custom Optimization Samples @@ -204,7 +204,7 @@ input_variables = pd.DataFrame( columns=["temp", "pressure"] ) -results = fzr("input.txt", input_variables, model, calculators) +results = fzr("input.txt", input_variables, model, calculators=calculators) # Use results to inform next iteration of optimization best_case = results.loc[results["efficiency"].idxmax()] @@ -227,7 +227,7 @@ input_variables = pd.DataFrame({ "pressure": 1.0 + 0.5 * np.sin(time/10) # Oscillating pressure }) -results = fzr("input.txt", input_variables, model, calculators) +results = fzr("input.txt", input_variables, model, calculators=calculators) ``` ## DataFrame vs Dict Comparison @@ -274,7 +274,7 @@ input_variables = pd.DataFrame({ "y": [10, 20, 15, 25, 30] }) -results = fzr("input.txt", input_variables, model, calculators) +results = fzr("input.txt", input_variables, model, calculators=calculators) # Results include all input variables print(results[["x", "y", "output"]]) @@ -298,7 +298,7 @@ input_variables.to_csv("my_design.csv", index=False) # Load and reuse input_variables = pd.read_csv("my_design.csv") -results = fzr("input.txt", input_variables, model, calculators) +results = fzr("input.txt", input_variables, model, calculators=calculators) ``` ### 4. Append or Filter Cases @@ -375,7 +375,7 @@ coarse_grid = pd.DataFrame({ "y": [0, 50, 100] }) -results_coarse = fzr("input.txt", coarse_grid, model, calculators) +results_coarse = fzr("input.txt", coarse_grid, model, calculators=calculators) # Identify region of interest (e.g., high output) threshold = results_coarse["output"].quantile(0.75) @@ -387,7 +387,7 @@ refined_grid = pd.DataFrame({ "y": np.linspace(40, 60, 10) }) -results_refined = fzr("input.txt", refined_grid, model, calculators) +results_refined = fzr("input.txt", refined_grid, model, calculators=calculators) ``` ## Summary diff --git a/examples/generate_notebooks.py b/examples/generate_notebooks.py index abbf52a7..16386114 100644 --- a/examples/generate_notebooks.py +++ b/examples/generate_notebooks.py @@ -308,7 +308,7 @@ def nb02(): | Formula | `@{expression}` | `@{x * 2 + 1}` | | Context code line | `#@ code` | `#@ import math` | | Static constant | `#@: NAME = value` | `#@: PI = 3.14159` | -| Legacy Java syntax | `?(name)` | `?(x)` | +| `?` prefix (model `varprefix: "?"`) | `?(name)` | `?(x)` | This notebook exhaustively tests each feature. """)) @@ -458,21 +458,23 @@ def nb02(): print(read_compiled(out, "static.in")) """)) - cells.append(md("## 6 · Legacy Java/Funz syntax: `?(name)`")) + cells.append(md("## 6 · Templates using `?(name)`\n\n" +"`?(name)` is not converted automatically: declare `?` as the variable prefix and " +"`()` as delimiters in the model.")) cells.append(code("""\ tmpl = WORK / "legacy.in" tmpl.write_text( - "# Legacy Funz Java variable syntax — fz auto-converts it\\n" "x = ?(x)\\n" "y = ?(y)\\n" "z = ?(z~0.0)\\n" ) -vars_ = fz.fzi(str(tmpl), MODEL) -print("Variables from legacy syntax:", vars_) +LEGACY_MODEL = {"varprefix": "?", "formulaprefix": "@", "delim": "()", "commentline": "#", "output": {}} +vars_ = fz.fzi(str(tmpl), LEGACY_MODEL) +print("Variables from ?(name) syntax:", vars_) out = WORK / "legacy_out" -fz.fzc(str(tmpl), {"x": 1.0, "y": 2.0}, MODEL, output_dir=str(out)) +fz.fzc(str(tmpl), {"x": 1.0, "y": 2.0}, LEGACY_MODEL, output_dir=str(out)) print("\\nCompiled:") print(read_compiled(out, "legacy.in")) """)) diff --git a/skills/fz/SKILL.md b/skills/fz/SKILL.md index 094aa910..69b096a2 100644 --- a/skills/fz/SKILL.md +++ b/skills/fz/SKILL.md @@ -62,7 +62,10 @@ fz install model modelica # name → https://github.com/Funz/fz-modelica fz list --check --format json # verify what got installed ``` -This drops into the project's `.fz/` directory (add `--global` for `~/.fz/`): +This drops into the project's `.fz/` directory (`--global` for `~/.fz/` — but then the +calculator alias still runs the relative `bash .fz/calculators/.sh`, never found in +`~/.fz/`, so it fails outside the install directory: prefer project-local installs, or edit the alias to an +absolute script path): - `.fz/models/.json` — the model definition (variable syntax + output parsers); refer to it by bare alias, e.g. `--model Modelica`. @@ -94,8 +97,8 @@ T_kelvin=@{$T_celsius + 273.15} V_m3=@{L_to_m3($V_L)} ``` -Syntax (with default model settings `varprefix="$"`, `formulaprefix="@"`, `delim="{}"`, -`commentline="#"`): +Syntax (with model settings `varprefix="$"`, `formulaprefix="@"`, `delim="{}"`, +`commentline="#"` — set `delim` explicitly, see below): - `$name` or `${name}` — a variable to substitute. - `${name~default}` — variable with a default value used when not provided. @@ -103,7 +106,10 @@ Syntax (with default model settings `varprefix="$"`, `formulaprefix="@"`, `delim R optional). Formulas may reference variables: `@{$T_celsius + 273.15}`. - Lines starting with `#@` (commentline + formulaprefix) define context for formulas: imports, constants, function definitions. Multi-line functions are supported. -- Legacy Java-Funz `?name` syntax is accepted transparently. +- **Always set `"delim"` in the model.** Without it, variables use `()` (`$x`, `$(x)`) and + `${x}` is NOT recognized, while formulas still use `@{...}` (Java-Funz convention). The + CLI without `--model` applies `delim="{}"`, so results can differ from Python. `?name` + is only a variable with `varprefix="?"` (no automatic conversion). If `$`, `@`, `{}`, or `#` collide with the simulation code's own syntax, change them in the model (e.g. `varprefix="%"`, `commentline="//"`). @@ -170,18 +176,23 @@ fzi --input_path input.txt --model perfectgas --format json # Must list EXACTLY your variables. Stray names (e.g. the code's own $-macros) mean your # varprefix collides with the code's syntax — change it (e.g. varprefix="%") and re-check. -# 4. Compilation correct for one case? +# 4. Compilation correct for one case? fzc writes ONE SUB-DIRECTORY PER CASE, named +# after the values (here compiled/n_mol=1,T_celsius=20,V_L=10/), even for scalars. fzc --input_path input.txt --model perfectgas \ --input_variables '{"n_mol": 1, "T_celsius": 20, "V_L": 10}' --output_dir compiled/ -cat compiled/input.txt +cat compiled/*/input.txt -# 5. Simulation runs on the compiled input? -(cd compiled && bash /path/to/PerfectGazPressure.sh input.txt) +# 5. Simulation runs on the compiled input? (run it inside the case sub-directory) +(cd compiled/*/ && bash /path/to/PerfectGazPressure.sh input.txt) -# 6. Outputs parse correctly? -fzo --output_path compiled/ --model perfectgas --format json +# 6. Outputs parse correctly? Point fzo at the case directory (or a glob), not its parent: +# `fzo compiled/` returns a single row of nulls. +fzo --output_path 'compiled/*' --model perfectgas --format json ``` +Also: if `compiled/` already exists, fz renames it with a timestamp suffix and writes a +fresh one — remove it between attempts to keep the glob unambiguous. + Python equivalents: `fz.fzi(input_path, model)`, `fz.fzc(input_path, input_variables, model, output_dir)`, `fz.fzo(output_path, model)`. @@ -210,28 +221,39 @@ fzr --input_path input.txt --model perfectgas \ - A **pandas DataFrame** ⇒ non-factorial: each row is one case (use for LHS designs, constrained combinations, or designs imported from CSV). - Returns a DataFrame with one row per case: variable columns, output columns, and - metadata columns `status` (`done`/`error`/`cached`), `calculator`, `error`, `command`. + metadata columns `status` (`done`/`failed`/`error`/`timeout`/`interrupted`; a cache hit + is `done` with a `cache://...` calculator), `calculator`, `error`, `command`. - List-valued outputs (e.g. time series) become list columns — one whole trajectory per row. The Modelica wrapper, for instance, yields `res__time`, `res__T`, … per case; plot directly with `for _, row in results.iterrows(): plt.plot(row["res_M_time"], row["res_M_T"])`. - Each case directory under `results/` keeps compiled inputs, outputs, `out.txt`, - `err.txt`, `log.txt` — read these to diagnose failed cases. + `err.txt`, `log.txt` — read these to diagnose failed cases. These names (plus + `info.txt`, `history.txt`, `.fz_hash`) are **reserved**: a code output called + `out.txt` is overwritten by the captured stdout. The results root also gets + `manifest.json` (+ `ro-crate-metadata.json`) for traceability. - Failed cases are retried automatically on another calculator (default 5 attempts, `FZ_MAX_RETRIES`). ## Calculators (where cases run) -Calculator URIs; pass one or a list (a list runs cases in parallel, round-robin): +Calculator URIs; pass one or a list. Each non-cache entry runs **one case at a time**, so +the number of entries is the number of parallel cases (`["sh://bash run.sh"] * 4` → 4): | URI | Meaning | |-----|---------| -| `sh://command` | Local shell. `command` gets the compiled input file as first argument. | -| `ssh://user[:password]@host[:port]/command` | Remote via SSH (files transferred automatically; prefer key auth; use absolute paths in `command`). | -| `slurm://[user@host[:port]]:partition/command` | SLURM via `srun` (local form: `slurm://:partition/...`). | +| `sh://command` | Local shell, run in the case's temp directory; the compiled input file names are appended to the end of `command`. | +| `ssh://user[:password]@host[:port]/command` | Remote via SSH (files transferred automatically; prefer key auth; use absolute paths in `command`). No default timeout. | +| `slurm://[user@host[:port]]:partition/command` | SLURM via `srun` (local form: `slurm://:partition/...`); resources as `?cores=4&mem=8G&time=01:00:00`. No default timeout. | +| `slurm-array://:partition/command` | Local SLURM only: all cases batched into one sbatch job array (`?maxrunning=M` throttles). | | `cache://path` | Reuse results from a previous results directory (match by input hash). Put it first in the list. | | `funz://host:port/ModelName` | Legacy Java Funz server. | +> **`sh://` appends the input files to the whole command line.** `sh://cat input.txt > +> res.txt` runs `cat input.txt > res.txt input.txt`. Put any file handling in a script +> launched with `sh://bash run.sh`; inside it, relative paths are the case directory and +> `$1`… are the compiled input files. (Path resolution: see the tips below.) + Interrupt-and-resume / incremental extension of a study: ```bash @@ -241,11 +263,21 @@ fzr --input_path input.txt --model m --input_variables '...' \ # only cases absent from results_run1 are computed ``` +To resume **in the same directory**, use the special entry `cache://_` (the previous +content of `--results_dir`, which fz renames with a timestamp before running): +`--calculators '["cache://_", "sh://bash calc.sh"]' --results_dir results/`. +`cache://results` with `--results_dir results` never hits. + Calculator aliases live in `.fz/calculators/.json` with the command per model id: `{"uri": "ssh://user@cluster", "models": {"perfectgas": "bash /path/calc.sh"}}`. Run `fz list --check --format json` (alias `fzl`) to list and validate installed models/calculators — prefer the `fz ` forms, which survive stale or partially-installed standalone scripts. +`fz list` limitation: calculator aliases are shown by their `uri`, not their file name, +and an alias whose command is in its `models` map (`{"uri": "sh://", "models": {...}}`, +the layout of installed wrappers) is reported `check_status: failed` / +`"Empty sh:// command"` by `--check` although it works. Trust the model's +`check_status` and a real `fzr` run without `--calculators`, not that calculator line. ## Design of experiments / optimization (fzd) @@ -324,8 +356,13 @@ read [algorithm-wrapper.md](algorithm-wrapper.md). default to `None`: for a non-parametric dataset (no variables in the input files) call `fzr(input_path, model=model, ...)` and omit it; if the input files do declare variables and it's omitted, fz raises a `ValueError` naming them. -- Concurrency: repeat the same calculator URI N times (or set `FZ_MAX_WORKERS`) to run N - cases in parallel. +- Concurrency: repeat the same calculator URI N times to run N cases in parallel. + `FZ_MAX_WORKERS` only caps the worker count; it never adds workers. +- `FZ_*` environment variables are read at `import fz`: set them before starting Python, + or call `fz.reload_config()` after changing `os.environ`. +- Timeouts: default 3600 s for `sh://`/`funz://`, none for `ssh://`/`slurm://`. To lift it + for one model, set `"timeout": null` in the model; `FZ_RUN_TIMEOUT=0` or `timeout=0` + makes every case time out immediately. - Long studies: run `fzr` in the background, then monitor `results/*/log.txt` and the per-case `out.txt`/`err.txt`; on interrupt, partial results survive and `cache://` resumes. - Full API and CLI details, environment variables, and the model/calculator JSON schemas: @@ -339,6 +376,10 @@ read [algorithm-wrapper.md](algorithm-wrapper.md). | All cases `failed`, `N calculator failures` | The calculator command itself errors — read the case's `err.txt`/`log.txt`. | | Output column is `null` but case is `done` | Output command matched nothing: wrong path/field, missing subdir output (see directory codes), locale (`LC_ALL=C`), or `python` vs `python3`. | | `fzi` lists extra/unexpected variables | `varprefix` collides with the code's own syntax — change it. | +| A directory literally named `sh:/...` appears; all cases fail | Calculator URI passed as 4th positional arg of `fz.fzr` (that slot is `results_dir`) — use `calculators=`. | +| Output column holds the program's stdout instead of a file's content | The code writes a reserved name (`out.txt`, `err.txt`, `log.txt`, ...) that fz overwrites — rename it. | +| Every case `timeout` immediately | `FZ_RUN_TIMEOUT=0` / `timeout=0`: zero is a real limit, not "unlimited". | +| Output file holds its content twice / odd arguments | The compiled input names are appended to the end of the `sh://` command line — move the logic into a script. | | `fzd` runs an empty `sh://` / every case fails | fz 1.0 only: `fzd` didn't auto-discover calculators — pass them explicitly, or upgrade to fz ≥ 1.1. | ## Worked examples diff --git a/skills/fz/algorithm-wrapper.md b/skills/fz/algorithm-wrapper.md index bb060584..18ec470e 100644 --- a/skills/fz/algorithm-wrapper.md +++ b/skills/fz/algorithm-wrapper.md @@ -111,14 +111,18 @@ From a scratch directory, install and run a known problem whose answer you can c ```bash fz install algorithm ./fz-myalgo.zip # or the repo path / URL -fz list # algorithm available? +ls .fz/algorithms/ # installed? (fz list shows models/calculators only; + # Python: fz.list_installed_algorithms()) # drive it through fzd on a simple input (see code-wrapper.md for wrapping the code) fzd --input_path tests/input.txt --model MyCode \ --input_variables '{"x": "[0;10]"}' --output_expression "result" \ - --algorithm myalgo --options '{"max_iter": 20}' --format json + --algorithm myalgo --options '{"max_iter": 20}' --results_dir results_fzd ``` +`fzd` has no `--format` option: it prints a summary on stdout and writes the design and +analysis under `--results_dir` (default `results_fzd`). + Ship that as `tests/test.sh` in the repo. Publish conventions match code wrappers: repo named `fz-`, default branch `main` (the installer fetches `archive/refs/heads/main.zip`), and a README stating the options, what the algorithm diff --git a/skills/fz/code-wrapper.md b/skills/fz/code-wrapper.md index 1e5c1865..853d96a4 100644 --- a/skills/fz/code-wrapper.md +++ b/skills/fz/code-wrapper.md @@ -80,13 +80,16 @@ Rules and choices: The contract (see "Per-case execution lifecycle" in [reference.md](reference.md)): - invoked **inside a fresh case directory** containing the compiled input file(s); -- receives the compiled input file (or directory) as **first argument** `$1`; +- receives the compiled input file names as arguments (`$1`, `$2`, ... — appended after + the command of the calculator alias); - must write the output files that the model's `output` commands parse; - exit status `0` = case done, non-zero = case failed (fz retries it elsewhere); - the command is written relative to the case directory: bare names (`input.txt`, `out.dat`) refer to the case's own files; a bare word is resolved to the launch directory only when it exists there and not in the case directory, and `>` targets are never resolved; -- stdout/stderr are captured to `out.txt`/`err.txt` automatically — print freely; +- stdout/stderr are captured to `out.txt`/`err.txt` automatically — print freely, but + never make the code write its own results to `out.txt`, `err.txt`, `log.txt`, + `info.txt` or `history.txt`: fz overwrites these names; - if the code spawns long-lived subprocesses, write their PID to a `PID` file so interrupts can kill them. @@ -154,7 +157,10 @@ worked wrapper of this kind. **Definition of done** — the wrapper is finished only when both hold: -1. `fz list --check --format json` shows the model AND a calculator supporting it; +1. `fz list --check --format json` shows the model with `check_status: passed` (the + calculator line shows the alias's `uri`, e.g. `sh://`, and may read + `"Empty sh:// command"` for a `{"uri": "sh://", "models": {...}}` alias — a known + `fz list` limitation, not a wrapper defect); 2. `fzr --model MyCode ...` **without any `--calculators` argument** runs a case successfully (proves alias discovery works, not just a hand-built `sh://` URI). @@ -164,14 +170,14 @@ From a scratch directory: ```bash fz install model ./fz-mycode.zip # or the repo path / URL -fz list --check --format json # model + calculator must validate +fz list --check --format json # model must pass (see the calculator caveat above) # then the SKILL.md verification ladder on a sample input: fzi --input_path tests/input.txt --model MyCode --format json # variables found? fzc --input_path tests/input.txt --model MyCode \ --input_variables '{"x": 1}' --output_dir compiled/ # compiles? -(cd compiled && bash .fz/calculators/MyCode.sh input.txt) # runs? -fzo --output_path compiled/ --model MyCode --format json # outputs parse? +(cd compiled/*/ && bash "$OLDPWD/.fz/calculators/MyCode.sh" input.txt) # runs? (case sub-dir) +fzo --output_path 'compiled/*' --model MyCode --format json # outputs parse? fzr --input_path tests/input.txt --model MyCode \ --input_variables '{"x": [1, 2]}' --format json # end to end ``` diff --git a/skills/fz/reference.md b/skills/fz/reference.md index d3d08627..62cefb59 100644 --- a/skills/fz/reference.md +++ b/skills/fz/reference.md @@ -26,9 +26,12 @@ fz.fzc(input_path: str, input_variables: dict, model: str | dict, output_dir: str = "output", input_static: list[str] = None) -> None ``` -Substitutes variables and evaluates formulas. Scalar values produce a single compiled -copy in `output_dir/`; list values produce one subdirectory per combination, named -`var1=val1,var2=val2,...`. +Substitutes variables and evaluates formulas. Whenever the input declares variables, +the result goes to one sub-directory per case, `output_dir/var1=val1,var2=val2,.../`, even +when every value is a scalar (one case → one sub-directory); lists produce one +sub-directory per combination. An existing `output_dir` is renamed with a timestamp +suffix first. To parse outputs of a compiled case, point `fzo` at the case sub-directory +or a glob (`output_dir/*`), not at `output_dir` itself. ### fz.fzo — parse output files @@ -68,8 +71,12 @@ fz.fzr(input_path: str, ``` - 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`. +- **Pass `calculators=` and `results_dir=` by keyword**: `results_dir` is the 4th + positional parameter, so `fzr(path, vars, model, "sh://bash run.sh")` silently uses the + URI as a directory name and runs without calculator (every case fails). +- Returns a DataFrame: variable columns + output columns + `status` (`done`, `failed`, + `error`, `timeout`, `interrupted`; a cache hit is `done` with a `cache://...` + `calculator`), `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 - unsafe characters in a key/value are percent-encoded @@ -88,8 +95,14 @@ fz.fzr(input_path: str, for the full write-up. A large (`FZ_STATIC_CANDIDATE_MIN_SIZE`, default 1 MiB) variable-free file left in `input_path` instead triggers a one-time warning suggesting `input_static`. -- `callbacks` supports `on_start(total_cases, calculators)`, plus per-case progress - callbacks (see docstring of `fz.fzr`). +- `callbacks` is a **dict** (not a list) with any of: `on_start(total_cases, + calculators)`, `on_case_start(case_index, total_cases, var_combo)`, + `on_case_complete(case_index, total_cases, var_combo, status, result)`, + `on_progress(completed, total, eta_seconds)`, `on_complete(total_cases, + completed_cases, results_df)`. Unknown keys raise `ValueError`; callbacks run in worker + threads and their exceptions are logged, not raised. +- `timeout` (seconds) overrides the model's `"timeout"` and `FZ_RUN_TIMEOUT`. `0` does + not disable it (every case times out at once); only a model `"timeout": null`/`0` does. - Ctrl+C interrupts gracefully; completed cases stay in `results_dir` and can be reused with a `cache://results_dir` calculator. @@ -129,10 +142,12 @@ callable as `model` instead of a dict/alias. Then `input_path` must be `None`; `input_variables` keys must match the function's parameters; `output_expression` may be `None` (defaults to the first value of the function's return — scalar, first list/tuple item, or first dict/namedtuple key); `calculators` must be an -`int` (default `1`), accepted for API compatibility — calls always run -sequentially in-process, never in parallel, so the model function is safe to -call even if it's only usable from the calling thread (e.g. an R function -bridged in via reticulate). Each iteration's directory then contains +`int` (default `1`): the number of points evaluated concurrently. `1` calls the +function sequentially in the calling thread (required for callables usable only +from that thread, e.g. an R function bridged in via reticulate); `N > 1` uses a +thread pool of N threads (thread-safe functions only), and any error raised then +aborts `fzd` with `fz.FunctionModelParallelError` instead of marking one point +failed. Each iteration's directory then contains only a `values.csv` of that iteration's function inputs/outputs (no case dirs). ### fz.fzl — list and validate models/calculators @@ -141,6 +156,15 @@ only a `values.csv` of that iteration's function inputs/outputs (no case dirs). fz.fzl(models: str = "*", calculators: str = "*", check: bool = False) -> dict ``` +Returns `{"models": {name: {"path", "properties", "supported_calculators", +"check_status"...}}, "calculators": {uri: {"supports_models", "check_status"...}}}`. +Algorithms are not listed (`fz.list_installed_algorithms()`). +`fz list` limitation: calculator aliases are shown by their `uri`, not their file name, +and an alias whose command is in its `models` map (`{"uri": "sh://", "models": {...}}`, +the layout of installed wrappers) is reported `check_status: failed` / +`"Empty sh:// command"` by `--check` although it works. Trust the model's +`check_status` and a real `fzr` run without `--calculators`, not that calculator line. + ### Configuration helpers ```python @@ -184,7 +208,11 @@ repeatable to add several. See `input_static` in `fz.fzr`'s signature above. `--input_variables`, `--calculator` = `--calculators` (repeatable), `--results` = `--results_dir`, `--output` = `--output_dir`. (fz 1.0 required the canonical flag names and had no positional form; the canonical flags work everywhere — prefer them.) -- `--format` accepts: `json`, `csv`, `html`, `markdown`, `table`. +- `--format` accepts: `json`, `csv`, `html`, `markdown`, `table`. `fzl`/`fz list` only + offer `json`, `markdown` and `table`; `fzc` and `fzd` have no `--format`. +- No CLI flag sets a timeout: use the model's `"timeout"` or `FZ_RUN_TIMEOUT`. +- `fzd --output_expression` takes one expression: the multi-objective list form is + Python-only. - `--input_variables` (fzc/fzr only) can be omitted when the input files declare no variables (a non-parametric dataset) — omitting it otherwise errors out listing the variable(s) found, so it's still required whenever the model actually has any. @@ -198,6 +226,8 @@ repeatable to add several. See `input_static` in `fz.fzr`'s signature above. `--formulaprefix`, `--delim`, `--commentline`, `--interpreter`, and repeatable `--output-cmd NAME=COMMAND` for output parsers. - `--input_vars` (fzd) takes JSON with `"[min;max]"` range strings for varied variables. +- `--input_variables` also accepts the short form `'a=1,b=[4,5,6]'` (fzd: `'x=[0;1],y=2'`). + It is always a full factorial: a list of explicit cases (DataFrame) is Python-only. Stream discipline: results go to stdout; logs (`FZ_LOG_LEVEL`), progress bar, and error messages go to stderr (the progress bar is disabled when stderr is not a TTY). Exit codes: @@ -221,10 +251,13 @@ non-zero on failure, and `fzr` exits 1 when no case reached status `done`. Use } ``` -All fields optional except `output` (required to parse results). `id` links the model to +All fields optional except `output` (required to parse results). Defaults when absent: +`varprefix` `$`, `formulaprefix` `@`, `commentline` `#`, `interpreter` python, and — the +trap — variable delimiters `()` / formula delimiters `{}` (`var_delim` / `formula_delim` +keys set them separately; `delim` sets both). `id` links the model to calculator alias files. Search path for aliases: `./.fz/models/.json` then `~/.fz/models/.json`. `timeout` (int seconds, or `null`/`0` to disable) overrides -`FZ_RUN_TIMEOUT` for this model; an explicit `timeout=` argument to `fzr()`/`fzc()` still +`FZ_RUN_TIMEOUT` for this model; an explicit `timeout=` argument to `fzr()` still wins over both. Static files identical across every case (never templated) are declared via `fzr`'s @@ -244,8 +277,8 @@ Static files identical across every case (never templated) are declared via `fzr } ``` -`models` maps model `id` → command on that machine (compiled input file/dir is passed as -first argument). Search path: `./.fz/calculators/.json` then `~/.fz/calculators/`. +`models` maps model `id` → command on that machine (the compiled input file names are +appended to the end of the command). Search path: `./.fz/calculators/.json` then `~/.fz/calculators/`. `code_id` (optional) names the calculator's code installation, not its command/host - two calculators sharing the same `code_id` share `cache://` results even with different commands; different `code_id`s never match. `version_cmd` resolves `code_id` by running a @@ -261,6 +294,8 @@ its calculators); a `version_cmd` that exits non-zero leaves the calculator with sh://command local shell (default when omitted) ssh://user[:password]@host[:port]/command remote SSH (paramiko, SFTP transfer) slurm://[user@host[:port]]:partition/command SLURM srun; local form slurm://:partition/cmd + (both SLURM forms accept ?cores=&mem=&time=&nodes= + &ntasks=&gres=&account=&qos=) slurm-array://:partition/command[?cores=N&maxrunning=M] local SLURM: all cases batched in one sbatch job array cache://path reuse prior results by input-file hash funz://[host]:port/ModelName legacy Java Funz server protocol @@ -269,27 +304,44 @@ funz://[host]:port/ModelName legacy Java Funz server protocol ## Per-case execution lifecycle For each case, fz: compiles inputs into `results_dir//`; copies them to a temp dir -on the calculator (under `.fz/tmp/`); runs the command with the input file as first -argument; captures `out.txt` (stdout), `err.txt` (stderr), `log.txt` (command, host, env, -timings, exit status); copies everything back; runs the `output` parsing commands; sets -`status` to `done` or `error`. Failed cases are retried on another calculator -(default 5 attempts). +on the calculator (under `.fz/tmp/`); runs the command with the input file names appended +at the end of the command line; captures `out.txt` (stdout), `err.txt` (stderr), `log.txt` +(command, host, env, timings, exit status), plus `info.txt`, `history.txt`, `.fz_hash`; +copies everything back; runs the `output` parsing commands; sets `status`. Failed cases +are retried on another calculator (default 5 attempts). + +Constraints worth knowing (full list: `doc/limitations.md` in the fz repo): + +- **Reserved file names**: a code output named `out.txt`, `err.txt`, `log.txt`, + `info.txt` or `history.txt` is overwritten by fz. Results root: `manifest.json`, + `ro-crate-metadata.json`, `cases.csv`. +- **`sh://` command line**: the compiled input names are appended to the end of the whole + command (after pipes/redirections); a bare file name is made absolute in the launch + directory only if it exists there and not in the case directory. Put file handling in a + script run as `sh://bash run.sh`. +- **Parallelism** = number of non-cache calculator entries; `FZ_MAX_WORKERS` only caps it. +- **Config is read at import**: after changing `os.environ["FZ_..."]`, call + `fz.reload_config()`. +- **Cache key** = SHA-256 of input files (+ `code_id` when declared), never the command. ## Environment variables ``` -FZ_LOG_LEVEL DEBUG | INFO | WARNING | ERROR -FZ_MAX_WORKERS max parallel cases +FZ_LOG_LEVEL QUIET | ERROR (default) | WARNING | INFO | DEBUG +FZ_MAX_WORKERS cap on parallel cases (never above the number of calculator entries, + except slurm-array://) FZ_MAX_RETRIES attempts for failed cases (default 5) FZ_RUN_TIMEOUT per-calculation timeout in seconds (default 3600 = 1h for sh://, funz://; unlimited for ssh://, slurm:// when unset); - a model's own "timeout" entry overrides this + a model's own "timeout" entry overrides this; 0 is NOT "unlimited" FZ_SLURM_POLL_INTERVAL seconds between sacct/squeue polls for slurm-array:// (default 2) FZ_SLURM_ARRAY_WINDOW seconds slurm-array:// gathers cases before one sbatch (default 1) FZ_RO_CRATE 0 to disable the ro-crate-metadata.json written (default 1) next to each campaign's manifest.json (fzr and fzd always write manifest.json) -FZ_SSH_AUTO_ACCEPT_HOSTKEYS 1 to skip interactive host-key prompt (CI; use with care) +FZ_SSH_AUTO_ACCEPT_HOSTKEYS 1 to skip the interactive host-key prompt shown with password auth + (key auth already auto-adds unknown hosts) FZ_SSH_KEEPALIVE SSH keepalive seconds +FZ_INTERPRETER default formula interpreter: python (default) | R FZ_SHELL_PATH bash location on Windows (MSYS2/Git Bash bin dirs) FZ_CASE_NAMING fzr case dir naming: path (default) | hash | index FZ_STATIC_CANDIDATE_MIN_SIZE bytes threshold for the input_static warning (default 1048576; 0 disables) @@ -310,7 +362,8 @@ ${name} variable, explicit delimiters ${name~default} variable with default value @{expr} formula, evaluated at compile time (may reference $vars) #@ code interpreter context line (imports, constants, function defs) -?name legacy Java-Funz syntax, auto-converted to $name +$(name) variable with the Java-Funz "()" delimiters (the default when the model has + no "delim"/"var_delim" key; ${name} is then NOT a variable) ``` ## MCP server From 086be688c51f2c5c9f81e83e7f1284f4f0ce1224 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:40:00 +0000 Subject: [PATCH 2/2] Fix usability defects found while reviewing the documentation - timeout=0 / FZ_RUN_TIMEOUT=0 mean no timeout (they timed every case out immediately); negative timeout= raises ValueError - a model without delim/var_delim recognizes both $(x) and ${x}; the CLI without --model uses the same default (Python only knew $(x), the CLI only ${x}) - fzr() raises ValueError when results_dir looks like a calculator URI (calculator passed as 4th positional argument) - fzl / fz list: aliases listed by file name with uri and path; --check validates each command of an alias's models map (installed-wrapper aliases no longer fail with 'Empty sh:// command'); project aliases shadow global ones; fzl and fz list share their output code - .fz/... paths of calculator aliases are resolved against the .fz/ they were loaded from, so 'fz install --global' wrappers run anywhere - empty .fz/tmp/fz_temp_* directories are removed after a run Status of a case without parsable outputs stays 'done' (deliberate, see test_examples_advanced.test_non_numeric_variables); documented as such. Docs (doc/, skill, NEWS) updated; tests/test_usability_fixes.py added. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012GxLbauyVHBQPCeSow8hdh --- NEWS.md | 32 +++- doc/cli-usage.md | 4 +- doc/configuration.md | 5 +- doc/installing-models.md | 9 +- doc/limitations.md | 40 ++--- doc/model-definition.md | 15 +- doc/syntax-guide.md | 7 +- fz/cli.py | 286 +++++++++++++--------------------- fz/core.py | 223 +++++++++++++------------- fz/helpers.py | 74 ++++++++- fz/interpreter.py | 55 +++++-- fz/runners/manager.py | 13 +- skills/fz/SKILL.md | 39 +++-- skills/fz/code-wrapper.md | 8 +- skills/fz/reference.md | 29 ++-- tests/test_usability_fixes.py | 187 ++++++++++++++++++++++ 16 files changed, 626 insertions(+), 400 deletions(-) create mode 100644 tests/test_usability_fixes.py diff --git a/NEWS.md b/NEWS.md index ea38f909..50fe2502 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,32 @@ ## Unreleased +### Usability fixes found while reviewing the documentation + +- **`0` means "no timeout" everywhere**: `timeout=0` and `FZ_RUN_TIMEOUT=0` made every + case time out immediately; they now disable the timeout, like a model `"timeout": 0`. + A negative `timeout=` raises `ValueError`. +- **Default variable delimiters**: a model without `delim`/`var_delim` now recognizes + both `$(x)` and `${x}` (it only recognized `$(x)`, so `${x}` was silently left in the + compiled files), and the CLI without `--model` uses the same default (it used `{}` + only). A model with an explicit `delim` is unchanged. Templates containing other + `${...}` text (e.g. shell snippets) with a model that sets no `delim` now report those + names as variables: set `delim` in such models. +- **`fzr()` rejects a `results_dir` that looks like a calculator URI** (`ValueError`): + `fzr(path, vars, model, "sh://bash run.sh")` used to create a directory named after the + URI and run every case without calculator. +- **`fz list` / `fzl`**: calculator aliases are listed by file name with their `uri` + and `path`; `--check` validates the command of each entry of an alias's `models` map, + so installed-wrapper aliases (`{"uri": "sh://", "models": {...}}`) no longer fail with + `Empty sh:// command`. A project alias shadows a global one with the same name. + The `fzl` and `fz list` output code is shared. +- **Global installs**: `.fz/...` paths in a calculator alias are resolved against the + `.fz/` directory the alias was loaded from, so `fz install model --global` wrappers + (`bash .fz/calculators/.sh`) run from any directory. +- **`.fz/tmp/`**: empty `fz_temp_*` directories are removed after each run (files left + behind are still kept for inspection). +- New `tests/test_usability_fixes.py`. + ### Documentation: constraints page, corrected examples, skill review - New `doc/limitations.md`: constraints and pitfalls checked by running fz (argument @@ -14,17 +40,11 @@ `fz.reload_config()` / `fz.set_log_level()`; `fz.shell_path` imports replaced by `fz.shell`; nonexistent `funz://...?timeout=` removed. - Behaviors now documented as they are: - - a model without `delim` delimits variables with `()` (`${x}` is not a variable) and - formulas with `{}`; the CLI without `--model` uses `{}`; - `?var` is not converted to `$var` (needs `"varprefix": "?"`); notebook 02 fixed; - `fzc` writes one sub-directory per case even for scalar values; `fzo` must target case directories (the skill's verification ladder used `compiled/input.txt`); - - `FZ_RUN_TIMEOUT=0` / `timeout=0` time out immediately (only a model `timeout` of - `null`/`0` disables it); - first Ctrl+C terminates running cases and `fzr` returns (it does not wait for them); - `cache://_` resumes into the same `results_dir`; - - `fz list` shows calculators by `uri` and flags installed-wrapper aliases as failed; - - `fz install --global` leaves runner paths relative (runs fail elsewhere); - no interactive SSH password prompt; `funz://` port is the UDP discovery port; - `fzd` has no `--format`; `fz list` does not list algorithms; DataFrame designs are Python-only. diff --git a/doc/cli-usage.md b/doc/cli-usage.md index c413eb0e..55e3b548 100644 --- a/doc/cli-usage.md +++ b/doc/cli-usage.md @@ -381,8 +381,8 @@ fz uninstall algorithm myalgo --version Show version --model MODEL Model alias or inline definition --varprefix PREFIX Variable prefix (default: $) ---delim DELIMITERS Variable and formula delimiters ({} when --model is absent; - a --model without "delim" keeps () for variables) +--delim DELIMITERS Variable and formula delimiters (default: variables accept + both $(x) and ${x}, formulas use @{...}) --formulaprefix PREFIX Formula prefix (default: @) --commentline CHAR Comment character (default: #) --format FORMAT Output format: json, table, csv, markdown, html diff --git a/doc/configuration.md b/doc/configuration.md index f2582feb..e43a4840 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -118,8 +118,9 @@ model (the calculation may run indefinitely): model = {"timeout": None, "output": {"result": "cat output.txt"}} ``` -`FZ_RUN_TIMEOUT=0` and `timeout=0` do **not** disable the timeout: every case then times -out immediately. Only the model entry disables it. +`0` means "no timeout" at every level: `timeout=0`, a model `"timeout": 0`, or +`FZ_RUN_TIMEOUT=0` (which then also applies to `ssh://`/`slurm://`). Negative values are +refused. ### 3. `fzr()`/`fzc()` `timeout=` Argument (Per-Call) diff --git a/doc/installing-models.md b/doc/installing-models.md index 39d77715..3d472fb6 100644 --- a/doc/installing-models.md +++ b/doc/installing-models.md @@ -82,12 +82,9 @@ alongside it. ## Install location and discovery -> **`--global` and runner scripts.** `fz install model --global` copies the wrapper to `~/.fz/`, but its calculator -> alias keeps the relative command `bash .fz/calculators/.sh`, looked up in the -> launch directory, then the case directory, never in `~/.fz/`: runs from any other -> directory fail (`Command not found locally: '.fz/calculators/.sh'`). Prefer project-local installs, or edit -> `~/.fz/calculators/localhost_.json` to use the absolute path of the script (`~` is -> not expanded). +> **Runner paths.** Installed calculator aliases run `bash .fz/calculators/.sh`; such +> `.fz/...` paths are resolved against the `.fz/` directory the alias was loaded from, so a +> `--global` install (in `~/.fz/`) works from any project directory. - **Project-local** (default): `./.fz/` — visible only inside the current project. - **Global** (`--global`): `~/.fz/` — visible from every project for the current user. diff --git a/doc/limitations.md b/doc/limitations.md index a81b3e8c..85ad50a3 100644 --- a/doc/limitations.md +++ b/doc/limitations.md @@ -20,9 +20,10 @@ rule, the consequence, and what to do instead. Items are checked against the cod ## Templates and models -- **Default delimiters differ for variables and formulas.** A model without `delim` - (nor `var_delim`) delimits variables with `()` and formulas with `{}`: `${x}` is then - not a variable. The CLI without `--model` uses `delim: "{}"`. Set `delim` explicitly. +- **Default delimiters.** A model without `delim` (nor `var_delim`) accepts both `$(x)` + and `${x}` for variables and uses `@{...}` for formulas; the CLI without `--model` uses + the same default. Setting `"delim": "{}"` or `"()"` restricts variables to one form. + Templates that contain other `${...}` text (shell snippets) should set `delim`. - **No automatic `?var` conversion.** `?var` is a variable only with `"varprefix": "?"`. - A variable absent from `input_variables` and without `~default` is left as-is in the compiled file (no error from `fzc`). @@ -32,10 +33,9 @@ rule, the consequence, and what to do instead. Items are checked against the cod - **`fzr` argument order is `(input_path, input_variables, model, results_dir, calculators, ...)`.** `results_dir` comes *before* `calculators`. A call such as - `fz.fzr("input.txt", variables, model, "sh://bash calc.sh")` puts the calculator URI into - `results_dir` (a directory literally named `sh:/bash calc.sh` is created) and runs with - no calculator, so every case fails. **Always pass `calculators=` and `results_dir=` by - keyword.** + `fz.fzr("input.txt", variables, model, "sh://bash calc.sh")` is refused with a + `ValueError` (a `results_dir` that looks like a URI is rejected). **Always pass + `calculators=` and `results_dir=` by keyword.** - **A DataFrame design must not contain duplicate rows** (`ValueError`): each row is one case. Variables given but absent from the templates only trigger a warning. - **`FZ_*` environment variables are read once, at `import fz`.** Setting @@ -64,10 +64,9 @@ rule, the consequence, and what to do instead. Items are checked against the cod (inline or file) or the short form `'a=1,b=[4,5,6]'`, always crossed as a full factorial; a list of cases requires a pandas DataFrame from Python. - **No `--timeout` flag.** Use the model's `"timeout"` entry or `FZ_RUN_TIMEOUT`. -- **`fz list` / `fzl`** shows calculator aliases by their `uri`, not their file name, and - `--check` reports an alias whose command sits in its `models` map - (`{"uri": "sh://", "models": {...}}`, the layout of installed wrappers) as failed - (`Empty sh:// command`) although `fzr` uses it correctly. Algorithms are not listed. +- **`fz list` / `fzl`** shows calculator aliases by file name with their `uri`; `--check` + validates the command of each entry of an alias's `models` map. Algorithms are not + listed (`fz.list_installed_algorithms()`). - **`fzr` exits with status 1 when no case succeeds**; data goes to stdout, logs and progress to stderr. Use `--format json` for machine-readable output. @@ -85,6 +84,9 @@ rule, the consequence, and what to do instead. Items are checked against the cod attempt moves to another calculator. - **Case `status` values:** `done`, `failed`, `error`, `timeout`, `interrupted`. A cache hit is `done` with a `calculator` value starting with `cache://`. +- **`done` does not mean "outputs found"**: a run that exits normally stays `done` even + when no output can be parsed (by design: the calculation ran); the outputs are `None` + and `error` holds `Missing output: ...`. Filter on the output columns or on `error`. ## Timeouts @@ -92,9 +94,8 @@ rule, the consequence, and what to do instead. Items are checked against the cod `FZ_RUN_TIMEOUT`. - **Default:** 3600 s for `sh://` and `funz://`; **no timeout** for `ssh://` and `slurm://` unless `FZ_RUN_TIMEOUT` is set explicitly (a warning is logged). -- **Only a model `"timeout"` of `null`/`None` or `0` disables the timeout.** - `FZ_RUN_TIMEOUT=0` or `timeout=0` does *not* disable it: every case times out - immediately. +- **`0` means "no timeout"** at every level (`timeout=0`, model `"timeout": 0` or + `null`, `FZ_RUN_TIMEOUT=0`). A negative value is refused. ## `sh://` command line @@ -127,12 +128,11 @@ rule, the consequence, and what to do instead. Items are checked against the cod the input declares variables, even when all values are scalars. Run the code and `fzo` inside that sub-directory (or on the glob `output_dir/*`); `fzo output_dir` itself returns a row of `None`. -- **Global wrapper installs:** `fz install model --global` copies the wrapper to `~/.fz/`, but its calculator - alias keeps the relative command `bash .fz/calculators/.sh`, looked up in the - launch directory, then the case directory, never in `~/.fz/`: runs from any other - directory fail (`Command not found locally: '.fz/calculators/.sh'`). Prefer project-local installs, or edit - `~/.fz/calculators/localhost_.json` to use the absolute path of the script (`~` is - not expanded). +- **Installed calculator aliases**: `.fz/...` paths in an alias (`bash + .fz/calculators/.sh`) are resolved against the `.fz/` directory the alias was loaded + from, so `fz install --global` wrappers work from any directory. +- **Temporary directories**: `.fz/tmp/fz_temp_*` directories are removed after a run + when empty; leftover files are kept for inspection. - **Existing results directories are not overwritten in place**: an existing `results_dir` is renamed with a timestamp suffix before the new run (and can be reused via `cache://`). diff --git a/doc/model-definition.md b/doc/model-definition.md index 6dd2268e..b4e5c1fc 100644 --- a/doc/model-definition.md +++ b/doc/model-definition.md @@ -42,17 +42,18 @@ Every syntax field is optional. The defaults, as applied by the code |--------------------------------------|---------| | `var_prefix`, `varprefix`, `var_char`, `varchar` | `$` | | `formula_prefix`, `formulaprefix`, `form_prefix`, `formprefix`, `formula_char`, `form_char` | `@` | -| `var_delim`, then `delim` (delimiters of **variables**) | **`()`** | +| `var_delim`, then `delim` (delimiters of **variables**) | both `()` and `{}` (`$(x)` and `${x}`) | | `formula_delim`, then `delim` (delimiters of **formulas**) | `{}` | | `commentline`, `comment_line`, `comment_char`, `commentchar`, `comment` | `#` | | `interpreter` | `FZ_INTERPRETER` (default `python`) | -> **Set `delim` explicitly.** Without a `delim` key, `${x}` is **not** a variable (only -> `$x` and `$(x)` are), while formulas use `@{...}`. The CLI used *without* `--model` -> applies `delim: "{}"` instead, so the same template can behave differently from Python -> and from the CLI. `"delim": "{}"` sets both delimiters to braces; `var_delim` / -> `formula_delim` set them separately (the Java-Funz convention is -> `var_delim: "()"`, `formula_delim: "{}"`). +> **Default delimiters.** Without `delim`/`var_delim`, variables may be written `$x`, +> `$(x)` or `${x}` (with or without `~default`), and formulas `@{...}`; the CLI without +> `--model` uses the same default. `"delim": "{}"` (or `"()"`) restricts both variables +> and formulas to that pair; `var_delim` / `formula_delim` set them separately (the +> Java-Funz convention is `var_delim: "()"`, `formula_delim: "{}"`). Set `delim` when +> the code's own syntax contains `${...}` or `$(...)` text that must not be read as +> variables. ## Model Fields diff --git a/doc/syntax-guide.md b/doc/syntax-guide.md index 19f04575..51060f56 100644 --- a/doc/syntax-guide.md +++ b/doc/syntax-guide.md @@ -22,7 +22,7 @@ Pressure: ${pressure} ```python model = { "varprefix": "$", # Variable prefix - "delim": "{}" # Delimiters; if omitted, variables use "()" and formulas "{}" + "delim": "{}" # Delimiters; if omitted, variables accept $(x) and ${x}, formulas use @{...} } ``` @@ -35,8 +35,9 @@ model = { ### Legacy Funz Syntax Compatibility Templates written for the Java Funz framework use `$(var)` for variables and `@{expr}` for -formulas. This is exactly what fz applies when the model has **no `delim` key** -(variables delimited by `()`, formulas by `{}`), or explicitly: +formulas. They work unchanged with a model that has **no `delim` key** (variables then +accept both `$(x)` and `${x}`, formulas use `@{...}`), or with the explicit Java-Funz +delimiters: ```python model = {"var_prefix": "$", "var_delim": "()", "formula_prefix": "@", "formula_delim": "{}"} diff --git a/fz/cli.py b/fz/cli.py index ed8c863d..00398db2 100644 --- a/fz/cli.py +++ b/fz/cli.py @@ -245,7 +245,9 @@ def parse_algorithm_options(opts_str): # definition without --model. _MODEL_FIELD_ARGS = ("varprefix", "formulaprefix", "delim", "commentline", "interpreter") -_DEFAULT_MODEL = {"varprefix": "$", "formulaprefix": "@", "delim": "{}", "commentline": "#"} +# No "delim": variables then accept both $(x) and ${x}, formulas use @{...}, exactly as +# a Python model without "delim" (see fz.interpreter.DEFAULT_VAR_DELIM). +_DEFAULT_MODEL = {"varprefix": "$", "formulaprefix": "@", "commentline": "#"} def _add_input_path_args(parser): @@ -277,7 +279,7 @@ def _add_model_args(parser): parser.add_argument("--varprefix", default=None, help="Variable prefix (default: $)") parser.add_argument("--formulaprefix", default=None, help="Formula prefix (default: @)") parser.add_argument("--delim", default=None, - help="Variable/formula delimiters (default: {})") + help="Variable/formula delimiters, e.g. {} or () (default: variables accept both $(x) and ${x}, formulas use @{...})") parser.add_argument("--commentline", default=None, help="Comment line character (default: #)") parser.add_argument("--interpreter", default=None, @@ -510,6 +512,104 @@ def format_output(data, format_type='markdown'): raise ValueError(f"Unsupported format: {format_type}") +def _print_fzl_result(result, fmt): + """Print an fzl() result in json, table or markdown format (fzl and fz list).""" + if fmt == "json": + print(json.dumps(result, indent=2)) + elif fmt == "table": + # Table format + print("\n=== MODELS ===") + if result["models"]: + for model_name, model_info in result["models"].items(): + # Show check mark or cross + check_mark = "" + if model_info.get("check_status") == "passed": + check_mark = " ✓" + elif model_info.get("check_status") == "failed": + check_mark = " ✗" + + print(f"\nModel: {model_name}{check_mark}") + print(f" Path: {model_info['path']}") + if model_info.get("check_status") == "failed" and model_info.get("check_error"): + print(f" Error: {model_info['check_error']}") + print(f" Supported Calculators: {len(model_info['supported_calculators'])}") + for calc in model_info['supported_calculators']: + print(f" - {calc}") + else: + print("No models found matching pattern.") + + print("\n=== CALCULATORS ===") + if result["calculators"]: + for calc_name, calc_info in result["calculators"].items(): + # Show check mark or cross + check_mark = "" + if calc_info.get("check_status") == "passed": + check_mark = " ✓" + elif calc_info.get("check_status") == "failed": + check_mark = " ✗" + + print(f"\nCalculator: {calc_name}{check_mark}") + if calc_info.get("uri") and calc_info["uri"] != calc_name: + print(f" URI: {calc_info['uri']}") + if calc_info.get("check_status") == "failed" and calc_info.get("check_error"): + print(f" Error: {calc_info['check_error']}") + if calc_info['supports_models'] == "all": + print(f" Supports: All models") + else: + print(f" Supports Models: {', '.join(calc_info['supports_models'])}") + else: + print("No calculators found matching pattern.") + else: + # Markdown format (default) + print("# Models and Calculators\n") + + print("## Models\n") + if result["models"]: + for model_name, model_info in result["models"].items(): + # Show check mark or cross + check_mark = "" + if model_info.get("check_status") == "passed": + check_mark = " ✓" + elif model_info.get("check_status") == "failed": + check_mark = " ✗" + + print(f"### {model_name}{check_mark}") + print(f"- **Path**: `{model_info['path']}`") + if model_info.get("check_status") == "failed" and model_info.get("check_error"): + print(f"- **Error**: {model_info['check_error']}") + print(f"- **Supported Calculators**: {len(model_info['supported_calculators'])}") + if model_info['supported_calculators']: + for calc in model_info['supported_calculators']: + print(f" - `{calc}`") + print() + else: + print("No models found matching pattern.\n") + + print("## Calculators\n") + if result["calculators"]: + for calc_name, calc_info in result["calculators"].items(): + # Show check mark or cross + check_mark = "" + if calc_info.get("check_status") == "passed": + check_mark = " ✓" + elif calc_info.get("check_status") == "failed": + check_mark = " ✗" + + print(f"### `{calc_name}`{check_mark}") + if calc_info.get("uri") and calc_info["uri"] != calc_name: + print(f"- **URI**: `{calc_info['uri']}`") + if calc_info.get("check_status") == "failed" and calc_info.get("check_error"): + print(f"- **Error**: {calc_info['check_error']}") + if calc_info['supports_models'] == "all": + print(f"- **Supports**: All models") + else: + models_list = ', '.join(f"`{m}`" for m in calc_info['supports_models']) + print(f"- **Supports Models**: {models_list}") + print() + else: + print("No calculators found matching pattern.\n") + + def fzl_main(): """Entry point for fzl command""" parser = argparse.ArgumentParser(description="fzl - List installed models and calculators") @@ -531,96 +631,7 @@ def fzl_main(): result = fzl_func(models=args.models, calculators=args.calculators, check=args.check) - if args.format == "json": - print(json.dumps(result, indent=2)) - elif args.format == "table": - # Table format - print("\n=== MODELS ===") - if result["models"]: - for model_name, model_info in result["models"].items(): - # Show check mark or cross - check_mark = "" - if model_info.get("check_status") == "passed": - check_mark = " ✓" - elif model_info.get("check_status") == "failed": - check_mark = " ✗" - - print(f"\nModel: {model_name}{check_mark}") - print(f" Path: {model_info['path']}") - if model_info.get("check_status") == "failed" and model_info.get("check_error"): - print(f" Error: {model_info['check_error']}") - print(f" Supported Calculators: {len(model_info['supported_calculators'])}") - for calc in model_info['supported_calculators']: - print(f" - {calc}") - else: - print("No models found matching pattern.") - - print("\n=== CALCULATORS ===") - if result["calculators"]: - for calc_name, calc_info in result["calculators"].items(): - # Show check mark or cross - check_mark = "" - if calc_info.get("check_status") == "passed": - check_mark = " ✓" - elif calc_info.get("check_status") == "failed": - check_mark = " ✗" - - print(f"\nCalculator: {calc_name}{check_mark}") - if calc_info.get("check_status") == "failed" and calc_info.get("check_error"): - print(f" Error: {calc_info['check_error']}") - if calc_info['supports_models'] == "all": - print(f" Supports: All models") - else: - print(f" Supports Models: {', '.join(calc_info['supports_models'])}") - else: - print("No calculators found matching pattern.") - else: - # Markdown format (default) - print("# Models and Calculators\n") - - print("## Models\n") - if result["models"]: - for model_name, model_info in result["models"].items(): - # Show check mark or cross - check_mark = "" - if model_info.get("check_status") == "passed": - check_mark = " ✓" - elif model_info.get("check_status") == "failed": - check_mark = " ✗" - - print(f"### {model_name}{check_mark}") - print(f"- **Path**: `{model_info['path']}`") - if model_info.get("check_status") == "failed" and model_info.get("check_error"): - print(f"- **Error**: {model_info['check_error']}") - print(f"- **Supported Calculators**: {len(model_info['supported_calculators'])}") - if model_info['supported_calculators']: - for calc in model_info['supported_calculators']: - print(f" - `{calc}`") - print() - else: - print("No models found matching pattern.\n") - - print("## Calculators\n") - if result["calculators"]: - for calc_name, calc_info in result["calculators"].items(): - # Show check mark or cross - check_mark = "" - if calc_info.get("check_status") == "passed": - check_mark = " ✓" - elif calc_info.get("check_status") == "failed": - check_mark = " ✗" - - print(f"### `{calc_name}`{check_mark}") - if calc_info.get("check_status") == "failed" and calc_info.get("check_error"): - print(f"- **Error**: {calc_info['check_error']}") - if calc_info['supports_models'] == "all": - print(f"- **Supports**: All models") - else: - models_list = ', '.join(f"`{m}`" for m in calc_info['supports_models']) - print(f"- **Supports Models**: {models_list}") - print() - else: - print("No calculators found matching pattern.\n") + _print_fzl_result(result, args.format) return 0 except Exception as e: @@ -1053,96 +1064,7 @@ def main(): from fz.core import fzl as fzl_func result = fzl_func(models=args.models, calculators=args.calculators, check=args.check) - if args.format == "json": - print(json.dumps(result, indent=2)) - elif args.format == "table": - # Table format - print("\n=== MODELS ===") - if result["models"]: - for model_name, model_info in result["models"].items(): - # Show check mark or cross - check_mark = "" - if model_info.get("check_status") == "passed": - check_mark = " ✓" - elif model_info.get("check_status") == "failed": - check_mark = " ✗" - - print(f"\nModel: {model_name}{check_mark}") - print(f" Path: {model_info['path']}") - if model_info.get("check_status") == "failed" and model_info.get("check_error"): - print(f" Error: {model_info['check_error']}") - print(f" Supported Calculators: {len(model_info['supported_calculators'])}") - for calc in model_info['supported_calculators']: - print(f" - {calc}") - else: - print("No models found matching pattern.") - - print("\n=== CALCULATORS ===") - if result["calculators"]: - for calc_name, calc_info in result["calculators"].items(): - # Show check mark or cross - check_mark = "" - if calc_info.get("check_status") == "passed": - check_mark = " ✓" - elif calc_info.get("check_status") == "failed": - check_mark = " ✗" - - print(f"\nCalculator: {calc_name}{check_mark}") - if calc_info.get("check_status") == "failed" and calc_info.get("check_error"): - print(f" Error: {calc_info['check_error']}") - if calc_info['supports_models'] == "all": - print(f" Supports: All models") - else: - print(f" Supports Models: {', '.join(calc_info['supports_models'])}") - else: - print("No calculators found matching pattern.") - else: - # Markdown format (default) - print("# Models and Calculators\n") - - print("## Models\n") - if result["models"]: - for model_name, model_info in result["models"].items(): - # Show check mark or cross - check_mark = "" - if model_info.get("check_status") == "passed": - check_mark = " ✓" - elif model_info.get("check_status") == "failed": - check_mark = " ✗" - - print(f"### {model_name}{check_mark}") - print(f"- **Path**: `{model_info['path']}`") - if model_info.get("check_status") == "failed" and model_info.get("check_error"): - print(f"- **Error**: {model_info['check_error']}") - print(f"- **Supported Calculators**: {len(model_info['supported_calculators'])}") - if model_info['supported_calculators']: - for calc in model_info['supported_calculators']: - print(f" - `{calc}`") - print() - else: - print("No models found matching pattern.\n") - - print("## Calculators\n") - if result["calculators"]: - for calc_name, calc_info in result["calculators"].items(): - # Show check mark or cross - check_mark = "" - if calc_info.get("check_status") == "passed": - check_mark = " ✓" - elif calc_info.get("check_status") == "failed": - check_mark = " ✗" - - print(f"### `{calc_name}`{check_mark}") - if calc_info.get("check_status") == "failed" and calc_info.get("check_error"): - print(f"- **Error**: {calc_info['check_error']}") - if calc_info['supports_models'] == "all": - print(f"- **Supports**: All models") - else: - models_list = ', '.join(f"`{m}`" for m in calc_info['supports_models']) - print(f"- **Supports Models**: {models_list}") - print() - else: - print("No calculators found matching pattern.\n") + _print_fzl_result(result, args.format) elif args.command == "uninstall": if args.uninstall_type == "model": diff --git a/fz/core.py b/fz/core.py index abb4163e..a8d5a1aa 100644 --- a/fz/core.py +++ b/fz/core.py @@ -72,6 +72,7 @@ def utf8_open( _resolve_algorithm_options, _resolve_calculators_arg, _calculator_supports_model, + _extract_calculator_uri, run_cases_parallel, compile_to_result_directories, prepare_temp_directories, @@ -104,6 +105,8 @@ def utf8_open( _get_comment_char, _get_var_prefix, _get_formula_prefix, + _delim_pairs, + get_var_delim, ) from .runners import resolve_calculators, resolve_calculators_with_metadata, run_calculation from .algorithms import ( @@ -640,7 +643,9 @@ def fzl(models: str = "*", calculators: str = "*", check: bool = False) -> Dict[ ... }, "calculators": { - "calculator_name_or_uri": { + "alias_name": { # or a URI: the default "sh://" when no alias exists + "path": "path/to/alias.json", + "uri": "sh://", "supports_models": ["model1", "model2", ...] or "all", "check_status": "passed" | "failed" | "not_checked", # if check=True "check_error": "error message" # if check failed @@ -653,129 +658,118 @@ def fzl(models: str = "*", calculators: str = "*", check: bool = False) -> Dict[ >>> result = fzl(models="*", calculators="*") >>> print(result["models"]) >>> result = fzl(models="*", calculators="*", check=True) - >>> print(result["calculators"]["sh://"]["check_status"]) + >>> print(result["calculators"]["localhost_mymodel"]["check_status"]) """ - # Find all matching models - models_list = [] - search_dirs = [Path.cwd() / ".fz" / "models", Path.home() / ".fz" / "models"] - - for model_dir in search_dirs: - if not model_dir.exists() or not model_dir.is_dir(): - continue - - for model_file in model_dir.glob("*.json"): - import fnmatch - model_name = model_file.stem - - # Match against pattern - if not fnmatch.fnmatch(model_name, models): - continue + import fnmatch + def _name_matches(name, pattern): + if fnmatch.fnmatch(name, pattern): + return True + if any(c in pattern for c in "^$+()|\\"): try: - with open(model_file, 'r') as f: - model_data = json.load(f) - - models_list.append({ - "name": model_name, - "path": str(model_file), - "properties": model_data - }) - except (json.JSONDecodeError, IOError) as e: - log_warning(f"Could not load model file {model_file}: {e}") + return re.search(pattern, name) is not None + except re.error: + return False + return False + + def _load_aliases(kind, pattern): + """Alias JSON files of ./.fz/ then ~/.fz/; the project wins on a name clash.""" + found = {} + for alias_dir in (Path.cwd() / ".fz" / kind, Path.home() / ".fz" / kind): + if not alias_dir.is_dir(): continue + for alias_file in sorted(alias_dir.glob("*.json")): + name = alias_file.stem + if name in found or not _name_matches(name, pattern): + continue + try: + with open(alias_file, 'r') as f: + data = json.load(f) + except (json.JSONDecodeError, IOError) as e: + log_warning(f"Could not load {kind[:-1]} file {alias_file}: {e}") + continue + found[name] = {"name": name, "path": str(alias_file), "data": data} + return found - # Find all matching calculators - calc_specs = _resolve_calculators_arg(calculators, model_name=None) + models_found = _load_aliases("models", models) + calcs_found = _load_aliases("calculators", calculators) - # Build result structure - result = { - "models": {}, - "calculators": {} - } + # Calculators given as URIs (not alias names) are listed as such + extra_uris = [] + if "://" in calculators: + extra_uris.append(calculators) + elif not calcs_found and calculators == "*": + extra_uris.append("sh://") # default calculator when no alias is installed - # Process models and find which calculators support them - for model_info in models_list: - model_name = model_info["name"] + def _model_id(info): + props = info["data"] if isinstance(info["data"], dict) else {} + return props.get("id", info["name"]) - # Find calculators that support this model - supported_calcs = [] + def _supports(calc_data, model_id): + return isinstance(calc_data, dict) and _calculator_supports_model(calc_data, model_id) - for calc_spec in calc_specs: - if isinstance(calc_spec, dict): - # Dict calculator - check if it supports this model - if _calculator_supports_model(calc_spec, model_name): - # Extract a displayable name - calc_display = calc_spec.get("uri", calc_spec.get("command", str(calc_spec))) - supported_calcs.append(calc_display) - else: - # String URI - assume it supports all models (we don't have metadata) - supported_calcs.append(calc_spec) + result = {"models": {}, "calculators": {}} + for model_name, info in models_found.items(): + model_id = _model_id(info) + supported = [name for name, c in calcs_found.items() if _supports(c["data"], model_id)] + supported += extra_uris model_result = { - "path": model_info["path"], - "properties": model_info["properties"], - "supported_calculators": supported_calcs + "path": info["path"], + "properties": info["data"], + "supported_calculators": supported, } - - # Add check status if requested if check: - check_status, check_error = _validate_model(model_info["properties"], model_name) - model_result["check_status"] = check_status - if check_error: - model_result["check_error"] = check_error + status, error = _validate_model(info["data"], model_name) + model_result["check_status"] = status + if error: + model_result["check_error"] = error else: model_result["check_status"] = "not_checked" - result["models"][model_name] = model_result - # Process calculators and find which models they support - for calc_spec in calc_specs: - if isinstance(calc_spec, dict): - calc_display = calc_spec.get("uri", calc_spec.get("command", str(calc_spec))) - - # Check which models this calculator supports - if "models" not in calc_spec: - # No models field - supports all models - calc_result = { - "supports_models": "all" - } - else: - # Has models field - check which models it supports - supported_models = [] - for model_info in models_list: - if _calculator_supports_model(calc_spec, model_info["name"]): - supported_models.append(model_info["name"]) - - calc_result = { - "supports_models": supported_models - } - - # Add check status if requested - if check: - check_status, check_error = _validate_calculator(calc_spec, calc_display) - calc_result["check_status"] = check_status - if check_error: - calc_result["check_error"] = check_error - else: - calc_result["check_status"] = "not_checked" - - result["calculators"][calc_display] = calc_result + for calc_name, info in calcs_found.items(): + data = info["data"] + calc_models = data.get("models") if isinstance(data, dict) else None + if calc_models is None: + supports = "all" else: - # String URI - assume it supports all models - calc_result = { - "supports_models": "all" - } - - # Add check status if requested - if check: - check_status, check_error = _validate_calculator(calc_spec, calc_spec) - calc_result["check_status"] = check_status - if check_error: - calc_result["check_error"] = check_error + supports = [m for m, mi in models_found.items() if _supports(data, _model_id(mi))] + calc_result = { + "path": info["path"], + "uri": data.get("uri", data.get("command")) if isinstance(data, dict) else data, + "supports_models": supports, + } + if check: + # An alias whose command lives in its "models" map is checked per model + # (uri + that model's command), not as a bare "sh://" URI. + if isinstance(calc_models, dict) and calc_models: + errors = [] + for model_id in calc_models: + uri = _extract_calculator_uri(data, model_id) + status, error = _validate_calculator(uri, f"{calc_name}[{model_id}]") + if status != "passed": + errors.append(f"{model_id}: {error}") + status, error = ("failed", "; ".join(errors)) if errors else ("passed", None) else: - calc_result["check_status"] = "not_checked" + status, error = _validate_calculator(data, calc_name) + calc_result["check_status"] = status + if error: + calc_result["check_error"] = error + else: + calc_result["check_status"] = "not_checked" + result["calculators"][calc_name] = calc_result - result["calculators"][calc_spec] = calc_result + for uri in extra_uris: + calc_result = {"uri": uri, "supports_models": "all"} + if check: + status, error = _validate_calculator(uri, uri) + calc_result["check_status"] = status + if error: + calc_result["check_error"] = error + else: + calc_result["check_status"] = "not_checked" + result["calculators"][uri] = calc_result return result @@ -821,8 +815,8 @@ def fzi(input_path: str, model: Union[str, Dict], input_static: Optional[List[st # Variable prefix: support multiple aliases varprefix = _get_var_prefix(model) - # Variable delimiters: use var_delim if set, else delim if set, else default to () - var_delim = model.get("var_delim", model.get("delim", "()")) + # Variable delimiters: var_delim, else delim, else both () and {} (DEFAULT_VAR_DELIM) + var_delim = get_var_delim(model) # Formula prefix: support multiple aliases formulaprefix = _get_formula_prefix(model) @@ -912,9 +906,9 @@ def fzi(input_path: str, model: Union[str, Dict], input_static: Optional[List[st clean_expr = formula_expr # Remove variable references with delimiters: $(var) or V(var) - if len(var_delim) == 2: - left_d = re.escape(var_delim[0]) - right_d = re.escape(var_delim[1]) + for pair in _delim_pairs(var_delim): + left_d = re.escape(pair[0]) + right_d = re.escape(pair[1]) var_prefix_esc = re.escape(varprefix) # Pattern: $(...) or V(...) pattern = rf'{var_prefix_esc}{left_d}([a-zA-Z_][a-zA-Z0-9_]*){right_d}' @@ -1403,6 +1397,17 @@ 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__}") + if isinstance(results_dir, str) and re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", results_dir): + raise ValueError( + f"results_dir looks like a calculator URI: {results_dir!r}. The 4th positional " + "argument of fzr() is results_dir; pass the calculator by keyword: " + "fzr(input_path, input_variables, model, calculators=..., results_dir=...)" + ) + if timeout is not None: + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): + raise TypeError(f"timeout must be a number of seconds, got {type(timeout).__name__}") + if timeout < 0: + raise ValueError(f"timeout must be >= 0 seconds (0 = no timeout), got {timeout}") from .helpers import _validate_input_static _validate_input_static(input_static) diff --git a/fz/helpers.py b/fz/helpers.py index fa144691..7949dc9d 100644 --- a/fz/helpers.py +++ b/fz/helpers.py @@ -76,10 +76,24 @@ def fz_temporary_directory(session_cwd=None): try: yield str(temp_dir) finally: - # Skip cleanup of temporary directory to allow inspection of case contents - # if temp_dir.exists(): - # shutil.rmtree(temp_dir) - log_debug(f"🔍 Temporary directory preserved for inspection: {temp_dir}") + # Files left in the temporary directory are kept for inspection, but empty + # directories (the usual case: case contents are moved to the results) are + # removed so that .fz/tmp/ does not accumulate one fz_temp_* per run. + _remove_empty_dirs(temp_dir) + if temp_dir.exists(): + log_debug(f"🔍 Temporary directory preserved for inspection: {temp_dir}") + + +def _remove_empty_dirs(root: Path) -> None: + """Remove root and its sub-directories when they contain no file (best effort).""" + try: + if not root.is_dir() or root.is_symlink(): + return + for child in root.iterdir(): + if child.is_dir() and not child.is_symlink(): + _remove_empty_dirs(child) + root.rmdir() # fails (and is ignored) if anything remains + except OSError: pass @@ -1902,8 +1916,9 @@ def compile_to_result_directories(input_path: str, model: Dict, input_variables: # Variable prefix: use var_prefix if set, else varprefix (old name), else default to "$" varprefix = model.get("var_prefix", model.get("varprefix", "$")) - # Variable delimiters: use var_delim if set, else delim if set, else default to () - delim = model.get("var_delim", model.get("delim", "()")) + # Variable delimiters: var_delim, else delim, else both () and {} (DEFAULT_VAR_DELIM) + from .interpreter import get_var_delim + delim = get_var_delim(model) input_path = Path(input_path) # Determine if input_variables is non-empty @@ -2337,6 +2352,47 @@ def _resolve_calculators_arg(calculators, model_name=None): # Generic item resolution functions (used by calculators, can be reused for other types) # ============================================================================ +def _anchor_fz_paths(calc_data, alias_file): + """ + Make the ".fz/..." paths of a calculator alias absolute, relative to the + directory holding the .fz/ directory the alias was loaded from. + + Installed wrappers ship aliases such as + {"uri": "sh://", "models": {"X": "bash .fz/calculators/X.sh"}}. Left + relative, ".fz/calculators/X.sh" is looked up in the launch directory, so an + alias installed with `fz install --global` (in ~/.fz/) only worked when fz + was launched from the home directory. Only words starting with ".fz/" that + exist under that root are rewritten; anything else is left untouched. + """ + if not isinstance(calc_data, dict): + return calc_data + alias_file = Path(alias_file) + fz_dir = alias_file.parent.parent + if alias_file.parent.name != "calculators" or fz_dir.name != ".fz": + return calc_data + root = fz_dir.parent + + import shlex + + def anchor(text): + if not isinstance(text, str) or ".fz/" not in text: + return text + + def repl(match): + target = root / match.group(0) + return shlex.quote(target.as_posix()) if target.exists() else match.group(0) + + return _re.sub(r"(?]+", repl, text) + + anchored = dict(calc_data) + for key in ("uri", "command", "version_cmd"): + if key in anchored: + anchored[key] = anchor(anchored[key]) + if isinstance(anchored.get("models"), dict): + anchored["models"] = {k: anchor(v) for k, v in anchored["models"].items()} + return anchored + + def find_items_by_pattern(pattern, item_type, model_name=None, use_regex=False): """ Find items (models or calculators) matching a glob or regex pattern. @@ -2396,6 +2452,8 @@ def find_items_by_pattern(pattern, item_type, model_name=None, use_regex=False): try: with open(item_file, 'r') as f: item_data = json.load(f) + if item_type == 'calculators': + item_data = _anchor_fz_paths(item_data, item_file) # For calculators, check model support if item_type == 'calculators' and model_name: @@ -2464,6 +2522,8 @@ def find_items_by_json_file_pattern(pattern, item_type, model_name=None, use_reg try: with open(json_file, 'r') as f: item_data = json.load(f) + if item_type == 'calculators': + item_data = _anchor_fz_paths(item_data, json_file) # Check if it's a valid item definition if not isinstance(item_data, dict): @@ -2505,6 +2565,8 @@ def find_items_by_json_file_pattern(pattern, item_type, model_name=None, use_reg try: with open(json_file, 'r') as f: item_data = json.load(f) + if item_type == 'calculators': + item_data = _anchor_fz_paths(item_data, json_file) # Check if it's a valid item definition if not isinstance(item_data, dict): diff --git a/fz/interpreter.py b/fz/interpreter.py index bab689f9..07991e4e 100755 --- a/fz/interpreter.py +++ b/fz/interpreter.py @@ -9,6 +9,25 @@ from typing import Dict, List, Union, Any, Set, Optional +# Variable delimiters used when a model sets neither "var_delim" nor "delim": +# both $(x) (Java Funz convention) and ${x} are recognized. A delimiter string +# longer than 2 characters is read as consecutive pairs ("(){}" -> "()", "{}"). +DEFAULT_VAR_DELIM = "(){}" + + +def _delim_pairs(delim: str) -> List[str]: + """Split a delimiter string into 2-character pairs ("" -> [], "()" -> ["()"]).""" + if not delim or len(delim) % 2: + return [] + return [delim[i:i + 2] for i in range(0, len(delim), 2)] + + +def get_var_delim(model: Dict) -> str: + """Variable delimiters of a model: var_delim, else delim, else DEFAULT_VAR_DELIM.""" + return model.get("var_delim", model.get("delim", DEFAULT_VAR_DELIM)) + + + def _format_decimal_pattern(value: float, pattern: str) -> str: """ Format a number using a (non-scientific) Java DecimalFormat-like pattern, @@ -205,6 +224,12 @@ def parse_variables_from_content(content: str, varprefix: str = "$", delim: str Returns: Set of variable names found (without default values and metadata) """ + if len(delim) > 2: + found = set() + for pair in _delim_pairs(delim): + found |= parse_variables_from_content(content, varprefix, pair) + return found + variables = set() # Pattern to match variables: varprefix + optional delim + varname + optional default + optional delim @@ -256,6 +281,11 @@ def parse_variable_defaults_from_content(content: str, varprefix: str = "$", """ defaults: Dict[str, Any] = {} + if len(delim) > 2: + for pair in _delim_pairs(delim): + defaults.update(parse_variable_defaults_from_content(content, varprefix, pair)) + return defaults + if len(delim) != 2: return defaults @@ -356,6 +386,11 @@ def replace_variables_in_content(content: str, input_variables: Dict[str, Any], Returns: Content with variables replaced """ + if len(delim) > 2: + for pair in _delim_pairs(delim): + content = replace_variables_in_content(content, input_variables, varprefix, pair) + return content + if len(delim) == 2: left_delim, right_delim = delim[0], delim[1] esc_varprefix = re.escape(varprefix) @@ -686,7 +721,7 @@ def evaluate_single_formula(formula: str, model: Dict, input_variables: Dict, in """ commentline = _get_comment_char(model) varprefix = _get_var_prefix(model) - var_delim = model.get("var_delim", model.get("delim", "()")) + var_delim = get_var_delim(model) # Extract context lines from model if available context_lines = [] @@ -714,10 +749,8 @@ def evaluate_single_formula(formula: str, model: Dict, input_variables: Dict, in # Replace variables in formula using the model's variable prefix # Handle both delimited and non-delimited variables for var, val in input_variables.items(): - if len(var_delim) == 2: + for left_delim, right_delim in _delim_pairs(var_delim): # Try with delimiters first: $(...) or V(...) - left_delim = var_delim[0] - right_delim = var_delim[1] var_pattern_delim = rf'{re.escape(varprefix)}{re.escape(left_delim)}{re.escape(var)}{re.escape(right_delim)}' formula = re.sub(var_pattern_delim, str(val), formula) @@ -779,10 +812,8 @@ def evaluate_single_formula(formula: str, model: Dict, input_variables: Dict, in # Handle both delimited and non-delimited variables r_formula = formula for var in input_variables.keys(): - if len(var_delim) == 2: + for left_delim, right_delim in _delim_pairs(var_delim): # Try with delimiters first - left_delim = var_delim[0] - right_delim = var_delim[1] var_pattern_delim = rf'{re.escape(varprefix)}{re.escape(left_delim)}{re.escape(var)}{re.escape(right_delim)}' r_formula = re.sub(var_pattern_delim, var, r_formula) @@ -833,7 +864,7 @@ def evaluate_formulas(content: str, model: Dict, input_variables: Dict, interpre delim = model.get("formula_delim", model.get("delim", "{}")) commentline = _get_comment_char(model) varprefix = _get_var_prefix(model) - var_delim = model.get("var_delim", model.get("delim", "()")) + var_delim = get_var_delim(model) # Only validate delim if it will be used (when we have delimiters) if len(delim) != 2 and len(delim) != 0: @@ -918,8 +949,8 @@ def replace_formula(match): # Replace variables in formula with their values for var, val in input_variables.items(): - if len(var_delim) == 2: - var_pattern_delim = rf'{re.escape(varprefix)}{re.escape(var_delim[0])}{re.escape(var)}{re.escape(var_delim[1])}' + for pair in _delim_pairs(var_delim): + var_pattern_delim = rf'{re.escape(varprefix)}{re.escape(pair[0])}{re.escape(var)}{re.escape(pair[1])}' formula = re.sub(var_pattern_delim, str(val), formula) var_pattern = rf'{re.escape(varprefix)}{re.escape(var)}\b' formula = re.sub(var_pattern, str(val), formula) @@ -1014,8 +1045,8 @@ def replace_formula(match): # So we just remove the varprefix for R r_formula = formula for var in input_variables.keys(): - if len(var_delim) == 2: - var_pattern_delim = rf'{re.escape(varprefix)}{re.escape(var_delim[0])}{re.escape(var)}{re.escape(var_delim[1])}' + for pair in _delim_pairs(var_delim): + var_pattern_delim = rf'{re.escape(varprefix)}{re.escape(pair[0])}{re.escape(var)}{re.escape(pair[1])}' r_formula = re.sub(var_pattern_delim, var, r_formula) var_pattern = rf'{re.escape(varprefix)}{re.escape(var)}\b' r_formula = re.sub(var_pattern, var, r_formula) diff --git a/fz/runners/manager.py b/fz/runners/manager.py index 7febae5c..ec751a31 100644 --- a/fz/runners/manager.py +++ b/fz/runners/manager.py @@ -234,8 +234,9 @@ def resolve_timeout( Resolve the effective run timeout in seconds. Precedence: explicit `timeout` argument > model's own "timeout" entry > - FZ_RUN_TIMEOUT config default. A model "timeout" of None/null or 0 disables - the timeout for that model (returns None, meaning no timeout). + FZ_RUN_TIMEOUT config default. At every level, 0 means "no timeout" (and so + does a model "timeout" of None/null): returns None. A negative value raises + ValueError. When FZ_RUN_TIMEOUT is not set explicitly, the built-in 3600 s default applies to sh:// and funz:// only; ssh:// and slurm:// default to no timeout (queue @@ -248,13 +249,19 @@ def resolve_timeout( effective = timeout elif isinstance(model, dict) and "timeout" in model: model_timeout = model["timeout"] - effective = None if (model_timeout is None or model_timeout == 0) else int(model_timeout) + effective = None if model_timeout is None else model_timeout else: config = get_config() if scheme in ("ssh", "slurm") and not config.run_timeout_explicit: effective = None else: effective = config.run_timeout + if effective is not None: + effective = int(effective) + if effective < 0: + raise ValueError(f"timeout must be >= 0 seconds (0 = no timeout), got {effective}") + if effective == 0: + effective = None if effective is None: if scheme in ("ssh", "slurm"): with _unlimited_timeout_lock: diff --git a/skills/fz/SKILL.md b/skills/fz/SKILL.md index 69b096a2..c7c93faf 100644 --- a/skills/fz/SKILL.md +++ b/skills/fz/SKILL.md @@ -62,10 +62,9 @@ fz install model modelica # name → https://github.com/Funz/fz-modelica fz list --check --format json # verify what got installed ``` -This drops into the project's `.fz/` directory (`--global` for `~/.fz/` — but then the -calculator alias still runs the relative `bash .fz/calculators/.sh`, never found in -`~/.fz/`, so it fails outside the install directory: prefer project-local installs, or edit the alias to an -absolute script path): +This drops into the project's `.fz/` directory (add `--global` for `~/.fz/`; the alias's +`.fz/...` script path is resolved against the `.fz/` it was loaded from, so a global +install works from any directory): - `.fz/models/.json` — the model definition (variable syntax + output parsers); refer to it by bare alias, e.g. `--model Modelica`. @@ -97,8 +96,8 @@ T_kelvin=@{$T_celsius + 273.15} V_m3=@{L_to_m3($V_L)} ``` -Syntax (with model settings `varprefix="$"`, `formulaprefix="@"`, `delim="{}"`, -`commentline="#"` — set `delim` explicitly, see below): +Syntax (with the default model settings `varprefix="$"`, `formulaprefix="@"`, +`commentline="#"`, no `delim`): - `$name` or `${name}` — a variable to substitute. - `${name~default}` — variable with a default value used when not provided. @@ -106,10 +105,10 @@ Syntax (with model settings `varprefix="$"`, `formulaprefix="@"`, `delim="{}"`, R optional). Formulas may reference variables: `@{$T_celsius + 273.15}`. - Lines starting with `#@` (commentline + formulaprefix) define context for formulas: imports, constants, function definitions. Multi-line functions are supported. -- **Always set `"delim"` in the model.** Without it, variables use `()` (`$x`, `$(x)`) and - `${x}` is NOT recognized, while formulas still use `@{...}` (Java-Funz convention). The - CLI without `--model` applies `delim="{}"`, so results can differ from Python. `?name` - is only a variable with `varprefix="?"` (no automatic conversion). +- Without `"delim"` in the model, variables may be `$x`, `$(x)` or `${x}` and formulas + `@{...}` (CLI and Python alike). Set `"delim"` when the code's own syntax contains + `${...}`/`$(...)` text that must not be read as variables. `?name` is only a variable + with `varprefix="?"` (no automatic conversion). If `$`, `@`, `{}`, or `#` collide with the simulation code's own syntax, change them in the model (e.g. `varprefix="%"`, `commentline="//"`). @@ -222,7 +221,9 @@ fzr --input_path input.txt --model perfectgas \ constrained combinations, or designs imported from CSV). - Returns a DataFrame with one row per case: variable columns, output columns, and metadata columns `status` (`done`/`failed`/`error`/`timeout`/`interrupted`; a cache hit - is `done` with a `cache://...` calculator), `calculator`, `error`, `command`. + is `done` with a `cache://...` calculator; `done` with `None` outputs and + `Missing output: ...` in `error` means the run ended but nothing was parsed), + `calculator`, `error`, `command`. - List-valued outputs (e.g. time series) become list columns — one whole trajectory per row. The Modelica wrapper, for instance, yields `res__time`, `res__T`, … per case; plot directly with @@ -273,11 +274,8 @@ Calculator aliases live in `.fz/calculators/.json` with the command per mo Run `fz list --check --format json` (alias `fzl`) to list and validate installed models/calculators — prefer the `fz ` forms, which survive stale or partially-installed standalone scripts. -`fz list` limitation: calculator aliases are shown by their `uri`, not their file name, -and an alias whose command is in its `models` map (`{"uri": "sh://", "models": {...}}`, -the layout of installed wrappers) is reported `check_status: failed` / -`"Empty sh:// command"` by `--check` although it works. Trust the model's -`check_status` and a real `fzr` run without `--calculators`, not that calculator line. +`fz list` shows calculator aliases by file name with their `uri`, and `--check` validates +each command of an alias's `models` map. ## Design of experiments / optimization (fzd) @@ -360,9 +358,8 @@ read [algorithm-wrapper.md](algorithm-wrapper.md). `FZ_MAX_WORKERS` only caps the worker count; it never adds workers. - `FZ_*` environment variables are read at `import fz`: set them before starting Python, or call `fz.reload_config()` after changing `os.environ`. -- Timeouts: default 3600 s for `sh://`/`funz://`, none for `ssh://`/`slurm://`. To lift it - for one model, set `"timeout": null` in the model; `FZ_RUN_TIMEOUT=0` or `timeout=0` - makes every case time out immediately. +- Timeouts: default 3600 s for `sh://`/`funz://`, none for `ssh://`/`slurm://`. `0` means + no timeout (`timeout=0`, model `"timeout": 0`/`null`, `FZ_RUN_TIMEOUT=0`). - Long studies: run `fzr` in the background, then monitor `results/*/log.txt` and the per-case `out.txt`/`err.txt`; on interrupt, partial results survive and `cache://` resumes. - Full API and CLI details, environment variables, and the model/calculator JSON schemas: @@ -376,9 +373,9 @@ read [algorithm-wrapper.md](algorithm-wrapper.md). | All cases `failed`, `N calculator failures` | The calculator command itself errors — read the case's `err.txt`/`log.txt`. | | Output column is `null` but case is `done` | Output command matched nothing: wrong path/field, missing subdir output (see directory codes), locale (`LC_ALL=C`), or `python` vs `python3`. | | `fzi` lists extra/unexpected variables | `varprefix` collides with the code's own syntax — change it. | -| A directory literally named `sh:/...` appears; all cases fail | Calculator URI passed as 4th positional arg of `fz.fzr` (that slot is `results_dir`) — use `calculators=`. | +| `ValueError: results_dir looks like a calculator URI` | Calculator URI passed as 4th positional arg of `fz.fzr` (that slot is `results_dir`) — use `calculators=`. | | Output column holds the program's stdout instead of a file's content | The code writes a reserved name (`out.txt`, `err.txt`, `log.txt`, ...) that fz overwrites — rename it. | -| Every case `timeout` immediately | `FZ_RUN_TIMEOUT=0` / `timeout=0`: zero is a real limit, not "unlimited". | +| Case `done` but outputs `None`, `error` = `Missing output: ...` | No declared output could be parsed: wrong file name/pattern, output written elsewhere, or a reserved file name. | | Output file holds its content twice / odd arguments | The compiled input names are appended to the end of the `sh://` command line — move the logic into a script. | | `fzd` runs an empty `sh://` / every case fails | fz 1.0 only: `fzd` didn't auto-discover calculators — pass them explicitly, or upgrade to fz ≥ 1.1. | diff --git a/skills/fz/code-wrapper.md b/skills/fz/code-wrapper.md index 853d96a4..c3a56c80 100644 --- a/skills/fz/code-wrapper.md +++ b/skills/fz/code-wrapper.md @@ -157,10 +157,8 @@ worked wrapper of this kind. **Definition of done** — the wrapper is finished only when both hold: -1. `fz list --check --format json` shows the model with `check_status: passed` (the - calculator line shows the alias's `uri`, e.g. `sh://`, and may read - `"Empty sh:// command"` for a `{"uri": "sh://", "models": {...}}` alias — a known - `fz list` limitation, not a wrapper defect); +1. `fz list --check --format json` shows the model and its `localhost_` calculator + alias, both with `check_status: passed`; 2. `fzr --model MyCode ...` **without any `--calculators` argument** runs a case successfully (proves alias discovery works, not just a hand-built `sh://` URI). @@ -170,7 +168,7 @@ From a scratch directory: ```bash fz install model ./fz-mycode.zip # or the repo path / URL -fz list --check --format json # model must pass (see the calculator caveat above) +fz list --check --format json # model + calculator must validate # then the SKILL.md verification ladder on a sample input: fzi --input_path tests/input.txt --model MyCode --format json # variables found? diff --git a/skills/fz/reference.md b/skills/fz/reference.md index 62cefb59..de333836 100644 --- a/skills/fz/reference.md +++ b/skills/fz/reference.md @@ -72,11 +72,12 @@ fz.fzr(input_path: str, - dict `input_variables` ⇒ factorial (Cartesian product); DataFrame ⇒ one case per row. - **Pass `calculators=` and `results_dir=` by keyword**: `results_dir` is the 4th - positional parameter, so `fzr(path, vars, model, "sh://bash run.sh")` silently uses the - URI as a directory name and runs without calculator (every case fails). + positional parameter; `fzr(path, vars, model, "sh://bash run.sh")` raises + `ValueError: results_dir looks like a calculator URI`. - Returns a DataFrame: variable columns + output columns + `status` (`done`, `failed`, `error`, `timeout`, `interrupted`; a cache hit is `done` with a `cache://...` - `calculator`), `calculator`, `error`, `command`. + `calculator`; a run without parsable outputs stays `done` with `Missing output: ...` + in `error`), `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 - unsafe characters in a key/value are percent-encoded @@ -101,8 +102,8 @@ fz.fzr(input_path: str, `on_progress(completed, total, eta_seconds)`, `on_complete(total_cases, completed_cases, results_df)`. Unknown keys raise `ValueError`; callbacks run in worker threads and their exceptions are logged, not raised. -- `timeout` (seconds) overrides the model's `"timeout"` and `FZ_RUN_TIMEOUT`. `0` does - not disable it (every case times out at once); only a model `"timeout": null`/`0` does. +- `timeout` (seconds) overrides the model's `"timeout"` and `FZ_RUN_TIMEOUT`; `0` means + no timeout, negative values raise `ValueError`. - Ctrl+C interrupts gracefully; completed cases stay in `results_dir` and can be reused with a `cache://results_dir` calculator. @@ -157,13 +158,9 @@ fz.fzl(models: str = "*", calculators: str = "*", check: bool = False) -> dict ``` Returns `{"models": {name: {"path", "properties", "supported_calculators", -"check_status"...}}, "calculators": {uri: {"supports_models", "check_status"...}}}`. -Algorithms are not listed (`fz.list_installed_algorithms()`). -`fz list` limitation: calculator aliases are shown by their `uri`, not their file name, -and an alias whose command is in its `models` map (`{"uri": "sh://", "models": {...}}`, -the layout of installed wrappers) is reported `check_status: failed` / -`"Empty sh:// command"` by `--check` although it works. Trust the model's -`check_status` and a real `fzr` run without `--calculators`, not that calculator line. +"check_status"...}}, "calculators": {alias_name: {"path", "uri", "supports_models", +"check_status"...}}}`. With `check=True`, each command of an alias's `models` map is +validated. Algorithms are not listed (`fz.list_installed_algorithms()`). ### Configuration helpers @@ -252,9 +249,9 @@ non-zero on failure, and `fzr` exits 1 when no case reached status `done`. Use ``` All fields optional except `output` (required to parse results). Defaults when absent: -`varprefix` `$`, `formulaprefix` `@`, `commentline` `#`, `interpreter` python, and — the -trap — variable delimiters `()` / formula delimiters `{}` (`var_delim` / `formula_delim` -keys set them separately; `delim` sets both). `id` links the model to +`varprefix` `$`, `formulaprefix` `@`, `commentline` `#`, `interpreter` python; variables +accept both `$(x)` and `${x}`, formulas use `@{...}` (`delim` restricts both to one pair; +`var_delim` / `formula_delim` set them separately). `id` links the model to calculator alias files. Search path for aliases: `./.fz/models/.json` then `~/.fz/models/.json`. `timeout` (int seconds, or `null`/`0` to disable) overrides `FZ_RUN_TIMEOUT` for this model; an explicit `timeout=` argument to `fzr()` still @@ -333,7 +330,7 @@ FZ_MAX_WORKERS cap on parallel cases (never above the number of ca FZ_MAX_RETRIES attempts for failed cases (default 5) FZ_RUN_TIMEOUT per-calculation timeout in seconds (default 3600 = 1h for sh://, funz://; unlimited for ssh://, slurm:// when unset); - a model's own "timeout" entry overrides this; 0 is NOT "unlimited" + a model's own "timeout" entry overrides this; 0 = no timeout FZ_SLURM_POLL_INTERVAL seconds between sacct/squeue polls for slurm-array:// (default 2) FZ_SLURM_ARRAY_WINDOW seconds slurm-array:// gathers cases before one sbatch (default 1) FZ_RO_CRATE 0 to disable the ro-crate-metadata.json written (default 1) next to diff --git a/tests/test_usability_fixes.py b/tests/test_usability_fixes.py new file mode 100644 index 00000000..8a4d466c --- /dev/null +++ b/tests/test_usability_fixes.py @@ -0,0 +1,187 @@ +""" +Regression tests for usability defects found while reviewing the documentation: + +1. timeout=0 / FZ_RUN_TIMEOUT=0 mean "no timeout" (they timed every case out at once); +2. a model without "delim" recognizes both $(x) and ${x} (${x} was silently ignored), + and the CLI without --model uses the same default; +3. fzl lists calculator aliases by name and checks the commands of their "models" map + (installed-wrapper aliases {"uri": "sh://", "models": {...}} were reported failed); +4. ".fz/..." paths of a calculator alias are anchored to the .fz/ it was loaded from + (aliases installed with --global only worked from the home directory); +5. fzr() refuses a results_dir that looks like a calculator URI (4th positional slot); +6. empty .fz/tmp/fz_temp_* directories are removed after a run. + +A case whose outputs are all missing deliberately stays "done" (the calculation ran; +see test_examples_advanced.test_non_numeric_variables); the reason is in "error". + +Each test runs in a fresh temporary directory (autouse fixture in conftest.py). +""" +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +import fz +from fz.runners.manager import resolve_timeout +from fz.helpers import _anchor_fz_paths + +MODEL = { + "delim": "{}", + "output": {"p": "python://grep(r'p = (\\S+)', 'output.txt')"}, +} + + +def _write_case_files(): + Path("input.txt").write_text("x=${x}\n") + Path("calc.sh").write_text('source "$1"\necho "p = $x" > output.txt\n') + + +# 1. timeouts --------------------------------------------------------------- + +def test_zero_timeout_means_unlimited(): + assert resolve_timeout({}, 0) is None + assert resolve_timeout({"timeout": 0}) is None + assert resolve_timeout({"timeout": None}) is None + assert resolve_timeout({}, 5) == 5 + + +def test_zero_run_timeout_env_means_unlimited(monkeypatch): + monkeypatch.setenv("FZ_RUN_TIMEOUT", "0") + fz.reload_config() + try: + assert resolve_timeout({}) is None + assert resolve_timeout({}, scheme="ssh") is None + finally: + monkeypatch.delenv("FZ_RUN_TIMEOUT") + fz.reload_config() + + +def test_negative_timeout_rejected(): + _write_case_files() + with pytest.raises(ValueError, match="timeout"): + fz.fzr("input.txt", {"x": [1]}, MODEL, calculators="sh://bash calc.sh", timeout=-1) + + +def test_fzr_with_zero_timeout_runs(): + _write_case_files() + results = fz.fzr("input.txt", {"x": [1, 2]}, MODEL, + calculators="sh://bash calc.sh", results_dir="results", timeout=0) + assert list(results["status"]) == ["done", "done"] + assert list(results["p"]) == [1, 2] + + +# 2. default delimiters ------------------------------------------------------ + +def test_default_delimiters_accept_both_forms(): + Path("t.txt").write_text("a=$(a)\nb=${b}\nc=${c~7}\nd=$(d~2)\ne=$e\nf=@{$a + ${b}}\n") + found = fz.fzi("t.txt", {}) + assert {"a", "b", "c", "d", "e"} <= set(found) + assert found["c"] == 7 and found["d"] == 2 + + fz.fzc("t.txt", {"a": 1, "b": 2, "e": 5}, {}, "compiled") + (case_dir,) = [p for p in Path("compiled").iterdir() if p.is_dir()] + assert (case_dir / "t.txt").read_text() == "a=1\nb=2\nc=7\nd=2\ne=5\nf=3\n" + + +def test_explicit_delim_still_restricts(): + Path("t.txt").write_text("a=$(a)\nb=${b}\n") + assert set(fz.fzi("t.txt", {"delim": "{}"})) == {"b"} + assert set(fz.fzi("t.txt", {"delim": "()"})) == {"a"} + assert set(fz.fzi("t.txt", {"var_delim": "()"})) == {"a"} + + +def test_cli_default_matches_python_default(): + Path("t.txt").write_text("a=$(a)\nb=${b}\n") + for extra in ([], ["--model", '{"output": {}}']): + out = subprocess.run( + [sys.executable, "-m", "fz.cli", "input", "t.txt", "--format", "json", *extra], + capture_output=True, text=True, check=True, + ).stdout + assert set(json.loads(out)) == {"a", "b"}, extra + + +# 3. fzl ----------------------------------------------------------------------- + +def _install_alias(root: Path, name="pg", script="run.sh"): + (root / ".fz" / "models").mkdir(parents=True, exist_ok=True) + (root / ".fz" / "calculators").mkdir(parents=True, exist_ok=True) + (root / ".fz" / "models" / f"{name}.json").write_text(json.dumps({"id": name, **MODEL})) + (root / ".fz" / "calculators" / script).write_text('source "$1"\necho "p = $x" > output.txt\n') + (root / ".fz" / "calculators" / f"localhost_{name}.json").write_text(json.dumps( + {"uri": "sh://", "models": {name: f"bash .fz/calculators/{script}"}})) + + +def test_fzl_lists_aliases_by_name_and_checks_model_commands(): + _install_alias(Path.cwd()) + result = fz.fzl(check=True) + assert result["models"]["pg"]["supported_calculators"] == ["localhost_pg"] + calc = result["calculators"]["localhost_pg"] + assert calc["uri"] == "sh://" + assert calc["supports_models"] == ["pg"] + assert calc["check_status"] == "passed", calc.get("check_error") + + +def test_fzl_default_calculator_when_no_alias(): + result = fz.fzl() + assert list(result["calculators"]) == ["sh://"] + + +# 4. .fz/ paths anchored to the alias location --------------------------------- + +def test_anchor_fz_paths(tmp_path): + _install_alias(tmp_path) + alias_file = tmp_path / ".fz" / "calculators" / "localhost_pg.json" + data = json.loads(alias_file.read_text()) + anchored = _anchor_fz_paths(data, alias_file) + script = (tmp_path / ".fz" / "calculators" / "run.sh").as_posix() + assert anchored["models"]["pg"] == f"bash {script}" + # Missing targets and files outside a .fz/calculators directory are left alone + data["models"]["pg"] = "bash .fz/calculators/missing.sh" + assert _anchor_fz_paths(data, alias_file)["models"]["pg"] == "bash .fz/calculators/missing.sh" + assert _anchor_fz_paths(data, tmp_path / "other.json") == data + + +def test_alias_found_in_home_runs_from_elsewhere(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + _install_alias(home) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + _write_case_files() + model = json.loads((home / ".fz" / "models" / "pg.json").read_text()) + results = fz.fzr("input.txt", {"x": [3]}, model, results_dir="results") + assert list(results["status"]) == ["done"], list(results["error"]) + assert list(results["p"]) == [3] + + +# 5. results_dir that looks like a URI ----------------------------------------- + +def test_calculator_passed_positionally_is_rejected(): + _write_case_files() + with pytest.raises(ValueError, match="calculators="): + fz.fzr("input.txt", {"x": [1]}, MODEL, "sh://bash calc.sh") + assert not any(p.name.startswith("sh:") for p in Path.cwd().iterdir()) + + +# Missing outputs are reported in "error" (status stays "done") ------------------- + +def test_case_without_any_output_reports_missing_output(): + _write_case_files() + Path("noop.sh").write_text("true\n") + results = fz.fzr("input.txt", {"x": [1]}, MODEL, + calculators="sh://bash noop.sh", results_dir="results") + assert list(results["status"]) == ["done"] + assert "Missing output" in results["error"][0] + + +# 6. temporary directories --------------------------------------------------------- + +def test_no_empty_temp_directories_left(): + _write_case_files() + fz.fzr("input.txt", {"x": [1, 2]}, MODEL, calculators="sh://bash calc.sh", results_dir="results") + fz.fzc("input.txt", {"x": 1}, MODEL, "compiled") + tmp = Path(".fz") / "tmp" + leftovers = [p for p in tmp.iterdir()] if tmp.exists() else [] + assert leftovers == []