Inspecting Runs from the Registry

Query, view, export, and diff stored configs and josh sources

Introduction

Every run registered in a joshpy registry stores its rendered config (.jshc) and josh source (.josh) content. The joshpy.inspect module lets you view, export, and diff these stored files – even after the original files have been modified or deleted.

This is useful for:

  • Discovering what’s in a registry: List labels, sessions, and run details from the terminal
  • Comparing runs: See exactly what changed between two parameter configurations
  • Debugging failures: Inspect the config and model source for a failed run
  • Auditing: Verify what was actually run, not what’s currently on disk

Setup

from joshpy.registry import RunRegistry
from joshpy.jobs import JobConfig

registry = RunRegistry(":memory:")
session_id = registry.create_session(
    config=JobConfig(simulation="Main"),
    experiment_name="inspect_demo",
)

# Register two runs with different configs and the same josh source
josh_source = """\
start simulation Main
  grid.size = 30 m
  grid.low = 33.9 degrees latitude, -116.05 degrees longitude
  grid.high = 33.91 degrees latitude, -116.04 degrees longitude
  steps.low = 0 count
  steps.high = 86 count
end simulation

start patch Default
  Tree.init = create 10 count of Tree
  export.treeCount.step = count(Tree)
  export.averageHeight.step = mean(Tree.height)
end patch

start organism Tree
  age.init = 0 years
  age.step = prior.age + 1 year
  height.init = 0 meters
  height.step = prior.height + sample uniform from 0 meters to config sweep_config.maxGrowth
end organism
"""

registry.register_run(
    session_id=session_id,
    run_hash="abc123def456",
    josh_path="model.josh",
    josh_content=josh_source,
    config_content="maxGrowth = 5 meters\nfireYear = 75 count",
    file_mappings={
        "soil_quality": {"path": "data/soil_quality.jshd", "hash": "a1b2c3"},
    },
    parameters={"maxGrowth": 5, "fireYear": 75},
)
registry.label_run("abc123def456", "low_growth")

registry.register_run(
    session_id=session_id,
    run_hash="fed987cba654",
    josh_path="model.josh",
    josh_content=josh_source,
    config_content="maxGrowth = 15 meters\nfireYear = 75 count",
    file_mappings={
        "soil_quality": {"path": "data/soil_quality.jshd", "hash": "a1b2c3"},
    },
    parameters={"maxGrowth": 15, "fireYear": 75},
)
registry.label_run("fed987cba654", "high_growth")

# Record completed runs (3 replicates each)
for run_hash in ["abc123def456", "fed987cba654"]:
    for rep in range(3):
        run_id = registry.start_run(
            run_hash=run_hash,
            session_id=session_id,
            replicate=rep,
            output_path=f"output/{run_hash}/rep{rep}.csv",
        )
        registry.complete_run(run_id, exit_code=0)
registry.update_session_status(session_id, "completed")

print(f"Registered 2 runs: low_growth, high_growth")
Registered 2 runs: low_growth, high_growth

Querying the Registry

Before viewing or diffing runs, you often need to know what’s in the registry. The registry exposes four describe_*() methods that return human-readable summaries – the same output the CLI produces, available directly on the registry object so you don’t have to import anything.

Note

Each describe_*() method is a thin wrapper around the matching joshpy.inspect.format_*() function (describe_labelsformat_labels, and so on). Use whichever reads better in your code; the free functions remain available if you prefer to import them.

Listing Labels

Labels are the human-readable names assigned to run configurations. Use describe_labels() to see what’s available:

print(registry.describe_labels())
LABEL        RUN_HASH      REPS  CREATED            
high_growth  fed987cba654  3     2026-06-17 04:57:08
low_growth   abc123def456  3     2026-06-17 04:57:08

Listing Sessions

A session groups the configs from a single sweep. Use describe_sessions() to see all sessions with their experiment name, status, and run counts:

print(registry.describe_sessions())
SESSION      EXPERIMENT    STATUS     JOBS  RUNS (ok/fail/pend)  CREATED            
078a9501...  inspect_demo  completed  2     6/0/0                2026-06-17 04:57:08

Run Details

Use describe_run() to see full details for a specific run – parameters, data files, and replicate results:

print(registry.describe_run("low_growth"))
Run: low_growth (abc123def456)
==============================
Session:  078a9501-9a68-4864-bdd8-b8215b59a5e7
Josh:     model.josh
Created:  2026-06-17 04:57:08

Parameters:
  fireYear = 75.0
  maxGrowth = 5.0

Data files:
  soil_quality: data/soil_quality.jshd

Consistency:
  [warning] ran_not_ingested: run_hash abc123def456 has a succeeded run but no ingested cell_data (run ahead of analysis — ingest results).

Runs: 3 succeeded, 0 failed, 0 pending
REP  EXIT  STARTED              OUTPUT                      
0    0     2026-06-17 04:57:08  output/abc123def456/rep0.csv
1    0     2026-06-17 04:57:08  output/abc123def456/rep1.csv
2    0     2026-06-17 04:57:08  output/abc123def456/rep2.csv

When you need the underlying data rather than a formatted string – for example to drive your own logic or reporting – use get_run_info(), which returns a structured RunDetail (the data-layer counterpart to describe_run()):

detail = registry.get_run_info("low_growth")

print("hash:      ", detail.run_hash)
hash:       abc123def456
print("label:     ", detail.label)
label:      low_growth
print("parameters:", detail.parameters)
parameters: {'fireYear': 75.0, 'maxGrowth': 5.0}
print("replicates:", detail.replicate_count)
replicates: 0
print("status:    ", detail.succeeded, "ok /", detail.failed, "failed /", detail.pending, "pending")
status:     3 ok / 0 failed / 0 pending

detail.config is the full ConfigInfo and detail.runs is the list of recorded RunInfo executions, so you can inspect everything programmatically.

Registry Summary

Use describe_summary() for a high-level overview of everything in the registry:

print(registry.describe_summary())
Registry Data Summary
========================================
Sessions: 1
Configs:  2
Runs:     6
Rows:     0

Variables: (none)
Entity types: (none)
Parameters: fireYear, maxGrowth

From the Command Line

All four queries are available as CLI flags, so you can inspect a registry without writing any Python:

# List all labeled runs
python -m joshpy.inspect experiment.duckdb --labels

# List sessions with status and run counts
python -m joshpy.inspect experiment.duckdb --sessions

# Show full details for a run (by label or hash)
python -m joshpy.inspect experiment.duckdb --info low_growth

# Print a data summary for the whole registry
python -m joshpy.inspect experiment.duckdb --summary
Tip

Use --labels first to discover what labels are available, then --info or --diff to dig deeper.

Note

For debug logs tied to a registered run, use the debug CLI in registry mode:

python -m joshpy.debug --registry experiment.duckdb --label low_growth --summary

This avoids manual debug file path lookup.

Viewing Configs

Python API

Use registry.export_config() to export a run’s stored config to a file:

import tempfile

tmpdir = tempfile.mkdtemp()
path = registry.export_config("low_growth", tmpdir)
print(path.read_text())
# READ-ONLY snapshot exported from registry
# Run: abc123def456
# Editing this file has no effect. To change parameters,
# edit your source .jshc file and re-run.

maxGrowth = 5 meters
fireYear = 75 count

Or use view_config() to get the content as a string without writing a file:

from joshpy.inspect import view_config

content = view_config(registry, "high_growth")
print(content)
maxGrowth = 15 meters
fireYear = 75 count

Diffing Two Configs

Export both configs and compare them side by side:

from joshpy.inspect import export_pair

path1, path2 = export_pair(registry, "low_growth", "high_growth", tmpdir)
print(f"--- {path1.name} ---")
--- low_growth.jshc ---
print(path1.read_text())
# READ-ONLY snapshot exported from registry
# Run: abc123def456
# Editing this file has no effect. To change parameters,
# edit your source .jshc file and re-run.

maxGrowth = 5 meters
fireYear = 75 count
print(f"--- {path2.name} ---")
--- high_growth.jshc ---
print(path2.read_text())
# READ-ONLY snapshot exported from registry
# Run: fed987cba654
# Editing this file has no effect. To change parameters,
# edit your source .jshc file and re-run.

maxGrowth = 15 meters
fireYear = 75 count

For a quick textual comparison that works anywhere – terminal, notebook, CI, or over SSH – use text_diff, which returns a unified diff of the two stored configs:

from joshpy.inspect import text_diff

print(text_diff(registry, "low_growth", "high_growth"), end="")
--- low_growth
+++ high_growth
@@ -1,2 +1,2 @@
-maxGrowth = 5 meters
+maxGrowth = 15 meters
 fireYear = 75 count

To open an interactive side-by-side diff in VS Code or Cursor instead:

# Opens side-by-side diff in VS Code
registry.compare_configs("low_growth", "high_growth")

# Or with Cursor
registry.compare_configs("low_growth", "high_growth", ide="cursor")

Viewing Josh Sources

The same workflow applies to the stored josh simulation source. This is especially useful when the original .josh file was rendered from a .josh.j2 template – the temp file is deleted after the sweep, but the content lives on in the registry.

from joshpy.inspect import view_josh

source = view_josh(registry, "low_growth")
print(source[:200], "...")
start simulation Main
  grid.size = 30 m
  grid.low = 33.9 degrees latitude, -116.05 degrees longitude
  grid.high = 33.91 degrees latitude, -116.04 degrees longitude
  steps.low = 0 count
  steps.hig ...

Diffing Josh Sources

# Opens side-by-side diff of josh sources in VS Code
registry.compare_josh("low_growth", "high_growth")

For a headless textual comparison, use text_josh_diff (the josh-source counterpart to text_diff):

from joshpy.inspect import text_josh_diff

print(text_josh_diff(registry, "low_growth", "high_growth"), end="")
Note

If both runs used the same josh source (common when only config parameters vary), the diff will show identical files. This confirms that only the config changed between runs.

Command-Line Interface

The python -m joshpy.inspect CLI provides the same functionality from the terminal, without writing Python.

View

# Print stored config to stdout
python -m joshpy.inspect experiment.duckdb --view low_growth

# Print stored josh source
python -m joshpy.inspect experiment.duckdb --view low_growth --type josh

Diff

# Open config diff in VS Code
python -m joshpy.inspect experiment.duckdb --diff low_growth high_growth

# Open josh source diff in Cursor
python -m joshpy.inspect experiment.duckdb --diff low_growth high_growth --type josh --ide cursor

To print a unified text diff to stdout instead of launching an IDE – useful on a headless machine, in CI, or over SSH – add --print:

# Print a config diff to the terminal (no IDE required)
python -m joshpy.inspect experiment.duckdb --diff low_growth high_growth --print

# Print a josh source diff
python -m joshpy.inspect experiment.duckdb --diff low_growth high_growth --type josh --print

Export Only

Use --export-only to write files without opening an IDE:

# Export configs to a directory
python -m joshpy.inspect experiment.duckdb --diff low_growth high_growth --export-only --output-dir ./exports

# Export josh sources
python -m joshpy.inspect experiment.duckdb --diff low_growth high_growth --type josh --export-only

CLI Reference

Flag Description
--labels List all labeled runs with hash and creation time
--sessions List sessions with experiment name, status, and run counts
--info RUN Show detailed info for a run (parameters, data files, results)
--summary Print a data summary for the entire registry
--view RUN View a single run’s stored content (label or hash)
--diff RUN1 RUN2 Diff two runs
--type {config,josh} What to view/diff (default: config)
--ide {vscode,cursor} IDE for viewing/diffing (default: vscode)
--print Print a unified text diff to stdout (works headless; --diff only)
--export-only Write files without opening IDE
--output-dir DIR Directory for exported files (default: system temp)

Smart File Resolution

When you open a view or diff, the inspect module checks whether the original file still exists on disk:

Situation Behavior
Original file exists, content matches Opens the real file (editable)
Original file exists, content has drifted Opens a read-only snapshot with a note
Original file is gone Opens a read-only snapshot from registry

This means open_view() and compare_configs() do the right thing automatically – you get the real file when possible, a faithful snapshot when not.

File Mappings

The registry also stores which .jshd data files were used for each run. Use get_file_mappings() to recall them:

# Register a run with file mappings
from pathlib import Path

registry.register_run(
    session_id=session_id,
    run_hash="with_data_hash",
    josh_path="model.josh",
    josh_content=josh_source,
    config_content="maxGrowth = 10 meters",
    file_mappings={
        "soil_quality": {"path": "/data/soil_quality_gradient.jshd", "hash": "abc123"},
        "climate": {"path": "/data/climate_ssp245.jshd", "hash": "def456"},
    },
    parameters={"maxGrowth": 10},
)
registry.label_run("with_data_hash", "with_data")

mappings = registry.get_file_mappings("with_data")
for name, path in mappings.items():
    print(f"  {name}: {path}")
  soil_quality: /data/soil_quality_gradient.jshd
  climate: /data/climate_ssp245.jshd

Re-running, Pooling, and Idempotency

The registry treats a run’s replicate index as a collision-avoidance tag, not a meaningful coordinate — what matters is how many replicates you have. This makes re-running safe and predictable:

  • Ingestion is idempotent. Re-ingesting outputs already loaded is a no-op; a replicate index already in the registry is skipped rather than duplicated.
  • get_replicate_count() counts distinct replicate indices — the source of truth for “how many do I have”. loaded_replicates() returns the actual set.
# Normally ingest() loads these; insert a few directly for illustration.
run_id = registry.get_runs_for_hash("abc123def456")[0].run_id
for rep in (0, 1, 2):
    registry.conn.execute(
        "INSERT INTO cell_data (run_id, run_hash, step, replicate) VALUES (?, ?, ?, ?)",
        [run_id, "abc123def456", 0, rep],
    )
<_duckdb.DuckDBPyConnection object at 0x7f005850edf0>
<_duckdb.DuckDBPyConnection object at 0x7f005850edf0>
<_duckdb.DuckDBPyConnection object at 0x7f005850edf0>
print("replicates present:", sorted(registry.loaded_replicates("low_growth")))
replicates present: []
print("replicate count:   ", registry.get_replicate_count("low_growth"))
replicate count:    0

Pooling backfills to a dense replicate set. With collision_policy="pool", re-running dispatches exactly the missing indices in 0..target-1 — a lost or never-run replicate is refilled at its own index, so the set stays dense (target replicates means indices 0..target-1). Re-running at the same target is a no-op; a larger target fills in the new indices. This works on local, Josh Cloud, and batch-remote alike.

Parity guard. check_consistency() surfaces run↔︎analysis drift — duplicate replicates, data without a config, or runs that succeeded but were never ingested. It runs automatically after ingest(); you can also call it directly:

# high_growth has completed runs but no ingested data yet.
for issue in registry.check_consistency():
    print(f"[{issue.severity}] {issue.kind}: {issue.detail}")
[warning] ran_not_ingested: run_hash fed987cba654 has a succeeded run but no ingested cell_data (run ahead of analysis — ingest results).

drop_run() is the one sanctioned way to mutate run data. Every other registry write is append-only or metadata. Use it to clear a run before redoing a config from scratch (rather than pooling more replicates onto bad outputs):

summary = registry.drop_run("high_growth")
print(f"Dropped {summary.run_hash}: {summary.total} rows "
      f"across {len(summary.rows)} tables")
Dropped fed987cba654: 6 rows across 6 tables

Cleanup

registry.close()

Learn More