The floor for a one-off run: a script, a CSV, and your own analysis tools – no registry, no linking between runs
Overview
This workflow uses as little of joshpy as possible – just enough to safely and cleanly pass your variables and configuration to Josh, run it, and get a CSV back. joshpy has more to offer – registries, labels, sweeps, diagnostics – covered in the tutorials linked throughout this one, but this is a good place to start if that’s more than you need right now.
Concretely, that means no RunRegistry, no sessions, no SimulationDiagnostics – just the primitives below. What’s Next at the end covers what starts to matter once a project grows past a single run.
NoteThis is not the only “low-level” tutorial
Low-Level Components is also about using joshpy’s pieces directly rather than a builder – but it still wires up RunRegistry, sessions, and recover_sweep_results() by hand. This tutorial goes a step further and skips the registry entirely. Use this one for no tracking at all; use Low-Level Components when tracking is wanted but with visibility into (or control over) every step that produces it.
For comparing two runs, see Single Run & Iteration – the same “run, look, tweak” spirit, but with a registry so the two runs stay linked. For a known parameter space to sweep, see SweepManager Workflow.
Prerequisites
This workflow doesn’t need the [registry] extra (no DuckDB required):
pip install -e'.[jobs]'
The Minimal Primitives
In increasing order of what gets opted into – each one is optional, and each adds exactly one thing:
Primitive
Adds
Cost
JoshCLI + RunConfig
Runs the JAR. The floor – nothing below this.
Output paths and run identity are tracked by hand.
JobConfig + JobExpander
A deterministic run_hash (content hash of the .josh source, rendered config, and any data files)
Nothing – this is collision safety, not tracking
run_single()
Runs one job, writes a provenance receipt (rendered config, resolved inputs, exact JAR command, exit code), and resolves the CSV path – one call
One archive on disk per run
RunRegistry never appears in this list. Nothing here remembers a run once the process exits, beyond what’s sitting in the receipt archive and the CSV itself. That’s the trade this workflow makes for zero setup: fast to start, with no record of what ran last week beyond what’s still on disk.
run_hash is an important abstraction in joshpy: it provides a deterministic 12-character “fingerprint” of exactly what was run, computed from the .josh source, the rendered config, and any input data file hashes. Same inputs always produce the same hash; any change to the source, config values, or data files produces a different one. Because it is a fingerprint rather than an arbitrary ID, it is safe to build filenames and receipt names from. See Best Practices: The run_hash Connection for the full mechanics, including how it is used as the primary key when a registry is in play.
The floor, for reference
Without even a run_hash or a receipt – say, a hand-written .jshc and a naming convention already in place – JoshCLI and RunConfig alone are enough:
Where the CSV lands is controlled by exportFiles.patch inside model.josh itself, not by anything passed to RunConfig – that’s the JAR’s own export mechanism, and it’s what the rest of this tutorial relies on. (RunConfig also has an output/output_format pair, but that’s a separate, optional flag for writing one consolidated output file; most models, including the one below, don’t need it.)
One subprocess call, one CSV. Nothing computes a hash, nothing checks whether the output path already exists from a previous run. The rest of this tutorial adds exactly enough to close that second gap, without adding tracking.
The Example Model
Same tree-growth model used in Single Run & Iteration, with one change: exportFiles.patch is keyed by {run_hash} instead of a parameter value, so two runs – even with identical parameters – never collide on the same output file:
from pathlib import PathSOURCE_PATH = Path("../../examples/minimal_run.josh")print(SOURCE_PATH.read_text())
# Minimal one-off run simulation - optimized for fast documentation builds
# Identical to tutorial_sweep.josh, except the export path is keyed by
# {run_hash} instead of {maxGrowth}: independent one-off runs (including two
# with the same parameter values) each get their own output file, with no
# registry required to keep them apart.
start simulation Main
grid.size = 5000 m
grid.low = 33.7 degrees latitude, -115.4 degrees longitude
grid.high = 34.0 degrees latitude, -116.4 degrees longitude
grid.patch = "Default"
steps.low = 0 count
steps.high = 10 count
exportFiles.patch = "file:///tmp/minimal_run_{run_hash}_{replicate}.csv"
end simulation
start patch Default
ForeverTree.init = create 10 count of ForeverTree
export.averageAge.step = mean(ForeverTree.age)
export.averageHeight.step = mean(ForeverTree.height)
end patch
start organism ForeverTree
initialTreeCount.init = config sweep_config.initialTreeCount
maxGrowth.init = config sweep_config.maxGrowth
age.init = 0 year
age.step = prior.age + 1 year
height.init = 0 meters
height.step = prior.height + sample uniform from 0 meters to maxGrowth
end organism
start unit year
alias years
alias yr
alias yrs
end unit
# Baseline configuration for iteration tutorial
# All parameters are auto-parsed by joshpy
initialTreeCount = 10 count
maxGrowth = 50 meters
Naming Outputs by Parameter
It’s common to template exportFiles.patch with a config variable so filenames are self-describing at a glance – tutorial_sweep.josh, used in the other tutorials, does exactly this:
Readable, but on its own it has the same collision risk this tutorial opened with: two runs sharing a maxGrowth value overwrite each other. Pairing the parameter with {run_hash} keeps both the readability and the safety:
minimal_run.josh, used throughout this tutorial, skips the parameter and keys on {run_hash} alone – safe, but the filename doesn’t say what maxGrowth was at a glance. That’s fine: the receipt already knows, as Step 3 below shows.
Step 1: Run It
run_single() wraps the sequence above – JobConfig for a run_hash, JobExpander, a receipted run_sweep() call, and resolving the export path – into one call, with no registry or session_id involved:
run_single() raises if the run fails, so a successful return means a CSV is waiting. It requires config to expand to exactly one job – a sweep= with more than one combination raises instead, pointing toward run_sweep() or SweepManager.
Step 2: Hand It Off
This is where joshpy’s job ends. Read the CSV with whatever’s already in use – here’s pandas, purely to confirm it’s a real file, not because pandas is required:
import pandas as pddf = pd.read_csv(run.csv_paths[0])df.head()
The same file opens in Excel or Stata just as well.
Step 3: The Receipt
run_single() also writes a receipt – a self-contained archive holding the exact command joshpy ran, alongside the rendered config and resolved inputs:
import tarfileimport jsonwith tarfile.open(run.bottle_path, "r:gz") as tar:for member in tar.getmembers():if member.name.endswith("manifest.json"): manifest = json.loads(tar.extractfile(member).read())breakprint(f"command: {' '.join(manifest['command'])}")
command: java -jar /workspaces/joshpy/jar/joshsim-fat-dev.jar run /workspaces/joshpy/examples/minimal_run.josh Main --data sweep_config.jshc=/tmp/josh_sweep_zns7v3cn/job_0000_f7a616e80a45/sweep_config.jshc --custom-tag initialTreeCount=10 --custom-tag maxGrowth=50 --custom-tag run_hash=f7a616e80a45
print(f"exit_code: {manifest['exit_code']}")
exit_code: 0
That command field isn’t a reconstruction after the fact – JoshCLI builds one argument list and hands the same list to both subprocess and the CLIResult that becomes this manifest, so it can’t drift from what actually ran. If a result looks wrong later, this is the first thing to check: whether joshpy handed the JAR what was expected (parameters, data files, flags) – a question that’s independent of whether the simulation’s output looks right. See Bottling Runs for Reproducibility for what else a receipt/bottle can do, including re-running it with run.sh on a machine with no Python at all.
The manifest also records job.parameters – which is what makes the “Naming Outputs by Parameter” tradeoff above a non-issue in practice. run.csv_paths[0]’s filename carries only run_hash, but the receipt still knows what maxGrowth was:
run_hash: 2630dd037859 (different from f7a616e80a45)
print(f"csv: {run2.csv_paths[0]}")
csv: /tmp/minimal_run_2630dd037859_0.csv
print(run2.csv_paths[0] != run.csv_paths[0])
True
Because the export path is keyed by {run_hash}, the second CSV landed next to the first without overwriting it – a structural guarantee, not something either run had to know about the other.
TipThe Minimal Contract
Every run_single() call gives you the same three things, independent of any other run: a run_hash, a CSV, and a receipt. That’s the whole contract, and it holds no matter how many times it’s called.
What it doesn’t do is keep track for you. If enough permutations pile up that matching a run_hash back to what it was for starts to feel tedious, that’s the signal to move on – see What’s Next below.
What’s Next?
This workflow scales down to zero setup and stays that way – there’s nothing to migrate away from if a project never needs more than this. But a few things it deliberately doesn’t do tend to become friction once a project grows past “run it once and look”:
Rerunning with a tweaked parameter and losing track of which CSV came from which config.run_hash-keyed filenames prevent overwrites, but they don’t say what changed between two runs at a glance. Single Run & Iteration adds labels and supersession, so a rerun can explicitly replace the one before it while the history stays queryable.
Asking a question across several runs – how a parameter affects an output across everything run this week – without reconstructing it from a pile of CSVs by hand.Low-Level Components or SweepManager Workflow put runs in a queryable registry instead.
Bottling specifically to file a bug report or archive a run for the long term, rather than as a byproduct of every run. Bottling Runs for Reproducibility covers the rest of what bottles can do – bottle="receipt" used here is one mode of that same feature.