Skip to main content

Automated research lab notebook: hypotheses, code diffs, results, and conclusions for every experiment you run.

Project description

labcoat

An automated research lab notebook. Experiment trackers record what the metrics were; labcoat records why you ran it, what you changed, and what you concluded — automatically.

labcoat run -- ./launch_seeds.sh --algo sac --env Walker2d-v4

Every launch captures the git state (HEAD + dirty diff as a reproducible patch), the exact command, and a hypothesis — prompted at launch, drafted by an LLM from your diff. Results flow in from your existing tracker (wandb first; labcoat never reimplements logging). Trials group automatically, statistics come with confidence intervals and significance tests, and an LLM pass drafts conclusions and surfaces anomalies in metrics you weren't watching.

See docs/walkthrough.md for a full worked example (PPO vs SAC, sweeps, multi-seed stats, the review flow).

Principles

  • labcoat never owns execution. It wraps whatever you already run — a multi-seed launcher script, a slurm submit, a sweep agent. Tracker runs link to the experiment through propagated environment variables; completion is tracked through tracker run states, so detached launchers just work.
  • Never reinvent logging. Metrics stay in wandb/tensorboard/etc., behind an adapter protocol.
  • The hypothesis is the soul. Capture is low-friction (prompt with an LLM draft, -m flag, backfill later) but always present in the record. And it's never the only record of intent: at every launch the LLM writes its own observation — an independent read of what the launch actually does (exact parameters changed, factors varied, metrics to watch, relation to prior experiments) — stored as an llm-authored note. Your one-liner says what you think; the observation documents what's going on, and both are searchable later (labcoat search, MCP).
  • Local-first. SQLite in .labcoat/ (gitignored); the generated labbook/ markdown is committed so your research history travels with the repo.

Installation

labcoat is on PyPI. The recommended install is as a uv tool, which puts the labcoat command on your PATH so you can call it directly from any repo — no uv run prefix, no activating environments:

uv tool install "labcoat[wandb]"

labcoat --version           # works from anywhere
labcoat run -- python train.py

Upgrade later with uv tool upgrade labcoat.

Alternatively, add it as a dependency of your research project (uv add "labcoat[wandb]") — then it runs as uv run labcoat or plain labcoat inside the activated environment. The tool install is the better default: the notebook wraps your project, it doesn't need to live inside it.

Quick start

cd your-research-repo
labcoat init                       # writes labcoat.toml, creates .labcoat/
labcoat run -- ./launch_seeds.sh   # hypothesis prompt (LLM-drafted from your diff)
labcoat log                        # timeline; lazy-syncs tracker state first
labcoat review                     # accept/edit drafted conclusions, metric proposals
labcoat compare exp-0001 exp-0002  # bootstrap CIs, Welch/Mann-Whitney, Cohen's d, Holm
labcoat show exp-0002 --diff       # full record incl. the captured patch
labcoat search "entropy collapse"  # months-later recall
labcoat book                       # regenerate committed labbook/ markdown
labcoat mcp                        # serve the record to coding agents (MCP)

Commands

init · run (-m hypothesis, -y accept drafts, --group attach-by-label, --pin shadow commit, -e/--experiment reuse-or-create by id, -p/--project) · list · log · show · compare · groups (list/name an experiment's trial groups) · ask (do we have the data to answer this?) · project (list/use/new/show/assign) · review · sync · search · book · mcp · annotate · conclude · exclude · merge (fold a mis-grouped experiment into another) · aggregate (fold experiments together) · impact (does a code change reach the launched script?) · link (attach a tracker run that launched outside labcoat run)

Concepts

The record is a strict hierarchy. From the top:

repository
└── project          one line of experimentation (a research question)
    └── experiment   one hypothesis
        ├── launch   one `labcoat run` invocation (command + code snapshot)
        └── run      one tracker run (e.g. one wandb run) the launch produced
            └─ grouped into ──┐
        ┌─────────────────────┘
        └── trial group       one distinct configuration within the experiment
            └── trial         one run of that exact configuration (a seed/replicate)

Project — a line of experimentation inside the repo, framed by a research question (e.g. ppo-clip: "does clip range matter?" vs arch-search). Declared in labcoat.toml as [projects.<name>], optionally with its own key metrics. Everything an experiment does stays inside its project: grouping candidates, varies_from lineage, and LLM context never cross projects. A repo that never declares projects has exactly one, implicit default project and behaves as if the layer didn't exist.

Experiment — one hypothesis under test. Created by labcoat run (with auto-grouping deciding new-vs-attach), belongs to exactly one project, and accumulates launches, runs, findings, notes, and a conclusion. Alongside the hypothesis, the LLM records its own observation of each new experiment — an independent description of what the launch actually changes.

Auto-grouping compares a launch against the whole project, nearest code first — not just the latest experiment. Two identity tiers are checked against every experiment: byte identity (same code + command), then a semantic impact digest — a Merkle-style hash of the AST of every module the entry point can reach — so reformatting, comment edits, and rewrites of unreachable code all still count as the same experiment. Beyond identity, candidates are ranked by impact-weighted code distance: changed lines bucketed into AST blocks (functions/methods/module residue) and weighted by whether that block can affect the launched script — provably-unreachable churn weighs zero, comment-only and moved code weighs ~zero, a changed command flag weighs like code. Run some experiments, spend a week on something else, come back — the returning launch reattaches to the original experiment (its trials land in the right trial groups), and a new experiment's varies_from lineage points at its nearest ancestor, not the unrelated detour.

Launch — one labcoat run invocation: the exact command plus a code snapshot (git HEAD + dirty patch). An experiment run on three machines has three launches.

Run — one tracker-side run (wandb etc.) discovered via the linking key. One launch of a multi-seed script yields many runs. labcoat never creates runs; it observes them.

Trial group — one distinct configuration within an experiment, derived automatically from the config axes that vary across its runs (a sweep over lr yields one group per lr value; seed-like keys never split groups). The group is the unit statistics compare. Groups are nameable (labcoat groups exp-0012 --name 1 baseline) and their identity is deterministic (experiment × config axes), so two machines deriving them independently converge to the same records on sync. An experiment whose runs all share one configuration has a single implicit group, shown as (all).

Trial — one run within a trial group: a replicate of that exact configuration. Trials are what give a group its n.

Breaking change (v0.2). This hierarchy replaces the v0.1 flat record: "arms" are now persisted trial-group entities (the runs.arm label column is gone) and every experiment belongs to a project. Pre-0.2 databases and sync records are not migrated — delete .labcoat/labcoat.db (and any record-sync remote) and re-init.

[projects.ppo-clip]
description = "PPO clipping ablations"
question = "Does clip range materially affect final return?"

[[projects.ppo-clip.metrics]]     # optional: replaces the global [[metrics]]
name = "eval/return"              # for this project (wholesale, not merged)
primary = true
$ labcoat project use ppo-clip        # active project for this machine
$ labcoat run -p arch-search -- ...   # or per-launch
$ labcoat groups exp-0012             # the derived trial groups
  #  NAME      LABEL       RUNS  eval/return
  1  —         lr=0.0001      4  96 ± 1.4 (n=4)
  2  baseline  lr=0.001       4  81 ± 1.4 (n=4)
$ labcoat groups exp-0012 --name 1 "tuned"   # names survive re-syncs & travel

Do we have the data to answer this? (ask)

labcoat ask (and the MCP tool assess_hypothesis) checks a new question against everything already recorded — every experiment's trial groups and which metrics each recorded — then answers answerable, partial, or no. When the evidence exists it doesn't stop at pointing: it runs the real statistics on the identified groups.

$ labcoat ask "does a smaller learning rate help final return?"
◆ answerable — exp-0012 sweeps lr with eval/return recorded on both sides (n=4/group)

eval/return · final · n=4 vs n=4
  exp-0012::lr=0.001  (A): 81 ± 1.4 (n=4)
  exp-0012::lr=0.0001 (B): 96 ± 1.4 (n=4)
  Δ = +15   95% bootstrap CI [+13.2, +16.8]
  Welch p=0.0001 · d=10
  → B better on this metric; unlikely to be noise (p=0.0001)

Every group and metric the LLM cites is validated against the record before any statistics run — hallucinated references are dropped, never computed. Without an LLM the command degrades to the data inventory plus keyword matches.

Does this change actually affect my run? (impact)

labcoat builds a static import + call graph from the launch command and checks whether your changed lines can even execute. It's used two ways:

  • Grouping is now provable. When a change cannot reach the launched entry point, the new run is grouped as a trial of the prior experiment with certainty — no LLM guess. Reachable changes still get the LLM's does-it-matter judgment.
  • labcoat impact inspects it directly — your uncommitted changes, or an existing experiment's:
$ labcoat impact --entry train.py
  verdict UNRELATED — (2 unrelated)
  ○ plotting.py · render_curves — symbol is never referenced anywhere reachable
  ○ notebooks/scratch.py — module is never imported or spawned from the entry point

The analysis is sound for the negative: it reports UNRELATED only when it can prove the change can't run. Dynamic imports, getattr, or a subprocess whose target it can't resolve degrade to UNCERTAIN rather than risk a false "unrelated" that would merge two genuinely different experiments. Subprocess launchers (labcoat run -- ./launch.sh) are followed when the spawned script is a literal path/-m target.

Splitting one experiment across machines

Pass an explicit id to tie separate launches together — even from different machines — without any shared state:

# on your laptop, and again on an EC2 box, and again on a slurm node:
labcoat run -e pfsp-sweep -- ./train.sh --seed 1
labcoat run -e pfsp-sweep -- ./train.sh --seed 2

The id becomes the tracker linking key, so every run stamped pfsp-sweep aggregates into one experiment on whichever machine syncs — no git, no shared DB. Reusing an existing id attaches another launch (the hypothesis is kept). To reunite experiments that were created separately, merge them after the fact with labcoat aggregate <target> <source>….

Syncing the notebook across workstations

The record — hypotheses, code snapshots (with the captured patch), launches, runs, metrics, findings, notes, conclusions — lives in a local SQLite database, so labcoat works fully offline. Point [sync] at a remote and that record propagates to every workstation, no git commits from throwaway machines required:

[sync]
remote = "s3://my-lab/labcoat"   # "wandb", or any fsspec url (s3://, gs://, hf://)
# auto = false                   # opt out of pull-before-read / push-after-write
  • remote = "wandb" rides on the wandb project you already use — the record travels as a versioned labcoat-record artifact. No extra credentials.
  • An object store (s3://…, gs://…, hf://datasets/user/repo, or a local path) needs the matching driver — uv tool install "labcoat[s3]" (or gcs, hf) — and reads credentials from the usual provider environment chains (AWS_*, GOOGLE_APPLICATION_CREDENTIALS, HF_TOKEN). labcoat never stores keys.

With auto on (the default), reads pull the latest first and record-producing commands push when they finish; labcoat sync always reconciles both directions. Merging is by stable UUID with last-writer-wins on each experiment's own fields and a union of its launches/runs/notes, so concurrent work on different machines converges without clobbering. A -e id gets the same UUID on every machine, so those launches merge into a single experiment on sync. The local database stays the source of truth: a missing or misconfigured remote degrades to a warning, never a blocked command.

Artifact experiments (generative models, VLM judging)

Not every experiment logs metrics — sometimes the result is a file: images or videos from a generation API, rendered outputs, model responses. labcoat handles these with watched-directory capture and a VLM rubric:

[adapters.artifacts]
watch = ["outputs/"]          # where your script writes/downloads results

[[criteria]]                  # the rubric — criteria play the role of metrics
name = "prompt_adherence"
description = "How faithfully does the image depict the requested prompt?"
scale = 10

[[criteria]]
name = "anatomical_quality"
description = "Hands, faces, limbs are plausible; no extra fingers."
scale = 10
primary = true
$ labcoat run -m "cfg=4.5 fixes hand artifacts" -- python generate.py
  ↳ wrapper exited (0)
  ↻ artifacts: 8 new file(s) under outputs/ → exp-0003

$ labcoat sync
  ↻ exp-0003: judged 8 artifact(s) × 2 criteria
  ◈ artifacts: 2/8 images still show fused fingers

$ labcoat compare exp-0002 exp-0003
  anatomical_quality · n=8 vs n=8
    Δ = +2.1  [95% CI: +0.7, +3.5] · Welch p=0.012

How it works: files that appear under the watched dirs during a launch are linked to the experiment (use labcoat.artifact(path) in your script for outputs elsewhere). Each artifact is recorded by reference + sha256 — files stay where your script put them, and scores/observations survive even if outputs are cleaned. At sync, a vision-capable LLM scores every artifact against the rubric; each artifact counts as one sample, so the full statistics pipeline (CIs, significance, compare) works with n = number of generations. Per-artifact observations aggregate into findings, and judgments are cached by content hash — reopening an experiment never re-judges old artifacts. Videos are judged from evenly spaced frames (requires ffmpeg; captured-but-not-judged without it).

LLM configuration

The LLM drafts hypotheses from your diffs, summarizes changes, groups runs semantically, narrates discovery findings, and drafts conclusions. It is configured in labcoat.toml (any litellm model string) with credentials from the environment — labcoat never stores keys:

[llm]
model = "anthropic/claude-sonnet-4-6"
context_budget = 12000     # max tokens of experiment history per call
export ANTHROPIC_API_KEY=sk-ant-...   # or OPENAI_API_KEY, GEMINI_API_KEY, LITELLM_API_KEY

Local models via ollama — no key needed; just point at a local model:

[llm]
model = "ollama/llama3.1"
# api_base = "http://gpu-box:11434"   # only if ollama isn't on localhost:11434
# autostart_ollama = false            # opt out of launching the daemon for you

If the model is a local ollama one and the daemon isn't running, labcoat starts it on first use (bounded wait, then degrades if it can't) — no ollama serve by hand. It won't pull missing weights, though: run ollama pull <model> once. Remote hosts (api_base elsewhere) are left untouched.

api_base also works for any OpenAI-compatible server (vLLM, LM Studio):

[llm]
model = "hosted_vllm/meta-llama/Llama-3.1-70B-Instruct"
api_base = "http://localhost:8000/v1"

Without any of these, every LLM feature degrades gracefully: launches never block, raw diffs are stored instead of summaries, grouping falls back to byte-identity, and conclusions are written by hand in labcoat review. Set enabled = false under [llm] to turn the LLM off explicitly, and labcoat exclude exp-NNNN to keep any experiment out of LLM context.

Development

The project is managed with uv. From a checkout:

uv sync                 # creates .venv from uv.lock (includes the dev group)
uv run pytest           # run the test suite
uv run labcoat --help   # run the CLI from source

Extras: uv sync --extra wandb --extra mcp to develop against the adapters.

Releases go to PyPI with:

uv build                # sdist + wheel into dist/
uv publish              # needs a PyPI token (UV_PUBLISH_TOKEN)

Status

v0.2: the full loop works — launch capture (git patch snapshots, optional shadow commits), LLM diff summaries + hypothesis drafts (litellm; degrades gracefully without a key), wandb ingestion with adapter-state-driven lazy sync, semantic auto-grouping (with static code-reachability), projects namespacing separate lines of experimentation, trial groups auto-derived from run configs as first-class nameable entities, the statistics toolkit, the discovery pass over all logged series, evidence assessment for new hypotheses (ask / MCP assess_hypothesis), drafted conclusions with a review flow, committed labbook/ rendering, search, an MCP server, and remote record sync (wandb or any object store) so the notebook travels across workstations. Planned next: tensorboard/mlflow adapters.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

labcoat-0.1.3.tar.gz (612.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

labcoat-0.1.3-py3-none-any.whl (110.9 kB view details)

Uploaded Python 3

File details

Details for the file labcoat-0.1.3.tar.gz.

File metadata

  • Download URL: labcoat-0.1.3.tar.gz
  • Upload date:
  • Size: 612.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for labcoat-0.1.3.tar.gz
Algorithm Hash digest
SHA256 628cea5372d3836f1fe832048799578619dc14de86c066af19ff9bda6903ec1f
MD5 40df4d6d351b9257d79ea1995dcb61b1
BLAKE2b-256 3432c5966b55b02c7df95227b19a43cdaf072b30a25c4aab7f37b6ecbd522604

See more details on using hashes here.

File details

Details for the file labcoat-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: labcoat-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 110.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for labcoat-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 825957ceefd33da9874a7347a43c352ecb4348ab5eac338dd37e7780caa4ad8c
MD5 4230e680933e9856062743ce3f910345
BLAKE2b-256 75aa23c6263171bc19cdbcea5892b1d73f62dd3609387abc528f7fe7e936ba6f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page