Skip to content

API & CLI reference

Recorder (manual API)

The framework-agnostic core — the TRL adapter is a thin layer over this, and custom loops call it directly. No torch required.

from rlprobe import Recorder

rec = Recorder(
    "runs/my-run",
    algorithm="grpo",              # "grpo" | "ppo" | "rloo" | ... (manifest metadata)
    run_id=None,                   # default: generated
    model_name="my-org/my-model",
    trainer_config={...},          # dumped verbatim into the manifest
    world_size=1, rank=0,          # distributed semantics — see recording format
    capture_env=True,              # git sha, package versions, GPU info
    sampling=None,                 # SamplingConfig(top_k=2, bottom_k=2, random_k=4)
    queue_size=8192,               # bounded queue; full = drop + count, never block
    flush_interval_s=5.0,          # write part files at least this often
    steps_per_flush=200,           # ... or after this many buffered steps
    install_handlers=True,         # atexit + excepthook + SIGTERM flush
)
method behavior
log_step(step, **metrics) Record per-step scalars. Known names map to StepRecord fields; unknown numeric kwargs land in extra. Only enqueues — validation happens on the writer thread, so the call costs microseconds and never raises on bad values (a bad_record event is written instead).
log_samples(step, samples, select=True) Record per-sample forensics (dicts or SampleRecords). select=True applies the top/bottom-k + random sampling policy. The recorder takes ownership of the dicts — don't mutate them afterwards.
log_event(kind, step=None, message="", **data) Record a sparse event; written to disk immediately.
flush(timeout=5.0) Ask the writer thread to persist everything buffered; True when done. Needed only when something else reads the run dir mid-run (live diagnosis does this for you).
close() Drain, flush, compact part files into steps.parquet. Idempotent; also runs via context-manager exit and the installed exit handlers.
dropped Count of records dropped because the queue was full.
active False on non-recording ranks (the no-op recorder).

TRL integration

from rlprobe.integrations.trl import attach

callback = attach(trainer, "runs/my-run")                  # record everything
callback = attach(trainer, "runs/my-run", halt=True)       # + live guardian

attach(trainer, run_dir, **kwargs) constructs an RlprobeCallback, registers it on the trainer, and returns it. Keyword arguments:

kwarg default meaning
halt None True = default HaltPolicy; or pass a custom HaltPolicy; None/False = never halt
on_finding None callable invoked with each new Finding as it is first detected (works without halting)
diagnose_every 25 run live diagnosis every N steps (flushes, re-reads from disk, evaluates detectors)
doctor_config None per-detector threshold overrides (same shape as doctor.toml)
anything else forwarded to Recorder (e.g. sampling=, record_all_ranks=)

After a halt: callback.halted_by holds the triggering Finding, and the recording contains an auto_halt event. Requires trl>=1.7,<1.8 (the per-sample shim reads a private-but-stable buffer; if a future TRL removes it, sample capture degrades gracefully with a shim_unavailable event and scalars are unaffected).

HaltPolicy

from rlprobe.doctor.live import HaltPolicy

HaltPolicy(
    min_severity="critical",   # "info" | "warn" | "critical"
    min_confidence=0.7,
    signatures=None,           # None = any; or frozenset({"KL_BLOWUP", ...})
)

Conservative by default: only critical findings at ≥70% confidence — a warn-level hunch must never burn a checkpoint.

Doctor (Python API)

All read-side; none of it imports torch.

from rlprobe.doctor import diagnose
findings = diagnose("runs/my-run", config_path="doctor.toml")   # list[Finding], ranked
function purpose
diagnose(run_dir, config_path=None, config=None) full catalog over a recording → ranked list[Finding]
diagnose_view(view, config=None) same, over an in-memory RunView (live mode uses this)
RunView.load(run_dir) recording → numpy series (view.series("kl_mean"), view.component(name), view.samples)
RunWatcher(run_dir, sinks=[...]) incremental live tailing; each tick() returns only new findings
webhook_sink(url) Slack-compatible alert sink for RunWatcher
render_report(run_dir, out_path=None) self-contained HTML post-mortem → Path
diff_runs(run_a, run_b, z_thresh=4.0, sustain=5) config delta + metric divergence points + per-prompt gaps → RunDiff
query_samples(run, sort="reward", step_from=..., step_to=..., k=10) rank captured samples by reward / advantage / kl / length / logprob
spike_drivers(run, step, window=5, k=8) the samples most responsible for the update around step — ranked by advantage, the actual policy-gradient weight

A Finding is a pydantic model: signature_id, severity, confidence, first_step, explanation, evidence, suggested_actions, links_to_samples.

Storage layer

rlprobe.storageRunReader (typed steps() / samples() / events(), merges rank dirs), RunWriter (synchronous writer the Recorder wraps), list_runs(root), load_manifest(run_dir).

CLI

Every command works on a plain run directory; nothing needs a server or the training environment.

command purpose notable flags
rlprobe ls ROOT list recorded runs
rlprobe doctor RUN ranked diagnosis; exit 1 on critical (CI-gate ready) --json, --config doctor.toml
rlprobe watch RUN live-tail a (possibly not-yet-existing) run; report new findings; exit 1 if critical seen --poll 3.0, --webhook URL, --max-ticks N
rlprobe report RUN self-contained HTML post-mortem --out FILE, --config
rlprobe show RUN --step N metrics + captured samples around a step --window 5, --max-samples 10
rlprobe samples RUN forensic sample query --sort reward\|advantage\|kl\|length\|logprob, --around N --window K or --from A --to B, --asc, -k 10
rlprobe diff A B config delta + divergence points + per-prompt gaps --json, --z-thresh 4.0, --sustain 5
rlprobe synth DIR generate a synthetic healthy/pathological recording --pathology NAME, --n-steps 500, --seed 0

Exit codes are part of the contract: doctor and watch return 1 when a critical finding exists, so both drop straight into CI gates and shell automation.