TAI BUI
Back to curriculum
CapstonePython

Experiment Runner

A research loop is only as honest as its measurements. Build the runner that takes a spec, executes it in a sandboxed subprocess, and emits a json metrics blob the evaluator can trust.

TIME: ~90 minutesPREREQUISITES: Phase 19 Track A lessons 20-29

Learning Objectives

  • Encode an experiment as a typed spec the runner can serialise to a subprocess.
  • Launch a subprocess with a hard wall clock timeout and a soft memory cap, and surface both as terminal conditions.
  • Capture stdout, stderr, and the structured metrics blob into a single result record.
  • Build an ablation table that sweeps one configuration knob at a time over a fixed base spec.
  • Keep every result deterministic given a seed so the evaluator sees the same numbers across runs.

Why a subprocess

A subprocess is a separate operating system process that the runner launches and watches. It has its own address space, and the parent holds a signal handle it can use to stop it.

A research loop runs untrusted code. The hypothesis came from a sampler and the experiment script came from the same path, so running either in-process risks a crash that takes the orchestrator down with it. A subprocess is the simplest isolation the language ships.

The runner here does not implement full sandboxing. There is no cgroup, no seccomp filter, no namespace remapping. What it has is a wall clock timeout, a polling loop for memory growth, and a kill path that terminates the process when either limit trips. That is the runtime contract every more elaborate sandbox extends, and I kept it small enough to read in one sitting.

Check: Why a subprocess0/3
1. What is a subprocess, as the runner uses the term?Define
2. Why does the runner execute the experiment script in a subprocess instead of inside the orchestrator process?Explain
3. You must run code you do not trust at all, and it may try to read other files or open network connections. What does this runner give you, and what is missing?Design

The ExperimentSpec shape

text
ExperimentSpec
  spec_id        : str            (stable id, "exp_001")
  hypothesis_id  : int            (link back to the queue from lesson 50)
  script_path    : str            (path to the python script to run)
  config         : dict           (written to a temp config.json; its path is argv[1])
  seed           : int            (deterministic seed for the experiment)
  wall_timeout_s : float          (hard timeout, killed on exceed)
  memory_cap_mb  : int            (soft cap, polled; killed on exceed)
  metric_keys    : list[str]      (which fields the evaluator will read)

The script lives on disk, and the runner writes the config to a temp file and passes that path to the script as argv[1]. The script is expected to print a json line on stdout whose keys are a superset of metric_keys. It may print several such lines, and the last one is final. Anything else on stdout is captured, but the metrics parser ignores it.

Check: The ExperimentSpec shape0/3
1. What is an `ExperimentSpec`?Define
2. What are `metric_keys` in a spec?Define
3. How does the spec's `config` reach the experiment script running in the child process?Explain

Architecture

Mermaid diagram

The runner is one class with one main method. The poller is a small thread that wakes once per poll interval and reads the subprocess resident set size from /proc/<pid>/status, falling back to ps -o rss=. If both fail, the poller logs a warning (once per process) and exits.

Why a soft memory cap

A soft memory cap is a limit the runner enforces by polling the subprocess's resident set size, not through the operating system. Hard memory caps need resource.setrlimit and only work on POSIX, so I went with a portable approach: read the resident set size from the platform and kill the subprocess if it exceeds the cap.

The cap is soft because the poller has a non zero interval. A process can spike above the cap between polls and then drop back. The runner records the maximum observed RSS, so the evaluator can see how close the run came to the limit.

On systems where neither /proc nor ps can report RSS, the poller logs a one time warning (once per process) and exits. The wall clock timeout still applies.

Check: Why a soft memory cap0/5
1. What is a soft memory cap?Define
2. Why is the memory cap described as soft?Explain
3. Neither `/proc/<pid>/status` nor `ps -o rss=` can report RSS on a host. What happens when the runner launches an experiment there?Apply
4. With a 512 MB cap and a 50 ms poll interval, a script allocates 800 MB for 10 ms between two polls, frees it, then prints its metrics and exits 0. What is the likely result?Analyze
5. The runner must cap memory on Linux, macOS and Windows hosts, and a slightly late kill is acceptable. Which approach matches the lesson's choice?Design

Capturing stdout and stderr

The runner drains both pipes on completion. It scans stdout line by line and takes the last line that parses as json with all required metric_keys as the metrics blob. Earlier json lines that qualify are kept in the result as intermediate_metrics, which the evaluator can use for learning curves.

Stderr goes into the result verbatim. The runner does not raise on a non zero exit code. It records the code in the result instead. A non zero exit is labelled "crash" even when the script printed metrics, so the evaluator treats partial runs as failures by default. The exceptions are runs the runner itself killed: "oom" and "timeout" take precedence over "crash". A zero exit with no line covering metric_keys is also "crash".

Check: Capturing stdout and stderr0/6
1. A script exits with a non zero code after printing a complete, valid metrics line, and the runner did not kill it. Why is the run still labelled `crash`?Explain
2. How does the runner pick the final metrics blob out of stdout?Explain
3. A script outlives its `wall_timeout_s`, the runner kills it, and the process exit code ends up non zero. Which terminal label is reported?Apply
4. A script exits with code 0 and prints only `{"loss": 0.4}`, but `metric_keys` is `["perplexity", "final_loss"]`. What does the runner report?Apply
5. Given stdout `noise`, then a json line with keys `step` and `perplexity` only, then a json line with `perplexity` and `final_loss`, and required keys `perplexity` and `final_loss`, what does `_scan_intermediates` return?Apply
6. A script prints a json line per step holding only `step` and `loss`, then one final line with `perplexity` and `final_loss`. With `metric_keys` of `["perplexity", "final_loss"]`, the evaluator wants learning curves, but `intermediate_metrics` comes back empty. Why?Analyze

Ablation table

An ablation table is the set of results from running one base spec many times while a single configuration knob takes a different value on each run.

python
def ablate(base: ExperimentSpec, knob: str, values: list[Any]) -> list[ExperimentSpec]:
    ...

Given a base spec and a knob name, the helper returns one spec per value with config[knob] overridden. Each spec gets a derived spec_id (f"{base.spec_id}_{knob}_{value}"). The runner ships an AblationRunner that runs them in order and returns an AblationTable whose rows are (value, ExperimentResult) pairs in sweep order.

Why one knob at a time? Full factorial sweeps blow up exponentially and produce results the evaluator cannot interpret. One knob gives a clean axis the evaluator can plot. Multi knob sweeps are supported only as repeated single knob ablations, composed by the caller.

Check: Ablation table0/6
1. What is an ablation table?Define
2. Why does the ablation helper vary one knob at a time instead of running a full factorial sweep?Explain
3. What does the table hold after `AblationRunner.sweep(base, "k", [4, 8, 16])` returns?Apply
4. `ablate(base, "k", [4, 8])` is called with a base whose `spec_id` is `"demo_base"` and whose config is `{"k": 16, "steps": 4}`. What do the two returned specs look like?Apply
5. A base spec has seed 7 and a 15 second timeout. After `ablate(base, "steps", [2, 6])`, what do the two derived specs share with the base?Apply
6. You want to study learning rate with 3 values and batch size with 2 values, using this runner. Which design matches how the runner handles multiple knobs?Design

Determinism

Determinism here means the same spec produces the same metrics on every run. Each spec carries a seed, and the runner forwards it to the script through the config dict (config["__seed"] = spec.seed). The mock experiment scripts in code/experiments/ honour the seed and produce identical metrics across runs. The evaluator in lesson fifty-three depends on this. Without determinism, a "regression" might just be a different random initialisation.

Check: Determinism0/4
1. What does determinism mean for this runner?Define
2. What does forwarding `spec.seed` as `config["__seed"]` buy the evaluator?Explain
3. A spec with `seed=42` runs against the mock sparsity script. What does `result.metrics["seed"]` hold?Apply
4. Two runs of an identical spec, with the same seed and config, report different perplexity. Which explanation fits what the lesson says about the runner?Analyze

The mock experiment script

The mock experiment is code/experiments/sparsity_experiment.py. It is a real script that reads its config file, simulates a small training run with a numpy random pass, and prints a json metrics blob. It honours a sleep_s knob for testing timeouts and an allocate_mb knob for testing the memory poller.

A second script, experiments/crashing_experiment.py, prints one json line that lacks the metric keys, writes to stderr, and exits with code 3 so the crash label has something to test against. The simulation in the first script trains nothing real. It is a numerical computation that mimics the shape of a training loop: a loss curve, a final perplexity, a wall time. The subject here is the runner, not the simulation. A real experiment script would import a model.

Check: The mock experiment script0/5
1. `crashing_experiment.py` is run with `metric_keys=["perplexity"]`. Which result fields does the runner produce?Apply
2. The same `crashing_experiment.py` runs with `metric_keys=[]`. What do `terminal` and `metrics` hold?Apply
3. `sparsity_experiment.py` runs with `steps` 4 and a covering `metric_keys`. How many entries does `intermediate_metrics` have?Apply
4. In `sparsity_experiment.py` the loss subtracts `0.05 * min(k, 32) / 32`, and the noise comes only from the seeded generator. With the same seed and steps, how do the `final_loss` values compare for k=32 and k=64?Apply
5. You want to force the timeout path and the memory path using the mock script. Which config knobs do you set on `sparsity_experiment.py`?Apply

Result shape

text
ExperimentResult
  spec_id              : str
  hypothesis_id        : int
  exit_code            : int
  terminal             : "ok" | "timeout" | "oom" | "crash"
  wall_time_s          : float
  peak_rss_mb          : float | None
  metrics              : dict
  intermediate_metrics : list[dict]
  stdout_tail          : str
  stderr_tail          : str

The evaluator reads metrics and terminal first. If terminal is anything other than "ok", the experiment counts as a failed run and the evaluator's verdict is automatic. Otherwise the metrics go through the significance test.

Check: Result shape0/2
1. What does the `terminal` field of an `ExperimentResult` record?Define
2. A teammate's evaluator accepts any result whose `metrics` is non empty and ignores `terminal`. What risk does this create?Analyze
Author's Note

I'd write the timeout and crash paths before the success path. A runner that reports a clean run is easy to build, and my bet is that the terminal labels are where the evaluator's trust comes from. I'd keep crash for any non zero exit the runner didn't cause itself (timeout and oom win), even when metrics were printed, so a partial run counts as a failure by default.

I'd treat peak_rss_mb as information, not as a guarantee. The cap is soft, and recording the maximum observed RSS shows how close a run came to the limit.

On ablations, I'd stay with one knob at a time and compose multi knob sweeps in the caller. A clean axis is what I want to plot.

How to read the code

code/main.py defines ExperimentSpec, ExperimentResult, ExperimentRunner, AblationRunner, and a deterministic demo. Subprocess management lives in one class, the memory poller is a small thread, and the ablation helper is a single function.

code/experiments/sparsity_experiment.py is the mock experiment used in tests. It takes its config file path from argv, prints intermediate json lines per step, and ends with the final metrics line. code/experiments/crashing_experiment.py is the failing counterpart.

code/tests/test_runner.py covers the success path, the timeout path, the crash path, the ablation table, and the determinism check across two runs.

Where this slots in

Lesson fifty generates the hypothesis. Lesson fifty-one filters out anything the literature already settled. Lesson fifty-two, this one, runs the experiment for whatever is left. Lesson fifty-three reads the result, runs the significance test, and writes the verdict the orchestrator stores against the hypothesis id.

Warm-up0/3
1. A parent program launches a child process, and the child crashes partway through. What is the usual outcome for the parent?Warm-up
2. You want a script that uses random numbers to produce the same output every time you run it. What do you do?Warm-up
3. A Python script is started with `python run.py settings.json`. How does run.py read the text `settings.json`?Warm-up

Code