Experiment Runner
The 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.
Lesson 50 of 85Build~90 minutesPython
Before this: Phase 19 Track A lessons 20 to 29
On this page
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 research loop runs untrusted code. The hypothesis came from a sampler, the experiment script came from the same path; treating either as safe in-process is asking for a crash that takes the orchestrator down. Subprocesses are the simplest isolation the language ships: a separate process, an independent address space, a signal handle on the parent side.
The runner here does not implement full sandboxing. There is no cgroup, no seccomp filter, no namespace remapping. What it does have is a wall clock timeout, a polling loop for memory growth, and a kill path that terminates the process on either limit. That is the runtime contract every more elaborate sandbox extends. The lesson keeps the contract small enough to read in one sitting.
The ExperimentSpec shape
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 (passed to the script as one json arg)
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; the runner writes the config to a temp file path that the script reads. The script is expected to print a single json line on stdout whose keys are a superset of metric_keys. Anything else on stdout is captured but ignored by the metrics parser.
cg-runner-limits
Architecture
The runner is one class with one main method. The poller is a small thread that wakes once every poll interval and reads the subprocess psutil equivalent from the proc filesystem when available, falling back to no op when the platform does not expose it.
Why a soft memory cap
Hard memory caps need resource.setrlimit and only work on POSIX. The lesson ships a portable approach: poll 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 without process inspection support, the poller logs a one time warning and disables itself. The wall clock timeout still applies. The lesson tests cover both paths.
Capturing stdout and stderr
The runner reads both pipes drained on completion. Stdout is scanned line by line; the last line that parses as json with all required metric_keys is taken as the metrics blob. Earlier json lines are kept in the result as intermediate_metrics; the evaluator can use these for learning curves.
Stderr is captured verbatim into the result. The runner never raises on a non zero exit code; instead it records the code in the result. Any non zero exit is labelled "crash" even when the script printed metrics, so the evaluator treats partial runs as failures by default.
Ablation table
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 keyed by knob value.
Why one knob at a time. Full factorial sweeps blow up exponentially and produce results the evaluator cannot interpret. One knob at a time produces a clean axis the evaluator can plot. The lesson supports multi knob sweeps only as repeated single knob ablations, composed by the caller.
Determinism
Every spec carries a seed. The runner forwards the seed to the script via 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 be a different random initialisation.
The mock experiment script
The lesson ships one experiment script: 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. The script honours a sleep_s knob for testing timeouts and an allocate_mb knob for testing the memory poller.
The simulation is not training anything real. It is a numerical computation that mimics the shape of a training loop: a loss curve, a final perplexity, a wall time. The point of the lesson is the runner, not the simulation. A real experiment script would import a model.
Result shape
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 are passed through the significance test.
How to read the code
code/main.py defines ExperimentSpec, ExperimentResult, ExperimentRunner, AblationRunner, and a deterministic demo. The subprocess management is one class. The memory poller is a small thread. The ablation helper is a single function.
code/experiments/sparsity_experiment.py is the mock experiment used in tests. It reads its config file path from argv and writes a single json metrics line on completion.
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 runs the experiment for what is left. Lesson fifty-three reads the result, runs the significance test, and writes the verdict the orchestrator stores against the hypothesis id.
Check yourself
Quick quiz
Why does the runner spawn a subprocess instead of importing the experiment script in process?
- Because subprocess is faster than import
- Because a separate process gives the runner a kill handle on timeout or memory limits and isolates crashes from the orchestrator
- Because Python disallows nested imports
- Because the metrics format requires it
Show the answer
B. Because a separate process gives the runner a kill handle on timeout or memory limits and isolates crashes from the orchestrator A separate process is the simplest isolation the language ships. The parent retains a kill handle and an independent address space, so a crashing experiment cannot take the orchestrator with it.
Why is the memory cap described as soft?
- Because it only applies to the heap, not the stack
- Because the poller has a non zero interval, so a process can spike above the cap between polls and the runner only acts on the next observation
- Because the operating system always overrides it
- Because numpy ignores the cap
Show the answer
B. Because the poller has a non zero interval, so a process can spike above the cap between polls and the runner only acts on the next observation The cap is enforced by polling, not by an in kernel limit. The poller interval is the resolution of the cap, and short spikes can briefly exceed the cap before the next observation.
Which terminal label does the runner return when stdout has the metrics but the exit code is non zero?
- timeout
- crash
- ok
- oom
Show the answer
B. crash Terminal is ok only when exit code is zero and metrics parsed. A non zero exit code is crash even if some metrics were captured before the failure.
How does the runner identify the final metrics blob inside stdout?
- It scans stderr for json
- It expects exactly one line of output
- It reads the last json line whose keys cover the required metric_keys; earlier covering lines become intermediate metrics
- It reads the first json line
Show the answer
C. It reads the last json line whose keys cover the required metric_keys; earlier covering lines become intermediate metrics Final metrics are the last line that parses as json and includes every required key. Earlier covering lines are kept as intermediate metrics for learning curves.
Why does the ablation helper sweep one knob at a time rather than a full factorial?
- Because one knob at a time produces a clean axis the evaluator can plot, while full factorial blows up exponentially and confuses interpretation
- Because the seed only varies along one knob
- Because the runner cannot launch more than one subprocess
- Because numpy does not support the cartesian product
Show the answer
A. Because one knob at a time produces a clean axis the evaluator can plot, while full factorial blows up exponentially and confuses interpretation Single knob sweeps keep the result interpretable. Full factorial costs scale exponentially with the number of knobs and the resulting table is hard to read.
What guarantee does forwarding spec.seed into config['__seed'] give the evaluator?
- Lower memory consumption
- Faster wall time
- A free significance test
- Determinism, so a re run with the same seed produces identical metrics and the evaluator can detect real changes from random noise
Show the answer
D. Determinism, so a re run with the same seed produces identical metrics and the evaluator can detect real changes from random noise The seed pins random initialisation in the experiment. Two runs with the same seed produce identical metrics, which is what lets the evaluator distinguish a real regression from a different draw.
Questions people ask
How do you run AI-generated code safely?
Run it in an isolated process with limits on time, memory, files and network. Capture its output in a fixed format and treat crashes and timeouts as results, not surprises. Never run generated code with access to real data or credentials unless reviewed.
Why should experiment results be in a structured format?
A fixed format like JSON with named metrics lets the next step read results reliably. Free-form text invites misreading. Structured output also makes it easy to log, compare and audit every run.
Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License